Files
optimclaw/src/config.rs
T
Illia PolosukhinandClaude Opus 4.5 32bfd24154 Add WASM sandbox secure API extension
Extends the WASM sandbox with HTTP API capabilities, secrets management,
tool aliasing, and leak detection. Key security principle: WASM never
sees credentials, injection happens at host boundary.

New modules:
- secrets: AES-256-GCM encrypted storage with HKDF key derivation
- leak_detector: Aho-Corasick + regex pattern matching for secret exfiltration
- capabilities: Extended capability system (HTTP, ToolInvoke, Secrets)
- allowlist: HTTP endpoint validation with glob patterns
- credential_injector: Host-boundary credential injection
- rate_limiter: Sliding window per-tool rate limiting
- storage: WASM binary storage with BLAKE3 integrity verification

Leak detection happens at two points:
1. Before HTTP request (prevents exfiltration via URL/headers/body)
2. After response (prevents exposure in outputs returned to WASM)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-02 23:22:52 -08:00

397 lines
12 KiB
Rust

//! Configuration for the NEAR Agent.
use std::path::PathBuf;
use std::time::Duration;
use secrecy::{ExposeSecret, SecretString};
use crate::error::ConfigError;
/// Main configuration for the agent.
#[derive(Debug, Clone)]
pub struct Config {
pub database: DatabaseConfig,
pub llm: LlmConfig,
pub channels: ChannelsConfig,
pub agent: AgentConfig,
pub safety: SafetyConfig,
pub wasm: WasmConfig,
pub secrets: SecretsConfig,
}
impl Config {
/// Load configuration from environment variables.
pub fn from_env() -> Result<Self, ConfigError> {
// Load .env file if present (ignore errors if not found)
let _ = dotenvy::dotenv();
Ok(Self {
database: DatabaseConfig::from_env()?,
llm: LlmConfig::from_env()?,
channels: ChannelsConfig::from_env()?,
agent: AgentConfig::from_env()?,
safety: SafetyConfig::from_env()?,
wasm: WasmConfig::from_env()?,
secrets: SecretsConfig::from_env()?,
})
}
}
/// Database configuration.
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
pub url: SecretString,
pub pool_size: usize,
}
impl DatabaseConfig {
fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
url: SecretString::from(required_env("DATABASE_URL")?),
pool_size: optional_env("DATABASE_POOL_SIZE")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "DATABASE_POOL_SIZE".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.unwrap_or(10),
})
}
/// Get the database URL (exposes the secret).
pub fn url(&self) -> &str {
self.url.expose_secret()
}
}
/// LLM provider configuration (NEAR AI only).
#[derive(Debug, Clone)]
pub struct LlmConfig {
pub nearai: NearAiConfig,
}
/// NEAR AI chat-api configuration.
#[derive(Debug, Clone)]
pub struct NearAiConfig {
/// Session token for authentication (format: sess_xxx)
pub session_token: SecretString,
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
pub model: String,
/// Base URL for the NEAR AI chat-api (default: https://api.near.ai)
pub base_url: String,
}
impl LlmConfig {
fn from_env() -> Result<Self, ConfigError> {
let session_token = required_env("NEARAI_SESSION_TOKEN")?;
Ok(Self {
nearai: NearAiConfig {
session_token: SecretString::from(session_token),
model: optional_env("NEARAI_MODEL")?
.unwrap_or_else(|| "claude-3-5-sonnet-20241022".to_string()),
base_url: optional_env("NEARAI_BASE_URL")?
.unwrap_or_else(|| "https://api.near.ai".to_string()),
},
})
}
}
/// Channel configurations.
#[derive(Debug, Clone)]
pub struct ChannelsConfig {
pub cli: CliConfig,
pub slack: Option<SlackConfig>,
pub telegram: Option<TelegramConfig>,
pub http: Option<HttpConfig>,
}
#[derive(Debug, Clone)]
pub struct CliConfig {
pub enabled: bool,
}
#[derive(Debug, Clone)]
pub struct SlackConfig {
pub bot_token: SecretString,
pub app_token: SecretString,
pub signing_secret: SecretString,
}
#[derive(Debug, Clone)]
pub struct TelegramConfig {
pub bot_token: SecretString,
}
#[derive(Debug, Clone)]
pub struct HttpConfig {
pub host: String,
pub port: u16,
pub webhook_secret: Option<SecretString>,
}
impl ChannelsConfig {
fn from_env() -> Result<Self, ConfigError> {
let slack = match (
optional_env("SLACK_BOT_TOKEN")?,
optional_env("SLACK_APP_TOKEN")?,
optional_env("SLACK_SIGNING_SECRET")?,
) {
(Some(bot_token), Some(app_token), Some(signing_secret)) => Some(SlackConfig {
bot_token: SecretString::from(bot_token),
app_token: SecretString::from(app_token),
signing_secret: SecretString::from(signing_secret),
}),
(None, None, None) => None,
_ => {
return Err(ConfigError::InvalidValue {
key: "SLACK_*".to_string(),
message: "all Slack environment variables must be set together".to_string(),
});
}
};
let telegram = optional_env("TELEGRAM_BOT_TOKEN")?.map(|token| TelegramConfig {
bot_token: SecretString::from(token),
});
let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() {
Some(HttpConfig {
host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()),
port: optional_env("HTTP_PORT")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "HTTP_PORT".to_string(),
message: format!("must be a valid port number: {e}"),
})?
.unwrap_or(8080),
webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from),
})
} else {
None
};
Ok(Self {
cli: CliConfig { enabled: true },
slack,
telegram,
http,
})
}
}
/// Agent behavior configuration.
#[derive(Debug, Clone)]
pub struct AgentConfig {
pub name: String,
pub max_parallel_jobs: usize,
pub job_timeout: Duration,
pub stuck_threshold: Duration,
pub repair_check_interval: Duration,
pub max_repair_attempts: u32,
}
impl AgentConfig {
fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
name: optional_env("AGENT_NAME")?.unwrap_or_else(|| "near-agent".to_string()),
max_parallel_jobs: parse_optional_env("AGENT_MAX_PARALLEL_JOBS", 5)?,
job_timeout: Duration::from_secs(parse_optional_env("AGENT_JOB_TIMEOUT_SECS", 3600)?),
stuck_threshold: Duration::from_secs(parse_optional_env(
"AGENT_STUCK_THRESHOLD_SECS",
300,
)?),
repair_check_interval: Duration::from_secs(parse_optional_env(
"SELF_REPAIR_CHECK_INTERVAL_SECS",
60,
)?),
max_repair_attempts: parse_optional_env("SELF_REPAIR_MAX_ATTEMPTS", 3)?,
})
}
}
/// Safety configuration.
#[derive(Debug, Clone)]
pub struct SafetyConfig {
pub max_output_length: usize,
pub injection_check_enabled: bool,
}
impl SafetyConfig {
fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
max_output_length: parse_optional_env("SAFETY_MAX_OUTPUT_LENGTH", 100_000)?,
injection_check_enabled: optional_env("SAFETY_INJECTION_CHECK_ENABLED")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "SAFETY_INJECTION_CHECK_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
})
}
}
/// WASM sandbox configuration.
#[derive(Debug, Clone)]
pub struct WasmConfig {
/// Whether WASM tool execution is enabled.
pub enabled: bool,
/// Default memory limit in bytes (default: 10 MB).
pub default_memory_limit: u64,
/// Default execution timeout in seconds (default: 60).
pub default_timeout_secs: u64,
/// Default fuel limit for CPU metering (default: 10M).
pub default_fuel_limit: u64,
/// Whether to cache compiled modules.
pub cache_compiled: bool,
/// Directory for compiled module cache.
pub cache_dir: Option<PathBuf>,
}
/// Secrets management configuration.
#[derive(Clone, Default)]
pub struct SecretsConfig {
/// Master key for encrypting secrets (loaded from SECRETS_MASTER_KEY env var).
/// Must be at least 32 bytes for AES-256-GCM.
pub master_key: Option<SecretString>,
/// Whether secrets management is enabled.
pub enabled: bool,
}
impl std::fmt::Debug for SecretsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretsConfig")
.field("master_key", &self.master_key.is_some())
.field("enabled", &self.enabled)
.finish()
}
}
impl SecretsConfig {
fn from_env() -> Result<Self, ConfigError> {
let master_key = optional_env("SECRETS_MASTER_KEY")?.map(SecretString::from);
let enabled = master_key.is_some();
// Validate master key length if provided
if let Some(ref key) = master_key {
if key.expose_secret().len() < 32 {
return Err(ConfigError::InvalidValue {
key: "SECRETS_MASTER_KEY".to_string(),
message: "must be at least 32 bytes for AES-256-GCM".to_string(),
});
}
}
Ok(Self {
master_key,
enabled,
})
}
/// Get the master key if configured.
pub fn master_key(&self) -> Option<&SecretString> {
self.master_key.as_ref()
}
}
impl Default for WasmConfig {
fn default() -> Self {
Self {
enabled: true,
default_memory_limit: 10 * 1024 * 1024, // 10 MB
default_timeout_secs: 60,
default_fuel_limit: 10_000_000,
cache_compiled: true,
cache_dir: None,
}
}
}
impl WasmConfig {
fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
enabled: optional_env("WASM_ENABLED")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "WASM_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
default_memory_limit: parse_optional_env(
"WASM_DEFAULT_MEMORY_LIMIT",
10 * 1024 * 1024,
)?,
default_timeout_secs: parse_optional_env("WASM_DEFAULT_TIMEOUT_SECS", 60)?,
default_fuel_limit: parse_optional_env("WASM_DEFAULT_FUEL_LIMIT", 10_000_000)?,
cache_compiled: optional_env("WASM_CACHE_COMPILED")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "WASM_CACHE_COMPILED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
cache_dir: optional_env("WASM_CACHE_DIR")?.map(PathBuf::from),
})
}
/// Convert to WasmRuntimeConfig.
pub fn to_runtime_config(&self) -> crate::tools::wasm::WasmRuntimeConfig {
use crate::tools::wasm::{FuelConfig, ResourceLimits, WasmRuntimeConfig};
use std::time::Duration;
WasmRuntimeConfig {
default_limits: ResourceLimits {
memory_bytes: self.default_memory_limit,
fuel: self.default_fuel_limit,
timeout: Duration::from_secs(self.default_timeout_secs),
},
fuel_config: FuelConfig {
initial_fuel: self.default_fuel_limit,
enabled: true,
},
cache_compiled: self.cache_compiled,
cache_dir: self.cache_dir.clone(),
optimization_level: wasmtime::OptLevel::Speed,
}
}
}
// Helper functions
fn required_env(key: &str) -> Result<String, ConfigError> {
std::env::var(key).map_err(|_| ConfigError::MissingEnvVar(key.to_string()))
}
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
match std::env::var(key) {
Ok(val) if val.is_empty() => Ok(None),
Ok(val) => Ok(Some(val)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(e) => Err(ConfigError::ParseError(format!(
"failed to read {key}: {e}"
))),
}
}
fn parse_optional_env<T>(key: &str, default: T) -> Result<T, ConfigError>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
optional_env(key)?
.map(|s| {
s.parse().map_err(|e| ConfigError::InvalidValue {
key: key.to_string(),
message: format!("{e}"),
})
})
.transpose()
.map(|opt| opt.unwrap_or(default))
}