Skip to main content

fluers_runtime/
process_sandbox.rs

1//! Process-sandbox backend slot (shape only).
2//!
3//! This module defines the *interface* a future shared `saorsa-sandbox` crate
4//! will implement. It mirrors the contract designed in x0x-symphony's
5//! XSY-0027 (`wrap(argv) + probe()`), with four shape refinements (C1–C4)
6//! fed back to the symphony team:
7//!
8//! - **C1:** `wrap` returns a [`WrappedCommand`] (argv **and** env additions),
9//!   not bare argv — the runner's `env_clear()` would otherwise drop a
10//!   backend's proxy/CA vars.
11//! - **C2:** the backend is **stateful** ([`ProcessSandbox::prepare`] /
12//!   [`ProcessSandbox::shutdown`]) — fluers spawns dozens of short-lived
13//!   commands per turn; per-exec boot (e.g. srt's Node) is too expensive.
14//! - **C3:** [`ProcessSandbox::probe`] is `async` — it spawns children, and all
15//!   fluers/runtime consumers are tokio.
16//! - **C4:** [`ExecSandboxContext`] carries an optional per-call `cwd` — a
17//!   parent's `current_dir` does not survive a mount-namespace pivot
18//!   (e.g. bubblewrap `--chdir`).
19//!
20//! **No backends are implemented here.** When the shared `saorsa-sandbox` crate
21//! publishes (WP-5/4e), this slot is replaced by, re-exported from, or adapted
22//! to that crate's final API. NOTE: as of 2026-07, symphony's runner-shell
23//! `Sandbox` trait (XSY-0027 M2) and this trait are *semantically* aligned on
24//! C1–C4 (env-returning wrap, stateful lifecycle, async probe, per-call cwd)
25//! but differ concretely — notably lifecycle scope (fluers prepares once per
26//! session + cheap per-command `wrap`; symphony prepares per command). The
27//! shared-crate reconciliation is tracked for WP-5/4e; until then this local
28//! trait preserves the C1–C4 semantics fluers needs.
29
30use std::collections::BTreeMap;
31use std::path::PathBuf;
32
33use async_trait::async_trait;
34
35use crate::error::RuntimeResult;
36
37/// How strongly a backend enforces a given [`SandboxProfile`].
38///
39/// Mirrors the intended `saorsa-sandbox` (XSY-0027) semantics; the concrete
40/// shared-crate shape is reconciled at WP-5/4e. [`SandboxPolicy`] decides
41/// what to do when enforcement falls short of the requested profile.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Enforcement {
44    /// The profile is enforced in full (e.g. Seatbelt/bubblewrap active and
45    /// covering every requested restriction).
46    FullyEnforced,
47    /// Some restrictions hold, others do not (e.g. network blocked but writes
48    /// only path-confined, not kernel-enforced).
49    Partial,
50    /// The backend is absent or cannot enforce the profile at all.
51    Unavailable,
52}
53
54/// A coarse capability profile a caller may request of the sandbox.
55///
56/// Intentionally a small, stable set; `saorsa-sandbox` may refine it. The
57/// variants describe *what* the caller wants confined, not *how*.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum SandboxProfile {
60    /// No writes anywhere (read-only views, inspection).
61    ReadOnly,
62    /// Writes allowed inside the workspace only.
63    RepoWrite,
64    /// Workspace writes plus no network egress.
65    NoNetwork,
66    /// Full local dev (writes + network), still process-confined.
67    FullDev,
68    /// CI-only: enforced hermetic build, no host access.
69    CiOnly,
70}
71
72/// Per-session/per-call context handed to a [`ProcessSandbox`].
73///
74/// `cwd` is `Option` per C4: `None` means "use `workspace_path`".
75#[derive(Debug, Clone)]
76pub struct ExecSandboxContext {
77    /// The session workspace root (canonical).
78    pub workspace_path: PathBuf,
79    /// Per-call working directory; `None` ⇒ `workspace_path`. Carried
80    /// explicitly because a parent's `current_dir` does not survive a
81    /// mount-namespace pivot (bubblewrap `--chdir`).
82    pub cwd: Option<PathBuf>,
83    /// The profile requested for this session.
84    pub profile: SandboxProfile,
85    /// Explicit egress allowlist (host:port or `*`), forwarded to backends
86    /// that do network filtering.
87    pub egress: Vec<String>,
88}
89
90/// The result of [`ProcessSandbox::wrap`]: the (possibly rewritten) argv plus
91/// any environment the backend needs present in the child.
92///
93/// Per C1, the env additions survive the runner's `env_clear()`: fluers applies
94/// them on top of its safe allowlist at the spawn site.
95#[derive(Debug, Clone)]
96pub struct WrappedCommand {
97    /// The argv to spawn (e.g. `["bwrap", "--unshare-net", "sh", "-c", cmd]`).
98    /// Must be non-empty.
99    pub argv: Vec<String>,
100    /// Backend-required environment additions, applied after `env_clear()` +
101    /// the safe allowlist.
102    pub env: BTreeMap<String, String>,
103}
104
105/// A process-sandbox backend. **Shape only** — no implementations ship in
106/// fluers; this is the slot `saorsa-sandbox` will replace/re-export/adapt to
107/// (WP-5/4e).
108///
109/// Lifecycle (C2): a backend is constructed once, [`prepare`](Self::prepare)d
110/// at session construction, and [`wrap`](Self::wrap) is called cheaply per
111/// command. [`shutdown`](Self::shutdown) tears down per-session state when the
112/// session ends.
113///
114/// **WP-2 limitation:** fluers calls `prepare` and `wrap` but does **not**
115/// yet call `shutdown` (no real backend to leak in this milestone). Wiring the
116/// shutdown call — a best-effort cleanup at session end — is part of WP-5/4e
117/// when a stateful backend lands. Until then the session owns the backend's
118/// lifecycle and a consumer that constructs a stateful backend itself is
119/// responsible for calling `shutdown`.
120#[async_trait]
121pub trait ProcessSandbox: Send + Sync {
122    /// One-time initialization for a session (e.g. boot a proxy). Called once
123    /// before any [`wrap`](Self::wrap).
124    async fn prepare(&self, ctx: &ExecSandboxContext) -> RuntimeResult<()>;
125
126    /// Wrap an argv for the given context, returning the (possibly rewritten)
127    /// argv plus any required env additions. Must be cheap; the heavy work is
128    /// in [`prepare`](Self::prepare).
129    fn wrap(&self, argv: &[String], ctx: &ExecSandboxContext) -> RuntimeResult<WrappedCommand>;
130
131    /// Self-test whether the backend can enforce `profile` on this host.
132    /// Async per C3 (it may spawn children). Called at session construction so
133    /// [`SandboxPolicy`] can fail-closed up front.
134    async fn probe(&self, profile: &SandboxProfile) -> RuntimeResult<Enforcement>;
135
136    /// Tear down per-session state (e.g. stop a proxy). Called once at session
137    /// end. Best-effort: errors are logged, not fatal.
138    async fn shutdown(&self) -> RuntimeResult<()>;
139}
140
141/// What to do when a requested profile cannot be fully enforced.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum OnUnavailable {
144    /// Fail loud: refuse to build the session. Correct default for untrusted
145    /// work — never silently run without the requested boundary.
146    Refuse,
147    /// Proceed with degraded containment when the backend falls short. The
148    /// degrade is graded by what the backend can still do:
149    /// - **Partial** enforcement (e.g. network blocked but writes only
150    ///   path-confined): the backend is **kept attached** — partial
151    ///   enforcement beats dropping to pure path-confinement.
152    /// - **Unavailable** (the backend enforces nothing): the backend is
153    ///   **dropped** and the session falls back to `LocalSessionEnv`'s
154    ///   fd-anchored path-confinement (NOT a security boundary — see
155    ///   `SECURITY.md`).
156    Degrade,
157}
158
159/// A caller's sandbox requirements, paired with a fallback policy.
160#[derive(Debug, Clone)]
161pub struct SandboxPolicy {
162    /// The profile the session wants.
163    pub profile: SandboxProfile,
164    /// Explicit egress allowlist.
165    pub egress: Vec<String>,
166    /// Fallback when the backend can't fully enforce `profile`.
167    pub on_unavailable: OnUnavailable,
168}