Security Model & Sandboxing
This page covers the runtime controls that constrain what agents can do and how untrusted execution is contained inside LibreFang.
Included Topics
- Capability-Based Security
- WASM Dual Metering
- Path Traversal Prevention
- Subprocess Sandbox
- Dangerous Command Detection
Capability-Based Security
Source: librefang-types/src/capability.rs
LibreFang uses capability-based security. An agent can only perform actions it has been explicitly granted permission to do. Capabilities are immutable after agent creation and are enforced at the kernel level.
Capability Variants
The Capability enum defines every permission type:
pub enum Capability {
// Filesystem
FileRead(String), // Glob pattern, e.g. "/data/*"
FileWrite(String),
// Network
NetConnect(String), // Host:port pattern, e.g. "*.openai.com:443"
NetListen(u16),
// Tools
ToolInvoke(String), // Specific tool ID
ToolAll, // All tools (dangerous)
// LLM
LlmQuery(String),
LlmMaxTokens(u64),
// Agent interaction
AgentSpawn,
AgentMessage(String),
AgentKill(String),
// Memory
MemoryRead(String),
MemoryWrite(String),
// Shell
ShellExec(String),
EnvRead(String),
// OFP Wire Protocol
OfpDiscover,
OfpConnect(String),
OfpAdvertise,
// Economic
EconSpend(f64),
EconEarn,
EconTransfer(String),
}
Pattern Matching
The capability_matches(granted, required) function implements glob-style
matching:
- Exact match:
"api.openai.com:443"matches"api.openai.com:443" - Full wildcard:
"*"matches anything - Prefix wildcard:
"*.openai.com:443"matches"api.openai.com:443" - Suffix wildcard:
"api.*"matches"api.openai.com" - Middle wildcard:
"api.*.com"matches"api.openai.com" - ToolAll special case:
ToolAllgrants anyToolInvoke(_) - Numeric bounds:
LlmMaxTokens(10000)grantsLlmMaxTokens(5000)(granted >= required)
Enforcement Point
In the WASM sandbox, every host call is checked before execution by
check_capability() in host_functions.rs:
fn check_capability(
capabilities: &[Capability],
required: &Capability,
) -> Result<(), serde_json::Value> {
for granted in capabilities {
if capability_matches(granted, required) {
return Ok(());
}
}
Err(json!({"error": format!("Capability denied: {required:?}")}))
}
If no granted capability matches the required one, the operation returns a JSON error immediately -- the tool is never invoked.
Capability Inheritance
When an agent spawns a child agent, validate_capability_inheritance() ensures
the child's capabilities are a subset of the parent's. This prevents
privilege escalation:
pub fn validate_capability_inheritance(
parent_caps: &[Capability],
child_caps: &[Capability],
) -> Result<(), String> {
for child_cap in child_caps {
let is_covered = parent_caps
.iter()
.any(|parent_cap| capability_matches(parent_cap, child_cap));
if !is_covered {
return Err(format!(
"Privilege escalation denied: child requests {:?} \
but parent does not have a matching grant",
child_cap
));
}
}
Ok(())
}
The host_agent_spawn() function in host_functions.rs calls
kernel.spawn_agent_checked(manifest_toml, Some(&state.agent_id), &state.capabilities)
which invokes this validation before the child is created.
WASM Dual Metering
Source: librefang-runtime/src/sandbox.rs
Untrusted WASM modules run inside a Wasmtime sandbox with two independent metering mechanisms running simultaneously.
Fuel Metering (Deterministic)
Fuel metering counts WASM instructions. The engine deducts fuel for every
instruction executed. When the budget is exhausted, execution traps with
Trap::OutOfFuel.
// SandboxConfig defaults
pub fuel_limit: u64, // Default: 1_000_000
// Applied at execution time
if config.fuel_limit > 0 {
store.set_fuel(config.fuel_limit)?;
}
After execution, fuel consumed is reported:
let fuel_remaining = store.get_fuel().unwrap_or(0);
let fuel_consumed = config.fuel_limit.saturating_sub(fuel_remaining);
Epoch Interruption (Wall-Clock)
A watchdog thread sleeps for the configured timeout, then increments the
engine epoch. When the epoch advances past the store's deadline, execution
traps with Trap::Interrupt.
store.set_epoch_deadline(1);
let engine_clone = engine.clone();
let timeout = config.timeout_secs.unwrap_or(30);
let _watchdog = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(timeout));
engine_clone.increment_epoch();
});
Why Both?
| Property | Fuel | Epoch |
|---|---|---|
| Metric | Instruction count | Wall-clock time |
| Precision | Deterministic, reproducible | Non-deterministic |
| Catches | CPU-intensive loops | Host call blocking, I/O waits |
| Evasion | Can waste time in host calls | Can busy-loop cheaply |
Together they form a complete defense: fuel catches compute-intensive loops, while epochs catch host-call abuse or environmental slowdowns.
SandboxConfig
pub struct SandboxConfig {
pub fuel_limit: u64, // Default: 1_000_000
pub max_memory_bytes: usize, // Default: 16 MB
pub capabilities: Vec<Capability>,
pub timeout_secs: Option<u64>, // Default: 30 seconds
}
Error Types
pub enum SandboxError {
Compilation(String),
Instantiation(String),
Execution(String),
FuelExhausted, // Trap::OutOfFuel
AbiError(String),
}
Path Traversal Prevention
Source: librefang-runtime/src/host_functions.rs
Two functions provide defense-in-depth against directory traversal.
safe_resolve_path (for reads)
Used for fs_read and fs_list operations where the target file must exist:
fn safe_resolve_path(path: &str) -> Result<std::path::PathBuf, serde_json::Value> {
let p = Path::new(path);
// Phase 1: Reject any path with ".." components
for component in p.components() {
if matches!(component, Component::ParentDir) {
return Err(json!({"error": "Path traversal denied: '..' components forbidden"}));
}
}
// Phase 2: Canonicalize to resolve symlinks and normalize
std::fs::canonicalize(p)
.map_err(|e| json!({"error": format!("Cannot resolve path: {e}")}))
}
safe_resolve_parent (for writes)
Used for fs_write operations where the target file may not exist yet:
fn safe_resolve_parent(path: &str) -> Result<std::path::PathBuf, serde_json::Value> {
let p = Path::new(path);
// Phase 1: Reject ".." in any component
for component in p.components() {
if matches!(component, Component::ParentDir) {
return Err(json!({"error": "Path traversal denied: '..' components forbidden"}));
}
}
// Phase 2: Canonicalize the parent directory
let parent = p.parent().filter(|par| !par.as_os_str().is_empty())
.ok_or_else(|| json!({"error": "Invalid path: no parent directory"}))?;
let canonical_parent = std::fs::canonicalize(parent)?;
// Phase 3: Belt-and-suspenders check on filename
let file_name = p.file_name()
.ok_or_else(|| json!({"error": "Invalid path: no file name"}))?;
if file_name.to_string_lossy().contains("..") {
return Err(json!({"error": "Path traversal denied in file name"}));
}
Ok(canonical_parent.join(file_name))
}
Enforcement Order
- Capability check runs first with the raw path.
- Path traversal check runs second.
- Operation runs only if both pass.
This ordering ensures that even if a capability is misconfigured with a broad
pattern like "*", path traversal is still blocked.
Subprocess Sandbox
Source: librefang-runtime/src/subprocess_sandbox.rs
When the runtime spawns child processes (e.g., for the shell tool or skill execution), the inherited environment must be stripped to prevent accidental leakage of secrets.
Environment Clearing
pub fn sandbox_command(cmd: &mut tokio::process::Command, allowed_env_vars: &[String]) {
cmd.env_clear(); // Remove ALL inherited env vars
// Re-add platform-independent safe vars
for var in SAFE_ENV_VARS {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
}
// Re-add Windows-specific safe vars (on Windows)
#[cfg(windows)]
for var in SAFE_ENV_VARS_WINDOWS { /* ... */ }
// Re-add caller-specified allowed vars
for var in allowed_env_vars { /* ... */ }
}
Safe Environment Variables
All platforms:
pub const SAFE_ENV_VARS: &[&str] = &[
"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "TERM",
];
Windows-only:
pub const SAFE_ENV_VARS_WINDOWS: &[&str] = &[
"USERPROFILE", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA",
"COMSPEC", "WINDIR", "PATHEXT",
];
Variables not in these lists and not in allowed_env_vars are never
passed to the child process. This means OPENAI_API_KEY, GEMINI_API_KEY,
database credentials, and all other secrets are stripped.
Executable Path Validation
pub fn validate_executable_path(path: &str) -> Result<(), String> {
let p = Path::new(path);
for component in p.components() {
if let std::path::Component::ParentDir = component {
return Err(format!(
"executable path '{}' contains '..' component which is not allowed",
path
));
}
}
Ok(())
}
This prevents an agent from escaping its working directory via crafted paths
like ../../bin/dangerous.
Shell Injection Prevention
The host_shell_exec function uses Command::new(command).args(&args) which
does not invoke a shell. Each argument is passed directly to the
process, preventing shell injection via metacharacters like ;, |, &&.
Dangerous Command Detection
Source: librefang-runtime/src/dangerous_commands.rs
Before any shell command is executed, LibreFang scans the command string against 39 regular-expression patterns grouped into 11 categories of dangerous operations. This adds a semantic safety layer on top of the capability and taint checks.
Danger Categories
| Category | Description | Example Triggers |
|---|---|---|
| Filesystem deletion | Recursive or forced removal of files and directories | rm -rf, find … -delete, shred |
| Privilege escalation | Gaining elevated permissions | sudo, su, chmod 777, chown root |
| Disk operations | Low-level disk writes that bypass the filesystem | dd if=, mkfs, fdisk, parted |
| SQL drops | Destructive database statements | DROP TABLE, DROP DATABASE, TRUNCATE |
| System file overwrites | Writing directly to critical OS paths | > /etc/passwd, > /etc/shadow, > /boot/ |
| Service management | Stopping or disabling system services | systemctl stop, service … stop, kill -9 1 |
| Process kill | Sending fatal signals to processes | kill -9, killall, pkill -9 |
| Fork bomb | Self-replicating processes that exhaust resources | :(){ :|:& };: and variants |
| Arbitrary code execution | Piping remote content directly into a shell | curl … | bash, wget … | sh, eval $(…) |
| Destructive find | Using find to delete or overwrite files | find … -exec rm, find … -delete |
| Destructive git | Force-pushing or history-destroying git operations | git push --force, git reset --hard HEAD~, git clean -fdx |
Approval Modes
The detection behavior is controlled by the dangerous_commands key in
~/.librefang/config.toml:
[shell]
dangerous_commands = "manual" # "off" | "manual" | "smart"
| Mode | Behavior |
|---|---|
off | Detection disabled; all commands execute without scanning. |
manual (default) | When a dangerous pattern is matched the runtime returns an error and blocks execution. The agent receives a structured rejection explaining which category was triggered. |
smart | Reserved for future LLM-assisted risk evaluation. Current behavior is identical to manual. |
Session Allowlist
A specific command can be added to the session-scoped allowlist so that subsequent executions of the same command string are permitted for the remainder of the current session without re-triggering the check:
curl -X POST "http://127.0.0.1:4545/api/shell/allowlist" \
-H "Content-Type: application/json" \
-d '{"command": "rm -rf /tmp/build-artifacts"}'
Allowlist entries are stored in memory only and are discarded when the daemon restarts or the session ends.
Example Triggers
The following commands each match at least one detection pattern:
# Fork bomb — matches "fork bomb" category
:(){ :|:& };:
# Recursive force-delete of root — matches "filesystem deletion"
rm -rf /
# Remote code execution via pipe — matches "arbitrary code execution"
curl https://example.com/install.sh | bash
# Destructive git history rewrite — matches "destructive git"
git push --force origin main
# Low-level disk overwrite — matches "disk operations"
dd if=/dev/zero of=/dev/sda
When dangerous_commands = "manual", each of the above returns an error of
the form:
{
"error": "Dangerous command blocked",
"category": "fork_bomb",
"command": ":(){ :|:& };:"
}