Files
optimclaw/src/sandbox/config.rs
T
097a26ace6 fix: harden openai-compatible provider, approval replay, and embeddings defaults (#237)
* fix: harden openai-compatible tool flow and local defaults

* fix: close approval replay gaps and harden openai-compatible flow

* fix: address review feedback and code improvements (takeover #112)

- Make ChatCompletionResponse.id Optional<String> to handle providers
  that omit or null the field
- Propagate HTTP client builder errors instead of silently dropping
  timeout configuration (openai_compatible_chat, nearai_chat)
- Add EMBEDDING_DIMENSION env var with smart per-model defaults instead
  of hardcoding 768/1536 everywhere
- Remove duplicated dimension inference logic from main.rs

Co-Authored-By: panosAthDBX <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: harden src/llm/ module from crate audit findings

- Replace 9x .expect() on RwLock with graceful poison recovery
  (nearai.rs: 7, nearai_chat.rs: 2) — eliminates production panics
- Propagate HTTP client builder errors in nearai.rs instead of
  silently dropping timeout config (NearAiProvider::new now returns Result)
- Make nearai_chat ChatCompletionResponse.id Optional<String>
  (mirrors openai_compatible_chat.rs fix for providers that omit id)
- Make nearai_chat usage fields optional with defensive parse_usage()
  helper (was required u32 fields that crash on null/missing)
- Truncate error responses to 512 chars in nearai_chat.rs error
  messages to prevent log bloat and potential data leakage
- Delegate 4 missing LlmProvider methods in FailoverProvider
  (model_metadata, seed_response_chain, get_response_chain_id,
  calculate_cost) to last-used provider instead of trait defaults

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor(llm): add RetryProvider, remove openai_compatible_chat, harden decorators

- Add composable RetryProvider decorator wrapping any LlmProvider with
  exponential backoff + jitter, respecting RateLimited retry_after hints
- Remove openai_compatible_chat.rs — replaced by rig adapter + RetryProvider
- Remove internal retry loop from nearai.rs (was causing double-retry
  with external RetryProvider, up to 16 attempts instead of 4)
- Remove internal retry loop from nearai_chat.rs (same issue)
- Wire RetryProvider into main.rs composition chain: each provider gets
  its own retry wrapper before failover
- Move normalize_tool_name to rig_adapter.rs for all rig-based providers
- Reconcile is_retryable() vs is_transient() error classification:
  ModelNotAvailable no longer retryable, Json no longer transient
- Fix unchecked Duration subtraction panic in circuit_breaker.rs
- Make failover.rs use shared is_retryable() from retry.rs
- Remove stale #[allow(dead_code)] on NearAiResponse::id (field is used)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback — error handling, dimension validation, libSQL warning

- Replace response.text().await.unwrap_or_default() with proper error
  propagation in nearai.rs and nearai_chat.rs (4 call sites). Failures
  now return LlmError::RequestFailed with context instead of silently
  proceeding with an empty string.
- Add embedding dimension validation in OllamaEmbeddings::embed_batch():
  returns EmbeddingError if Ollama returns embeddings with a dimension
  that doesn't match the configured value.
- Add runtime warning when libSQL backend is used with non-1536 embedding
  dimension, since the libSQL schema uses F32_BLOB(1536) and cannot store
  different-dimension vectors.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Apply suggestions from code review

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: panosAthDbx <[email protected]>
Co-authored-by: panosAthDBX <[email protected]>
Co-authored-by: panosAthDBX <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Copilot <[email protected]>
2026-02-19 23:05:04 +00:00

217 lines
7.8 KiB
Rust

//! Configuration for the Docker execution sandbox.
use std::time::Duration;
/// Configuration for the sandbox system.
#[derive(Debug, Clone)]
pub struct SandboxConfig {
/// Whether the sandbox is enabled.
pub enabled: bool,
/// Security policy for sandbox execution.
pub policy: SandboxPolicy,
/// Default timeout for command execution.
pub timeout: Duration,
/// Memory limit in megabytes.
pub memory_limit_mb: u64,
/// CPU shares (relative weight, default 1024).
pub cpu_shares: u32,
/// Network allowlist for proxied requests.
pub network_allowlist: Vec<String>,
/// Docker image to use for the sandbox.
pub image: String,
/// Whether to auto-pull the image if not found.
pub auto_pull_image: bool,
/// Port for the HTTP proxy (0 = auto-assign).
pub proxy_port: u16,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
enabled: false, // Disabled by default until Docker is confirmed available
policy: SandboxPolicy::ReadOnly,
timeout: Duration::from_secs(120),
memory_limit_mb: 2048,
cpu_shares: 1024,
network_allowlist: default_allowlist(),
image: "ironclaw-worker:latest".to_string(),
auto_pull_image: true,
proxy_port: 0,
}
}
}
/// Security policy for sandbox execution.
///
/// ```text
/// ┌─────────────────────────────────────────────────────────────────────┐
/// │ Sandbox Policies │
/// ├─────────────────┬──────────────────┬────────────────────────────────┤
/// │ Policy │ Filesystem │ Network │
/// ├─────────────────┼──────────────────┼────────────────────────────────┤
/// │ ReadOnly │ /workspace (ro) │ Proxied (allowlist only) │
/// │ WorkspaceWrite │ /workspace (rw) │ Proxied (allowlist only) │
/// │ FullAccess │ Full host │ Full network (DANGER) │
/// └─────────────────┴──────────────────┴────────────────────────────────┘
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxPolicy {
/// Read-only access to workspace, proxied network.
/// Use for: exploring code, fetching docs, read-only operations.
#[default]
ReadOnly,
/// Read/write access to workspace, proxied network.
/// Use for: building software, running tests, generating files.
WorkspaceWrite,
/// Full access (no sandbox). Use with extreme caution.
/// This bypasses all isolation and runs directly on host.
FullAccess,
}
impl SandboxPolicy {
/// Returns true if filesystem writes are allowed.
pub fn allows_writes(&self) -> bool {
matches!(
self,
SandboxPolicy::WorkspaceWrite | SandboxPolicy::FullAccess
)
}
/// Returns true if network requests bypass the proxy.
pub fn has_full_network(&self) -> bool {
matches!(self, SandboxPolicy::FullAccess)
}
/// Returns true if running in a container.
pub fn is_sandboxed(&self) -> bool {
!matches!(self, SandboxPolicy::FullAccess)
}
}
impl std::str::FromStr for SandboxPolicy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"readonly" | "read_only" | "ro" => Ok(SandboxPolicy::ReadOnly),
"workspacewrite" | "workspace_write" | "rw" => Ok(SandboxPolicy::WorkspaceWrite),
"fullaccess" | "full_access" | "full" | "none" => Ok(SandboxPolicy::FullAccess),
_ => Err(format!(
"invalid sandbox policy '{}', expected 'readonly', 'workspace_write', or 'full_access'",
s
)),
}
}
}
/// Resource limits for container execution.
#[derive(Debug, Clone)]
pub struct ResourceLimits {
/// Maximum memory in bytes.
pub memory_bytes: u64,
/// CPU shares (relative weight).
pub cpu_shares: u32,
/// Maximum execution time.
pub timeout: Duration,
/// Maximum output size in bytes.
pub max_output_bytes: usize,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
memory_bytes: 2 * 1024 * 1024 * 1024, // 2 GB
cpu_shares: 1024,
timeout: Duration::from_secs(120),
max_output_bytes: 64 * 1024, // 64 KB
}
}
}
/// Default network allowlist for common development operations.
pub fn default_allowlist() -> Vec<String> {
vec![
// Package registries
"crates.io".to_string(),
"static.crates.io".to_string(),
"index.crates.io".to_string(),
"registry.npmjs.org".to_string(),
"proxy.golang.org".to_string(),
"pypi.org".to_string(),
"files.pythonhosted.org".to_string(),
// Documentation
"docs.rs".to_string(),
"doc.rust-lang.org".to_string(),
"nodejs.org".to_string(),
"go.dev".to_string(),
"docs.python.org".to_string(),
// Version control (read-only)
"github.com".to_string(),
"raw.githubusercontent.com".to_string(),
"api.github.com".to_string(),
"codeload.github.com".to_string(),
// Common APIs (credentials will be injected by proxy)
"api.openai.com".to_string(),
"api.anthropic.com".to_string(),
"api.near.ai".to_string(),
]
}
/// Default credential mappings for common APIs.
pub fn default_credential_mappings() -> Vec<crate::secrets::CredentialMapping> {
use crate::secrets::CredentialMapping;
vec![
CredentialMapping::bearer("OPENAI_API_KEY", "api.openai.com"),
CredentialMapping::header("ANTHROPIC_API_KEY", "x-api-key", "api.anthropic.com"),
CredentialMapping::bearer("NEARAI_API_KEY", "api.near.ai"),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_policy_parsing() {
assert_eq!(
"readonly".parse::<SandboxPolicy>().unwrap(),
SandboxPolicy::ReadOnly
);
assert_eq!(
"workspace_write".parse::<SandboxPolicy>().unwrap(),
SandboxPolicy::WorkspaceWrite
);
assert_eq!(
"full_access".parse::<SandboxPolicy>().unwrap(),
SandboxPolicy::FullAccess
);
assert!("invalid".parse::<SandboxPolicy>().is_err());
}
#[test]
fn test_policy_properties() {
assert!(!SandboxPolicy::ReadOnly.allows_writes());
assert!(SandboxPolicy::WorkspaceWrite.allows_writes());
assert!(SandboxPolicy::FullAccess.allows_writes());
assert!(!SandboxPolicy::ReadOnly.has_full_network());
assert!(!SandboxPolicy::WorkspaceWrite.has_full_network());
assert!(SandboxPolicy::FullAccess.has_full_network());
assert!(SandboxPolicy::ReadOnly.is_sandboxed());
assert!(SandboxPolicy::WorkspaceWrite.is_sandboxed());
assert!(!SandboxPolicy::FullAccess.is_sandboxed());
}
#[test]
fn test_default_allowlist_has_common_registries() {
let allowlist = default_allowlist();
assert!(allowlist.contains(&"crates.io".to_string()));
assert!(allowlist.contains(&"registry.npmjs.org".to_string()));
assert!(allowlist.contains(&"github.com".to_string()));
}
}