fluers-runtime 0.6.0

The Fluers agent harness: agent definition, sessions, skills, sandbox, events
Documentation
//! Sandbox backends.
//!
//! A [`Sandbox`] manufactures a fresh `SessionEnv` for a
//! session. Flue ships three flavours — *virtual*, *local*, and *remote
//! container* — selected via `local()` / container providers. This crate
//! implements the local flavour (see [`LocalSessionEnv`]); virtual + remote
//! are stubbed for later phases (see `PORTING_PLAN.md`).
//!
//! [`LocalSessionEnv`]: crate::LocalSessionEnv

use async_trait::async_trait;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::warn;

use crate::env::{Limits, SessionEnv};
use crate::error::RuntimeResult;
use crate::local_env::LocalSessionEnv;
use crate::process_sandbox::{
    Enforcement, ExecSandboxContext, OnUnavailable, ProcessSandbox, SandboxPolicy,
};

/// A factory that produces a [`SessionEnv`] for one session.
#[async_trait]
pub trait Sandbox: Send + Sync {
    /// Human-readable name (e.g. `local`, `virtual`, `e2b`).
    fn name(&self) -> &str;

    /// Build the environment for a session rooted at `workdir`.
    async fn env_for(&self, workdir: &Path) -> RuntimeResult<Arc<dyn SessionEnv>>;
}

/// A local-filesystem sandbox: tools run against a real directory on disk.
///
/// This is the Rust equivalent of Flue's `local()` from
/// `@flue/runtime/node`.
pub struct LocalSandbox {
    root: PathBuf,
    limits: Limits,
    /// Optional process-sandbox backend (WP-2 slot; `saorsa-sandbox` will fill
    /// it in WP-5/4e). `None` ⇒ spawn behaviour is byte-identical to pre-WP-2.
    exec_sandbox: Option<Arc<dyn ProcessSandbox>>,
    /// Policy for `exec_sandbox`. `Some` iff `exec_sandbox` is `Some`.
    policy: Option<SandboxPolicy>,
}

impl LocalSandbox {
    /// Create a local sandbox rooted at `root`.
    #[must_use]
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self {
            root: root.into(),
            limits: Limits::default(),
            exec_sandbox: None,
            policy: None,
        }
    }

    /// Override the default resource limits.
    #[must_use]
    pub fn with_limits(mut self, limits: Limits) -> Self {
        self.limits = limits;
        self
    }

    /// Attach a process-sandbox backend (WP-2 slot). The backend is probed at
    /// session construction; the policy decides what happens if enforcement
    /// falls short of `policy.profile`. With no backend attached, all spawns
    /// behave exactly as before WP-2.
    #[must_use]
    pub fn with_exec_sandbox(
        mut self,
        backend: Arc<dyn ProcessSandbox>,
        policy: SandboxPolicy,
    ) -> Self {
        self.exec_sandbox = Some(backend);
        self.policy = Some(policy);
        self
    }
}

#[async_trait]
impl Sandbox for LocalSandbox {
    fn name(&self) -> &str {
        "local"
    }

    async fn env_for(&self, _workdir: &Path) -> RuntimeResult<Arc<dyn SessionEnv>> {
        // The sandbox's configured root is the session root; the `workdir`
        // override is honored by direct `LocalSessionEnv::new` callers.
        match (&self.exec_sandbox, &self.policy) {
            (None, None) => {
                // Pre-WP-2 path: no backend, plain local env.
                Ok(Arc::new(
                    LocalSessionEnv::new(&self.root, self.limits).await?,
                ))
            }
            (Some(backend), Some(policy)) => {
                // Probe the backend up front so we can fail-closed at session
                // construction, before any command runs. `probe` is async (C3):
                // it may spawn children to self-test.
                let enforcement = backend.probe(&policy.profile).await?;
                let active = match (enforcement, policy.on_unavailable) {
                    // Fully enforced: keep the backend, full enforcement active.
                    (Enforcement::FullyEnforced, _) => true,
                    // Partial + Degrade: keep the backend. Partial enforcement
                    // (e.g. network blocked but writes only path-confined) is
                    // strictly better than dropping to pure fd-anchored
                    // containment, so a Degrade caller keeps what it can get.
                    // Rule 12 (fail-loud): the operator must see that the
                    // requested profile is only partially enforced.
                    (Enforcement::Partial, OnUnavailable::Degrade) => {
                        warn!(
                            profile = ?policy.profile,
                            enforcement = "partial",
                            "process sandbox only partially enforces the \
                             requested profile; proceeding under `Degrade` with \
                             the partial process boundary, not the full profile"
                        );
                        true
                    }
                    // Unavailable + Degrade: the backend cannot enforce the
                    // profile AT ALL. Keeping it would pay prepare cost for no
                    // gain and risk a broken no-op wrap; a true degrade drops
                    // it and falls back to fd-anchored path-confinement.
                    // Rule 12 (fail-loud): this is the dangerous arm — the
                    // operator configured a boundary and got none. Say it.
                    (Enforcement::Unavailable, OnUnavailable::Degrade) => {
                        warn!(
                            profile = ?policy.profile,
                            enforcement = "unavailable",
                            "process sandbox unavailable under `Degrade` policy; \
                             running WITHOUT a process sandbox. Only fd-anchored \
                             path-containment remains, which is NOT a security \
                             boundary — untrusted model-run commands execute with \
                             no enforced confinement"
                        );
                        false
                    }
                    // Less than fully enforced and the caller refused
                    // degradation: fail loud. Never silently run untrusted
                    // work without the requested boundary.
                    (Enforcement::Partial, OnUnavailable::Refuse)
                    | (Enforcement::Unavailable, OnUnavailable::Refuse) => {
                        return Err(crate::error::RuntimeError::Sandbox(format!(
                            "sandbox backend reports `{enforcement:?}` for profile \
                             `{:?}` but policy is `Refuse` — refusing to build the \
                             session without the requested boundary",
                            policy.profile
                        )));
                    }
                };
                if !active {
                    // Degrade dropped the backend: plain no-backend env. There is
                    // no "backend None + policy Some" state by construction.
                    return Ok(Arc::new(
                        LocalSessionEnv::new(&self.root, self.limits).await?,
                    ));
                }
                // Ensure the root exists, THEN canonicalize — matching
                // `LocalSessionEnv::new`'s create-if-missing behavior so the
                // active-backend path doesn't regress on a missing root.
                tokio::fs::create_dir_all(&self.root)
                    .await
                    .map_err(crate::error::RuntimeError::Io)?;
                // Canonicalize the root so `prepare` and every later `wrap` see
                // the SAME absolute workspace path (a relative/nonexistent root
                // would otherwise make the backend's view inconsistent with
                // LocalSessionEnv's fd-anchored root).
                let canon = tokio::fs::canonicalize(&self.root)
                    .await
                    .map_err(crate::error::RuntimeError::Io)?;
                // prepare is one-time per session (C2); LocalSessionEnv owns
                // the resulting backend for the session's lifetime.
                let ctx = ExecSandboxContext {
                    workspace_path: canon.clone(),
                    cwd: None,
                    profile: policy.profile,
                    egress: policy.egress.clone(),
                };
                backend.prepare(&ctx).await?;
                Ok(Arc::new(
                    LocalSessionEnv::new_with_sandbox(
                        canon,
                        self.limits,
                        Arc::clone(backend),
                        policy.clone(),
                    )
                    .await?,
                ))
            }
            // Structurally impossible: `exec_sandbox` and `policy` are set
            // together in `with_exec_sandbox` and `new`. Fail loud rather than
            // silently drop to the no-backend path (a config/construction bug
            // must surface, not be masked).
            _ => Err(crate::error::RuntimeError::Sandbox(
                "LocalSandbox invariant violated: exec_sandbox and policy must be \
                 set together (use with_exec_sandbox or neither)"
                    .into(),
            )),
        }
    }
}

/// Convenience constructor matching Flue's `local()` import.
#[must_use]
pub fn local() -> LocalSandbox {
    LocalSandbox::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}