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