Skip to main content

cageforge_backend_api/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Backend capability negotiation and preflight for Cageforge execution.
4//!
5//! This crate is the typed boundary between [`cageforge_command`] and
6//! [`cageforge_policy_compose`] values and a native execution backend. It does
7//! not implement native filesystem or network I/O, resolve DNS, or select an
8//! operating-system sandbox. [`Sandbox`] and [`DynSandbox`] delegate execution
9//! to the selected native backend while [`SandboxChild`] exposes its lifecycle.
10//!
11//! Start with [`BackendRequest`] and [`BackendCapabilities`]. A native backend
12//! implements [`SandboxBackend`], advertises the capabilities it can enforce,
13//! and calls [`BackendRequest::prepare_for`] before lowering the prepared
14//! request to its operating-system API. The backend owns process launch and
15//! lifecycle after this preflight boundary.
16
17#![doc = include_str!("../README.md")]
18#![deny(missing_docs)]
19
20mod capability;
21mod execution;
22mod requirements;
23
24use std::collections::{BTreeMap, BTreeSet};
25use std::ffi::OsString;
26use std::fmt;
27use std::marker::PhantomData;
28use std::net::{IpAddr, SocketAddr};
29use std::path::Path;
30use std::sync::Arc;
31
32use cageforge_command::{CommandRequest, CommandSpec, StdioSpec, TimeoutPolicy};
33use cageforge_path::normalize_lexical_path;
34use cageforge_policy::{
35    ConnectionAuthorization, FilesystemDecision, NetworkDecision, PathResolutionContext,
36    PathSelector, ResolvedNetworkTarget,
37};
38use cageforge_policy_compose::{
39    EffectiveFilesystemLowering, EffectiveNetworkLowering, EffectivePathContext, EffectiveSandbox,
40    EnvironmentInput,
41};
42mod model;
43
44pub use execution::{DynSandbox, Sandbox, SandboxChild, SandboxExecutionError};
45
46pub use model::{
47    BackendCapabilities, BackendCapability, BackendContractError, BackendIdentity, BackendRequest,
48    PreparedBackendRequest, SandboxBackend,
49};
50
51impl BackendCapabilities {
52    /// Creates an empty capability set.
53    pub const fn new() -> Self {
54        Self {
55            capabilities: BTreeSet::new(),
56        }
57    }
58
59    /// Creates a capability set from an iterable collection.
60    pub fn from_capabilities<I>(capabilities: I) -> Self
61    where
62        I: IntoIterator<Item = BackendCapability>,
63    {
64        Self {
65            capabilities: capabilities.into_iter().collect(),
66        }
67    }
68
69    /// Returns a copy with one capability added.
70    pub fn with(mut self, capability: BackendCapability) -> Self {
71        self.capabilities.insert(capability);
72        self
73    }
74
75    /// Returns whether this backend advertises a capability.
76    pub fn supports(&self, capability: BackendCapability) -> bool {
77        self.capabilities.contains(&capability)
78    }
79
80    /// Returns capabilities in deterministic order.
81    pub fn iter(&self) -> impl Iterator<Item = &BackendCapability> {
82        self.capabilities.iter()
83    }
84}
85
86impl FromIterator<BackendCapability> for BackendCapabilities {
87    fn from_iter<T: IntoIterator<Item = BackendCapability>>(iter: T) -> Self {
88        Self::from_capabilities(iter)
89    }
90}
91
92impl<'a> BackendRequest<'a> {
93    /// Creates a backend request from a command and composed sandbox.
94    pub const fn new(command: &'a CommandRequest, sandbox: &'a EffectiveSandbox) -> Self {
95        Self { command, sandbox }
96    }
97
98    /// Returns the command intent.
99    pub const fn command(&self) -> &'a CommandRequest {
100        self.command
101    }
102
103    /// Returns the effective sandbox constraint.
104    pub const fn sandbox(&self) -> &'a EffectiveSandbox {
105        self.sandbox
106    }
107
108    /// Performs common preflight using exactly the capabilities advertised by
109    /// `backend` and the backend's runtime path context.
110    ///
111    /// This is the safe handoff entry point for native integrations. The
112    /// capability check is defined by Cageforge and cannot be overridden by a
113    /// backend implementation. The context is narrowed before return, and an
114    /// effective working directory must be supplied by the runtime context and
115    /// permitted by the effective filesystem policy. The returned value is
116    /// bound to `B`, so a handoff prepared for one backend type cannot be passed
117    /// to a native lowering method for another backend type by accident.
118    pub fn prepare_for<B: SandboxBackend>(
119        self,
120        backend: &B,
121        base_context: &PathResolutionContext,
122    ) -> Result<PreparedBackendRequest<'a, B>, BackendContractError> {
123        self.validate::<B>(&backend.capabilities(), backend.identity(), base_context)
124    }
125
126    /// Computes the capabilities required by this request.
127    pub fn required_capabilities(&self) -> BackendCapabilities {
128        let mut required = BackendCapabilities::new().with(BackendCapability::CommandExecution);
129        requirements::add_command_capabilities(&mut required, self.command);
130        requirements::add_filesystem_capabilities(&mut required, self.sandbox);
131        requirements::add_network_capabilities(&mut required, self.sandbox);
132        requirements::add_environment_capabilities(&mut required, self.sandbox);
133        required
134    }
135
136    /// Validates this request against advertised backend capabilities.
137    ///
138    /// No process, filesystem, DNS, or socket operation is performed. The
139    /// returned value only proves that the request's portable requirements are
140    /// represented by the advertised capability set; native enforcement still
141    /// belongs to the backend.
142    fn validate<B: SandboxBackend>(
143        self,
144        capabilities: &BackendCapabilities,
145        backend_identity: &BackendIdentity,
146        base_context: &PathResolutionContext,
147    ) -> Result<PreparedBackendRequest<'a, B>, BackendContractError> {
148        if !self
149            .sandbox
150            .environment()
151            .requested_matches(self.command.environment())
152        {
153            return Err(BackendContractError::CommandEnvironmentMismatch);
154        }
155        for capability in self.required_capabilities().iter().copied() {
156            if !capabilities.supports(capability) {
157                return Err(BackendContractError::UnsupportedCapability { capability });
158            }
159        }
160        let path_context = self
161            .sandbox
162            .path_context(base_context)
163            .map_err(|source| BackendContractError::InvalidRuntimeContext { source })?;
164        let working_directory = match self.command.working_directory() {
165            Some(path) if path.is_absolute() => normalize_lexical_path(path).into_owned(),
166            Some(path) => {
167                let current_directory = base_context.current_directory().ok_or_else(|| {
168                    BackendContractError::WorkingDirectoryResolution {
169                        path: path.to_path_buf(),
170                    }
171                })?;
172                normalize_lexical_path(&current_directory.join(path)).into_owned()
173            }
174            None => base_context
175                .current_directory()
176                .map(normalize_lexical_path)
177                .map(std::borrow::Cow::into_owned)
178                .ok_or(BackendContractError::MissingRuntimeCurrentDirectory)?,
179        };
180        match self
181            .sandbox
182            .filesystem()
183            .access_for_path(&working_directory, &path_context)
184            .map_err(|source| BackendContractError::FilesystemEvaluation { source })?
185        {
186            FilesystemDecision::Read
187            | FilesystemDecision::Write
188            | FilesystemDecision::ExternallyEnforced => {}
189            FilesystemDecision::Deny => {
190                return Err(BackendContractError::WorkingDirectoryDenied {
191                    path: working_directory.clone(),
192                });
193            }
194        }
195        Ok(PreparedBackendRequest {
196            request: self,
197            path_context,
198            working_directory,
199            capabilities: capabilities.clone(),
200            backend_identity: backend_identity.clone(),
201            backend: PhantomData,
202        })
203    }
204}
205
206impl BackendIdentity {
207    /// Creates a new backend-instance identity.
208    #[allow(clippy::new_without_default)]
209    pub fn new() -> Self {
210        Self(Arc::new(()))
211    }
212}
213
214impl fmt::Debug for BackendIdentity {
215    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216        formatter
217            .debug_struct("BackendIdentity")
218            .finish_non_exhaustive()
219    }
220}
221
222impl PartialEq for BackendIdentity {
223    fn eq(&self, other: &Self) -> bool {
224        Arc::ptr_eq(&self.0, &other.0)
225    }
226}
227
228impl Eq for BackendIdentity {}
229
230impl<'a, B: SandboxBackend> Clone for PreparedBackendRequest<'a, B> {
231    fn clone(&self) -> Self {
232        Self {
233            request: self.request,
234            path_context: self.path_context.clone(),
235            working_directory: self.working_directory.clone(),
236            capabilities: self.capabilities.clone(),
237            backend_identity: self.backend_identity.clone(),
238            backend: PhantomData,
239        }
240    }
241}
242
243impl<'a, B: SandboxBackend> fmt::Debug for PreparedBackendRequest<'a, B> {
244    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245        formatter
246            .debug_struct("PreparedBackendRequest")
247            .field("request", &self.request)
248            .field("path_context", &self.path_context)
249            .field("working_directory", &self.working_directory)
250            .field("capabilities", &self.capabilities)
251            .finish()
252    }
253}
254
255impl<'a, B: SandboxBackend> PreparedBackendRequest<'a, B> {
256    fn ensure_backend(&self, backend: &B) -> Result<(), BackendContractError> {
257        if self.backend_identity != *backend.identity() {
258            Err(BackendContractError::BackendIdentityMismatch)
259        } else if self.capabilities != backend.capabilities() {
260            Err(BackendContractError::BackendCapabilitiesMismatch)
261        } else {
262            Ok(())
263        }
264    }
265
266    /// Returns the validated executable and argv values.
267    ///
268    /// The working directory is intentionally exposed separately through
269    /// [`Self::working_directory`]. A backend must not recover or inherit the
270    /// original optional cwd from a raw [`CommandRequest`] after preflight.
271    pub fn command_spec(&self, backend: &B) -> Result<&'a CommandSpec, BackendContractError> {
272        self.ensure_backend(backend)?;
273        Ok(self.request.command().command())
274    }
275
276    /// Returns the validated effective sandbox.
277    pub fn sandbox(&self, backend: &B) -> Result<&'a EffectiveSandbox, BackendContractError> {
278        self.ensure_backend(backend)?;
279        Ok(self.request.sandbox())
280    }
281
282    /// Returns all filesystem constraint layers required for native lowering.
283    ///
284    /// The backend must enforce every layer in the returned view. This is
285    /// distinct from the combined decision helpers: a native sandbox builder
286    /// needs the concrete rules, protected paths, and glob settings, while
287    /// the view prevents it from selecting only the requested or ceiling
288    /// side.
289    pub fn filesystem_lowering(
290        &self,
291        backend: &B,
292    ) -> Result<EffectiveFilesystemLowering<'_>, BackendContractError> {
293        self.ensure_backend(backend)?;
294        Ok(self.request.sandbox().filesystem().lowering())
295    }
296
297    /// Returns all network constraint layers required for native lowering.
298    ///
299    /// These rules configure enforcement only. Actual connections must still
300    /// use [`Self::authorize_connection`] with a resolved target and exact
301    /// socket address.
302    pub fn network_lowering(
303        &self,
304        backend: &B,
305    ) -> Result<EffectiveNetworkLowering<'_>, BackendContractError> {
306        self.ensure_backend(backend)?;
307        Ok(self.request.sandbox().network().lowering())
308    }
309
310    /// Returns the runtime path context that was narrowed and checked during
311    /// [`BackendRequest::prepare_for`].
312    pub fn path_context(&self, backend: &B) -> Result<&EffectivePathContext, BackendContractError> {
313        self.ensure_backend(backend)?;
314        Ok(&self.path_context)
315    }
316
317    /// Returns the effective working directory resolved during preflight.
318    ///
319    /// This is always present. When the command did not specify an explicit
320    /// directory, it is the runtime current directory supplied in the path
321    /// context and checked against the effective filesystem policy.
322    pub fn working_directory(&self, backend: &B) -> Result<&Path, BackendContractError> {
323        self.ensure_backend(backend)?;
324        Ok(&self.working_directory)
325    }
326
327    /// Returns the validated standard-stream routing.
328    pub fn stdio(&self, backend: &B) -> Result<StdioSpec, BackendContractError> {
329        self.ensure_backend(backend)?;
330        Ok(self.request.command().stdio())
331    }
332
333    /// Returns the validated timeout intent.
334    pub fn timeout_policy(&self, backend: &B) -> Result<TimeoutPolicy, BackendContractError> {
335        self.ensure_backend(backend)?;
336        Ok(self.request.command().timeout_policy())
337    }
338
339    /// Applies the effective environment to a backend-selected input base.
340    ///
341    /// A backend must construct [`EnvironmentInput::core`] only after it has
342    /// selected the platform's conservative core environment.
343    pub fn apply_environment(
344        &self,
345        backend: &B,
346        input: EnvironmentInput,
347    ) -> Result<BTreeMap<OsString, OsString>, BackendContractError> {
348        self.ensure_backend(backend)?;
349        self.request
350            .sandbox()
351            .environment()
352            .apply_to(input)
353            .map_err(|source| BackendContractError::EnvironmentPreparation { source })
354    }
355
356    /// Evaluates one absolute path against both effective filesystem policies.
357    pub fn filesystem_access_for_path(
358        &self,
359        backend: &B,
360        path: &Path,
361    ) -> Result<FilesystemDecision, BackendContractError> {
362        self.ensure_backend(backend)?;
363        self.request
364            .sandbox()
365            .filesystem()
366            .access_for_path(path, &self.path_context)
367            .map_err(|source| BackendContractError::FilesystemEvaluation { source })
368    }
369
370    /// Evaluates one symbolic filesystem selector against both effective
371    /// policies and the narrowed runtime context.
372    ///
373    /// The context must come from [`Self::path_context`]. A selector that has
374    /// no effective runtime paths is denied, so a backend cannot accidentally
375    /// replace a workspace-root ceiling with a broader context.
376    pub fn filesystem_access_for(
377        &self,
378        backend: &B,
379        selector: &PathSelector,
380    ) -> Result<FilesystemDecision, BackendContractError> {
381        self.ensure_backend(backend)?;
382        self.request
383            .sandbox()
384            .filesystem()
385            .access_for(selector, &self.path_context)
386            .map_err(|source| BackendContractError::FilesystemEvaluation { source })
387    }
388
389    /// Evaluates a resolved hostname and all addresses captured for it.
390    ///
391    /// This is a policy query, not connection authorization. A backend must
392    /// call [`Self::authorize_connection`] immediately before connecting.
393    pub fn network_decision_for_domain_with_resolved_ips(
394        &self,
395        backend: &B,
396        domain: &str,
397        resolved_ips: &[IpAddr],
398    ) -> Result<NetworkDecision, BackendContractError> {
399        self.ensure_backend(backend)?;
400        self.request
401            .sandbox()
402            .network()
403            .decision_for_domain_with_resolved_ips(domain, resolved_ips)
404            .map_err(|source| BackendContractError::NetworkEvaluation { source })
405    }
406
407    /// Authorizes the exact socket address the backend is about to connect to.
408    pub fn authorize_connection(
409        &self,
410        backend: &B,
411        target: &ResolvedNetworkTarget,
412        connected: SocketAddr,
413    ) -> Result<ConnectionAuthorization, BackendContractError> {
414        self.ensure_backend(backend)?;
415        self.request
416            .sandbox()
417            .network()
418            .authorize_connection(target, connected)
419            .map_err(|source| BackendContractError::NetworkEvaluation { source })
420    }
421
422    /// Evaluates one Unix socket path against both effective network policies.
423    pub fn network_decision_for_unix_socket(
424        &self,
425        backend: &B,
426        socket: &Path,
427    ) -> Result<NetworkDecision, BackendContractError> {
428        self.ensure_backend(backend)?;
429        self.request
430            .sandbox()
431            .network()
432            .decision_for_unix_socket(socket)
433            .map_err(|source| BackendContractError::NetworkEvaluation { source })
434    }
435}