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}