fluers-runtime 0.6.0

The Fluers agent harness: agent definition, sessions, skills, sandbox, events
Documentation
//! Process-sandbox backend slot (shape only).
//!
//! This module defines the *interface* a future shared `saorsa-sandbox` crate
//! will implement. It mirrors the contract designed in x0x-symphony's
//! XSY-0027 (`wrap(argv) + probe()`), with four shape refinements (C1–C4)
//! fed back to the symphony team:
//!
//! - **C1:** `wrap` returns a [`WrappedCommand`] (argv **and** env additions),
//!   not bare argv — the runner's `env_clear()` would otherwise drop a
//!   backend's proxy/CA vars.
//! - **C2:** the backend is **stateful** ([`ProcessSandbox::prepare`] /
//!   [`ProcessSandbox::shutdown`]) — fluers spawns dozens of short-lived
//!   commands per turn; per-exec boot (e.g. srt's Node) is too expensive.
//! - **C3:** [`ProcessSandbox::probe`] is `async` — it spawns children, and all
//!   fluers/runtime consumers are tokio.
//! - **C4:** [`ExecSandboxContext`] carries an optional per-call `cwd` — a
//!   parent's `current_dir` does not survive a mount-namespace pivot
//!   (e.g. bubblewrap `--chdir`).
//!
//! **No backends are implemented here.** When the shared `saorsa-sandbox` crate
//! publishes (WP-5/4e), this slot is replaced by, re-exported from, or adapted
//! to that crate's final API. NOTE: as of 2026-07, symphony's runner-shell
//! `Sandbox` trait (XSY-0027 M2) and this trait are *semantically* aligned on
//! C1–C4 (env-returning wrap, stateful lifecycle, async probe, per-call cwd)
//! but differ concretely — notably lifecycle scope (fluers prepares once per
//! session + cheap per-command `wrap`; symphony prepares per command). The
//! shared-crate reconciliation is tracked for WP-5/4e; until then this local
//! trait preserves the C1–C4 semantics fluers needs.

use std::collections::BTreeMap;
use std::path::PathBuf;

use async_trait::async_trait;

use crate::error::RuntimeResult;

/// How strongly a backend enforces a given [`SandboxProfile`].
///
/// Mirrors the intended `saorsa-sandbox` (XSY-0027) semantics; the concrete
/// shared-crate shape is reconciled at WP-5/4e. [`SandboxPolicy`] decides
/// what to do when enforcement falls short of the requested profile.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Enforcement {
    /// The profile is enforced in full (e.g. Seatbelt/bubblewrap active and
    /// covering every requested restriction).
    FullyEnforced,
    /// Some restrictions hold, others do not (e.g. network blocked but writes
    /// only path-confined, not kernel-enforced).
    Partial,
    /// The backend is absent or cannot enforce the profile at all.
    Unavailable,
}

/// A coarse capability profile a caller may request of the sandbox.
///
/// Intentionally a small, stable set; `saorsa-sandbox` may refine it. The
/// variants describe *what* the caller wants confined, not *how*.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxProfile {
    /// No writes anywhere (read-only views, inspection).
    ReadOnly,
    /// Writes allowed inside the workspace only.
    RepoWrite,
    /// Workspace writes plus no network egress.
    NoNetwork,
    /// Full local dev (writes + network), still process-confined.
    FullDev,
    /// CI-only: enforced hermetic build, no host access.
    CiOnly,
}

/// Per-session/per-call context handed to a [`ProcessSandbox`].
///
/// `cwd` is `Option` per C4: `None` means "use `workspace_path`".
#[derive(Debug, Clone)]
pub struct ExecSandboxContext {
    /// The session workspace root (canonical).
    pub workspace_path: PathBuf,
    /// Per-call working directory; `None` ⇒ `workspace_path`. Carried
    /// explicitly because a parent's `current_dir` does not survive a
    /// mount-namespace pivot (bubblewrap `--chdir`).
    pub cwd: Option<PathBuf>,
    /// The profile requested for this session.
    pub profile: SandboxProfile,
    /// Explicit egress allowlist (host:port or `*`), forwarded to backends
    /// that do network filtering.
    pub egress: Vec<String>,
}

/// The result of [`ProcessSandbox::wrap`]: the (possibly rewritten) argv plus
/// any environment the backend needs present in the child.
///
/// Per C1, the env additions survive the runner's `env_clear()`: fluers applies
/// them on top of its safe allowlist at the spawn site.
#[derive(Debug, Clone)]
pub struct WrappedCommand {
    /// The argv to spawn (e.g. `["bwrap", "--unshare-net", "sh", "-c", cmd]`).
    /// Must be non-empty.
    pub argv: Vec<String>,
    /// Backend-required environment additions, applied after `env_clear()` +
    /// the safe allowlist.
    pub env: BTreeMap<String, String>,
}

/// A process-sandbox backend. **Shape only** — no implementations ship in
/// fluers; this is the slot `saorsa-sandbox` will replace/re-export/adapt to
/// (WP-5/4e).
///
/// Lifecycle (C2): a backend is constructed once, [`prepare`](Self::prepare)d
/// at session construction, and [`wrap`](Self::wrap) is called cheaply per
/// command. [`shutdown`](Self::shutdown) tears down per-session state when the
/// session ends.
///
/// **WP-2 limitation:** fluers calls `prepare` and `wrap` but does **not**
/// yet call `shutdown` (no real backend to leak in this milestone). Wiring the
/// shutdown call — a best-effort cleanup at session end — is part of WP-5/4e
/// when a stateful backend lands. Until then the session owns the backend's
/// lifecycle and a consumer that constructs a stateful backend itself is
/// responsible for calling `shutdown`.
#[async_trait]
pub trait ProcessSandbox: Send + Sync {
    /// One-time initialization for a session (e.g. boot a proxy). Called once
    /// before any [`wrap`](Self::wrap).
    async fn prepare(&self, ctx: &ExecSandboxContext) -> RuntimeResult<()>;

    /// Wrap an argv for the given context, returning the (possibly rewritten)
    /// argv plus any required env additions. Must be cheap; the heavy work is
    /// in [`prepare`](Self::prepare).
    fn wrap(&self, argv: &[String], ctx: &ExecSandboxContext) -> RuntimeResult<WrappedCommand>;

    /// Self-test whether the backend can enforce `profile` on this host.
    /// Async per C3 (it may spawn children). Called at session construction so
    /// [`SandboxPolicy`] can fail-closed up front.
    async fn probe(&self, profile: &SandboxProfile) -> RuntimeResult<Enforcement>;

    /// Tear down per-session state (e.g. stop a proxy). Called once at session
    /// end. Best-effort: errors are logged, not fatal.
    async fn shutdown(&self) -> RuntimeResult<()>;
}

/// What to do when a requested profile cannot be fully enforced.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnUnavailable {
    /// Fail loud: refuse to build the session. Correct default for untrusted
    /// work — never silently run without the requested boundary.
    Refuse,
    /// Proceed with degraded containment when the backend falls short. The
    /// degrade is graded by what the backend can still do:
    /// - **Partial** enforcement (e.g. network blocked but writes only
    ///   path-confined): the backend is **kept attached** — partial
    ///   enforcement beats dropping to pure path-confinement.
    /// - **Unavailable** (the backend enforces nothing): the backend is
    ///   **dropped** and the session falls back to `LocalSessionEnv`'s
    ///   fd-anchored path-confinement (NOT a security boundary — see
    ///   `SECURITY.md`).
    Degrade,
}

/// A caller's sandbox requirements, paired with a fallback policy.
#[derive(Debug, Clone)]
pub struct SandboxPolicy {
    /// The profile the session wants.
    pub profile: SandboxProfile,
    /// Explicit egress allowlist.
    pub egress: Vec<String>,
    /// Fallback when the backend can't fully enforce `profile`.
    pub on_unavailable: OnUnavailable,
}