Skip to main content

fluers_runtime/
sandbox.rs

1//! Sandbox backends.
2//!
3//! A [`Sandbox`] manufactures a fresh `SessionEnv` for a
4//! session. Flue ships three flavours — *virtual*, *local*, and *remote
5//! container* — selected via `local()` / container providers. This crate
6//! implements the local flavour (see [`LocalSessionEnv`]); virtual + remote
7//! are stubbed for later phases (see `PORTING_PLAN.md`).
8//!
9//! [`LocalSessionEnv`]: crate::LocalSessionEnv
10
11use async_trait::async_trait;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use tracing::warn;
15
16use crate::env::{Limits, SessionEnv};
17use crate::error::RuntimeResult;
18use crate::local_env::LocalSessionEnv;
19use crate::process_sandbox::{
20    Enforcement, ExecSandboxContext, OnUnavailable, ProcessSandbox, SandboxPolicy,
21};
22
23/// A factory that produces a [`SessionEnv`] for one session.
24#[async_trait]
25pub trait Sandbox: Send + Sync {
26    /// Human-readable name (e.g. `local`, `virtual`, `e2b`).
27    fn name(&self) -> &str;
28
29    /// Build the environment for a session rooted at `workdir`.
30    async fn env_for(&self, workdir: &Path) -> RuntimeResult<Arc<dyn SessionEnv>>;
31}
32
33/// A local-filesystem sandbox: tools run against a real directory on disk.
34///
35/// This is the Rust equivalent of Flue's `local()` from
36/// `@flue/runtime/node`.
37pub struct LocalSandbox {
38    root: PathBuf,
39    limits: Limits,
40    /// Optional process-sandbox backend (WP-2 slot; `saorsa-sandbox` will fill
41    /// it in WP-5/4e). `None` ⇒ spawn behaviour is byte-identical to pre-WP-2.
42    exec_sandbox: Option<Arc<dyn ProcessSandbox>>,
43    /// Policy for `exec_sandbox`. `Some` iff `exec_sandbox` is `Some`.
44    policy: Option<SandboxPolicy>,
45}
46
47impl LocalSandbox {
48    /// Create a local sandbox rooted at `root`.
49    #[must_use]
50    pub fn new(root: impl Into<PathBuf>) -> Self {
51        Self {
52            root: root.into(),
53            limits: Limits::default(),
54            exec_sandbox: None,
55            policy: None,
56        }
57    }
58
59    /// Override the default resource limits.
60    #[must_use]
61    pub fn with_limits(mut self, limits: Limits) -> Self {
62        self.limits = limits;
63        self
64    }
65
66    /// Attach a process-sandbox backend (WP-2 slot). The backend is probed at
67    /// session construction; the policy decides what happens if enforcement
68    /// falls short of `policy.profile`. With no backend attached, all spawns
69    /// behave exactly as before WP-2.
70    #[must_use]
71    pub fn with_exec_sandbox(
72        mut self,
73        backend: Arc<dyn ProcessSandbox>,
74        policy: SandboxPolicy,
75    ) -> Self {
76        self.exec_sandbox = Some(backend);
77        self.policy = Some(policy);
78        self
79    }
80}
81
82#[async_trait]
83impl Sandbox for LocalSandbox {
84    fn name(&self) -> &str {
85        "local"
86    }
87
88    async fn env_for(&self, _workdir: &Path) -> RuntimeResult<Arc<dyn SessionEnv>> {
89        // The sandbox's configured root is the session root; the `workdir`
90        // override is honored by direct `LocalSessionEnv::new` callers.
91        match (&self.exec_sandbox, &self.policy) {
92            (None, None) => {
93                // Pre-WP-2 path: no backend, plain local env.
94                Ok(Arc::new(
95                    LocalSessionEnv::new(&self.root, self.limits).await?,
96                ))
97            }
98            (Some(backend), Some(policy)) => {
99                // Probe the backend up front so we can fail-closed at session
100                // construction, before any command runs. `probe` is async (C3):
101                // it may spawn children to self-test.
102                let enforcement = backend.probe(&policy.profile).await?;
103                let active = match (enforcement, policy.on_unavailable) {
104                    // Fully enforced: keep the backend, full enforcement active.
105                    (Enforcement::FullyEnforced, _) => true,
106                    // Partial + Degrade: keep the backend. Partial enforcement
107                    // (e.g. network blocked but writes only path-confined) is
108                    // strictly better than dropping to pure fd-anchored
109                    // containment, so a Degrade caller keeps what it can get.
110                    // Rule 12 (fail-loud): the operator must see that the
111                    // requested profile is only partially enforced.
112                    (Enforcement::Partial, OnUnavailable::Degrade) => {
113                        warn!(
114                            profile = ?policy.profile,
115                            enforcement = "partial",
116                            "process sandbox only partially enforces the \
117                             requested profile; proceeding under `Degrade` with \
118                             the partial process boundary, not the full profile"
119                        );
120                        true
121                    }
122                    // Unavailable + Degrade: the backend cannot enforce the
123                    // profile AT ALL. Keeping it would pay prepare cost for no
124                    // gain and risk a broken no-op wrap; a true degrade drops
125                    // it and falls back to fd-anchored path-confinement.
126                    // Rule 12 (fail-loud): this is the dangerous arm — the
127                    // operator configured a boundary and got none. Say it.
128                    (Enforcement::Unavailable, OnUnavailable::Degrade) => {
129                        warn!(
130                            profile = ?policy.profile,
131                            enforcement = "unavailable",
132                            "process sandbox unavailable under `Degrade` policy; \
133                             running WITHOUT a process sandbox. Only fd-anchored \
134                             path-containment remains, which is NOT a security \
135                             boundary — untrusted model-run commands execute with \
136                             no enforced confinement"
137                        );
138                        false
139                    }
140                    // Less than fully enforced and the caller refused
141                    // degradation: fail loud. Never silently run untrusted
142                    // work without the requested boundary.
143                    (Enforcement::Partial, OnUnavailable::Refuse)
144                    | (Enforcement::Unavailable, OnUnavailable::Refuse) => {
145                        return Err(crate::error::RuntimeError::Sandbox(format!(
146                            "sandbox backend reports `{enforcement:?}` for profile \
147                             `{:?}` but policy is `Refuse` — refusing to build the \
148                             session without the requested boundary",
149                            policy.profile
150                        )));
151                    }
152                };
153                if !active {
154                    // Degrade dropped the backend: plain no-backend env. There is
155                    // no "backend None + policy Some" state by construction.
156                    return Ok(Arc::new(
157                        LocalSessionEnv::new(&self.root, self.limits).await?,
158                    ));
159                }
160                // Ensure the root exists, THEN canonicalize — matching
161                // `LocalSessionEnv::new`'s create-if-missing behavior so the
162                // active-backend path doesn't regress on a missing root.
163                tokio::fs::create_dir_all(&self.root)
164                    .await
165                    .map_err(crate::error::RuntimeError::Io)?;
166                // Canonicalize the root so `prepare` and every later `wrap` see
167                // the SAME absolute workspace path (a relative/nonexistent root
168                // would otherwise make the backend's view inconsistent with
169                // LocalSessionEnv's fd-anchored root).
170                let canon = tokio::fs::canonicalize(&self.root)
171                    .await
172                    .map_err(crate::error::RuntimeError::Io)?;
173                // prepare is one-time per session (C2); LocalSessionEnv owns
174                // the resulting backend for the session's lifetime.
175                let ctx = ExecSandboxContext {
176                    workspace_path: canon.clone(),
177                    cwd: None,
178                    profile: policy.profile,
179                    egress: policy.egress.clone(),
180                };
181                backend.prepare(&ctx).await?;
182                Ok(Arc::new(
183                    LocalSessionEnv::new_with_sandbox(
184                        canon,
185                        self.limits,
186                        Arc::clone(backend),
187                        policy.clone(),
188                    )
189                    .await?,
190                ))
191            }
192            // Structurally impossible: `exec_sandbox` and `policy` are set
193            // together in `with_exec_sandbox` and `new`. Fail loud rather than
194            // silently drop to the no-backend path (a config/construction bug
195            // must surface, not be masked).
196            _ => Err(crate::error::RuntimeError::Sandbox(
197                "LocalSandbox invariant violated: exec_sandbox and policy must be \
198                 set together (use with_exec_sandbox or neither)"
199                    .into(),
200            )),
201        }
202    }
203}
204
205/// Convenience constructor matching Flue's `local()` import.
206#[must_use]
207pub fn local() -> LocalSandbox {
208    LocalSandbox::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
209}