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}