Skip to main content

heimdall_core/
request.rs

1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4use heimdall_sandbox_policy::{
5    AgentPolicy, FilesystemPolicy, NetworkMode, ProcMode, validate_filesystem_policy,
6};
7
8use crate::{Error, Result};
9
10/// Child stdio handling policy.
11///
12/// Controls how stdin, stdout, and stderr are connected between the sandbox
13/// parent process and the sandboxed child.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum StdioPolicy {
16    /// Inherit stdin, stdout, and stderr from the sandbox process.
17    Inherit,
18    /// Null stdin and pipe stdout/stderr.
19    Piped,
20}
21
22impl std::str::FromStr for StdioPolicy {
23    type Err = String;
24
25    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
26        match s {
27            "inherit" => Ok(Self::Inherit),
28            "piped" => Ok(Self::Piped),
29            _ => Err(format!("unknown stdio policy: {s}")),
30        }
31    }
32}
33
34impl std::fmt::Display for StdioPolicy {
35    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::Inherit => formatter.write_str("inherit"),
38            Self::Piped => formatter.write_str("piped"),
39        }
40    }
41}
42
43/// Child environment filtering policy.
44///
45/// Determines which parent environment variables are inherited by the sandboxed
46/// child process.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum EnvPolicy {
49    /// Pass only explicitly allowed parent environment variables.
50    Allowlist,
51    /// Pass parent environment variables except explicitly denied keys.
52    Blocklist,
53}
54
55impl std::str::FromStr for EnvPolicy {
56    type Err = String;
57
58    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
59        match s {
60            "allowlist" => Ok(Self::Allowlist),
61            "blocklist" => Ok(Self::Blocklist),
62            _ => Err(format!("unknown env policy: {s}")),
63        }
64    }
65}
66
67impl std::fmt::Display for EnvPolicy {
68    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::Allowlist => formatter.write_str("allowlist"),
71            Self::Blocklist => formatter.write_str("blocklist"),
72        }
73    }
74}
75
76/// Structured child execution request independent from CLI parsing.
77///
78/// Built via `new` followed by builder methods. Validates that the working
79/// directory exists and that a command is provided.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct ExecRequest {
82    cwd: PathBuf,
83    argv: Vec<String>,
84    allowed_env: Vec<String>,
85    denied_env: Vec<String>,
86    env_policy: EnvPolicy,
87    stdio_policy: StdioPolicy,
88    network_mode: NetworkMode,
89    /// Filesystem stance declared by the caller. `None` states no
90    /// confinement; `Some` marks sandbox intent where an empty value
91    /// confines read-only.
92    filesystem_policy: Option<FilesystemPolicy>,
93    proc_mode: ProcMode,
94    agent_policy: AgentPolicy,
95}
96
97impl ExecRequest {
98    /// Create a validated execution request.
99    ///
100    /// Returns [`Error::MissingCommand`] when `argv` is empty and
101    /// [`Error::InvalidCwd`] when `cwd` is not an existing directory.
102    pub fn new(
103        cwd: impl Into<PathBuf>,
104        argv: Vec<String>,
105        allowed_env: Vec<String>,
106    ) -> Result<Self> {
107        if argv.is_empty() {
108            return Err(Error::MissingCommand);
109        }
110
111        let cwd = cwd.into();
112        validate_cwd(&cwd)?;
113
114        Ok(Self {
115            cwd,
116            argv,
117            allowed_env,
118            denied_env: Vec::new(),
119            env_policy: EnvPolicy::Allowlist,
120            stdio_policy: StdioPolicy::Inherit,
121            network_mode: NetworkMode::Host,
122            filesystem_policy: None,
123            proc_mode: ProcMode::Default,
124            agent_policy: AgentPolicy::default(),
125        })
126    }
127
128    /// Set the child stdio handling policy.
129    #[must_use]
130    pub const fn with_stdio_policy(mut self, stdio_policy: StdioPolicy) -> Self {
131        self.stdio_policy = stdio_policy;
132        self
133    }
134
135    /// Set the environment policy and denied keys.
136    #[must_use]
137    pub fn with_env_policy(mut self, env_policy: EnvPolicy, denied_env: Vec<String>) -> Self {
138        self.env_policy = env_policy;
139        self.denied_env = denied_env;
140        self
141    }
142
143    /// Set both allowed and denied environment keys.
144    ///
145    /// Sets the policy to [`EnvPolicy::Allowlist`].
146    #[must_use]
147    pub fn with_env(mut self, allowed_env: Vec<String>, denied_env: Vec<String>) -> Self {
148        self.allowed_env = allowed_env;
149        self.denied_env = denied_env;
150        self.env_policy = EnvPolicy::Allowlist;
151        self
152    }
153
154    /// Set the child network isolation mode.
155    #[must_use]
156    pub const fn with_network_mode(mut self, network_mode: NetworkMode) -> Self {
157        self.network_mode = network_mode;
158        self
159    }
160
161    /// Set the filesystem sandbox policy.
162    ///
163    /// `Some` declares confinement, where an empty value confines read-only.
164    /// `None` leaves the child unconfined by filesystem rules unless another
165    /// isolation request applies. Returns an error when filesystem policy
166    /// validation fails.
167    pub fn with_filesystem_policy(
168        mut self,
169        filesystem_policy: Option<FilesystemPolicy>,
170    ) -> Result<Self> {
171        if let Some(policy) = &filesystem_policy {
172            validate_filesystem_policy(policy)?;
173        }
174        self.filesystem_policy = filesystem_policy;
175        Ok(self)
176    }
177
178    /// Set the `/proc` mount policy.
179    #[must_use]
180    pub const fn with_proc_mode(mut self, proc_mode: ProcMode) -> Self {
181        self.proc_mode = proc_mode;
182        self
183    }
184
185    /// Set the host agent socket mount policy.
186    #[must_use]
187    pub const fn with_agent_policy(mut self, agent_policy: AgentPolicy) -> Self {
188        self.agent_policy = agent_policy;
189        self
190    }
191
192    /// Whether this request needs OS-level isolation.
193    ///
194    /// Returns `true` when network isolation, a declared filesystem stance
195    /// (an empty block confines read-only), or host agent socket access is
196    /// requested.
197    #[must_use]
198    pub fn needs_isolation(&self) -> bool {
199        self.network_mode == NetworkMode::None
200            || self.filesystem_policy.is_some()
201            || !self.agent_policy.is_empty()
202    }
203
204    /// Child working directory.
205    #[must_use]
206    pub fn cwd(&self) -> &Path {
207        &self.cwd
208    }
209
210    /// Child command argv, including program name at index zero.
211    #[must_use]
212    pub fn argv(&self) -> &[String] {
213        &self.argv
214    }
215
216    /// Parent environment keys allowed in the child environment.
217    #[must_use]
218    pub fn allowed_env(&self) -> &[String] {
219        &self.allowed_env
220    }
221
222    /// Parent environment keys denied in blocklist mode.
223    #[must_use]
224    pub fn denied_env(&self) -> &[String] {
225        &self.denied_env
226    }
227
228    /// Child environment filtering policy.
229    #[must_use]
230    pub const fn env_policy(&self) -> EnvPolicy {
231        self.env_policy
232    }
233
234    /// Child stdio handling policy.
235    #[must_use]
236    pub const fn stdio_policy(&self) -> StdioPolicy {
237        self.stdio_policy
238    }
239
240    /// Child network isolation policy.
241    #[must_use]
242    pub const fn network_mode(&self) -> NetworkMode {
243        self.network_mode
244    }
245
246    /// Filesystem stance declared by the caller.
247    ///
248    /// `None` means no filesystem confinement was declared; `Some` carries
249    /// the controls, where an empty value confines read-only.
250    #[must_use]
251    pub fn filesystem_policy(&self) -> Option<&FilesystemPolicy> {
252        self.filesystem_policy.as_ref()
253    }
254
255    /// Filesystem controls in force under isolation.
256    ///
257    /// Isolation paths call this after `needs_isolation()` returns `true`; a
258    /// request that declared no filesystem block confines read-only through
259    /// the empty policy, which both renderers treat as their closed-by-default
260    /// base profile.
261    #[must_use]
262    pub fn effective_filesystem_policy(&self) -> Cow<'_, FilesystemPolicy> {
263        match &self.filesystem_policy {
264            Some(policy) => Cow::Borrowed(policy),
265            None => Cow::Owned(FilesystemPolicy::default()),
266        }
267    }
268
269    /// Proc filesystem mount policy.
270    #[must_use]
271    pub const fn proc_mode(&self) -> ProcMode {
272        self.proc_mode
273    }
274
275    /// Host agent socket mount policy.
276    #[must_use]
277    pub const fn agent_policy(&self) -> AgentPolicy {
278        self.agent_policy
279    }
280}
281
282/// Validate a child working directory.
283///
284/// Returns [`Error::InvalidCwd`] when the path is not an existing directory.
285pub fn validate_cwd(cwd: &Path) -> Result<()> {
286    if cwd.is_dir() {
287        Ok(())
288    } else {
289        Err(Error::InvalidCwd(cwd.to_path_buf()))
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use std::collections::BTreeMap;
296
297    use super::*;
298
299    #[test]
300    fn request_accepts_valid_cwd_and_command() {
301        let request = ExecRequest::new(
302            std::env::current_dir().expect("current dir exists"),
303            vec!["printf".into(), "hello".into()],
304            vec!["PATH".into()],
305        )
306        .expect("valid request is accepted");
307
308        assert_eq!(request.argv(), ["printf", "hello"]);
309        assert_eq!(request.allowed_env(), ["PATH"]);
310        assert_eq!(request.stdio_policy(), StdioPolicy::Inherit);
311    }
312
313    #[test]
314    fn request_accepts_piped_stdio_policy() {
315        let request = ExecRequest::new(
316            std::env::current_dir().expect("current dir exists"),
317            vec!["printf".into(), "hello".into()],
318            vec!["PATH".into()],
319        )
320        .expect("valid request is accepted")
321        .with_stdio_policy(StdioPolicy::Piped);
322
323        assert_eq!(request.stdio_policy(), StdioPolicy::Piped);
324    }
325
326    #[test]
327    fn request_rejects_missing_command() {
328        let error = ExecRequest::new(
329            std::env::current_dir().expect("current dir exists"),
330            Vec::<String>::new(),
331            Vec::<String>::new(),
332        )
333        .expect_err("empty command is rejected");
334
335        assert_eq!(error.exit_code(), crate::SANDBOX_MISCONFIGURATION_EXIT_CODE);
336    }
337
338    #[test]
339    fn request_rejects_invalid_cwd() {
340        let error = ExecRequest::new(
341            PathBuf::from("/definitely/not/a/heimdall/sandbox/path"),
342            vec!["true".into()],
343            Vec::<String>::new(),
344        )
345        .expect_err("invalid cwd is rejected");
346
347        assert_eq!(error.exit_code(), crate::SANDBOX_MISCONFIGURATION_EXIT_CODE);
348    }
349
350    #[test]
351    fn request_rejects_invalid_filesystem_policy() {
352        let request = ExecRequest::new(
353            std::env::current_dir().expect("current dir exists"),
354            vec!["true".into()],
355            Vec::<String>::new(),
356        )
357        .expect("valid request is accepted");
358        let filesystem_policy = FilesystemPolicy::new(
359            Vec::new(),
360            Vec::new(),
361            BTreeMap::from([(PathBuf::from("etc/passwd"), "content".to_string())]),
362        );
363
364        let error = request
365            .with_filesystem_policy(Some(filesystem_policy))
366            .expect_err("relative virtual path is rejected");
367
368        assert_eq!(error.exit_code(), crate::SANDBOX_MISCONFIGURATION_EXIT_CODE);
369    }
370}