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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum StdioPolicy {
16 Inherit,
18 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum EnvPolicy {
49 Allowlist,
51 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#[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_policy: Option<FilesystemPolicy>,
93 proc_mode: ProcMode,
94 agent_policy: AgentPolicy,
95}
96
97impl ExecRequest {
98 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[must_use]
206 pub fn cwd(&self) -> &Path {
207 &self.cwd
208 }
209
210 #[must_use]
212 pub fn argv(&self) -> &[String] {
213 &self.argv
214 }
215
216 #[must_use]
218 pub fn allowed_env(&self) -> &[String] {
219 &self.allowed_env
220 }
221
222 #[must_use]
224 pub fn denied_env(&self) -> &[String] {
225 &self.denied_env
226 }
227
228 #[must_use]
230 pub const fn env_policy(&self) -> EnvPolicy {
231 self.env_policy
232 }
233
234 #[must_use]
236 pub const fn stdio_policy(&self) -> StdioPolicy {
237 self.stdio_policy
238 }
239
240 #[must_use]
242 pub const fn network_mode(&self) -> NetworkMode {
243 self.network_mode
244 }
245
246 #[must_use]
251 pub fn filesystem_policy(&self) -> Option<&FilesystemPolicy> {
252 self.filesystem_policy.as_ref()
253 }
254
255 #[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 #[must_use]
271 pub const fn proc_mode(&self) -> ProcMode {
272 self.proc_mode
273 }
274
275 #[must_use]
277 pub const fn agent_policy(&self) -> AgentPolicy {
278 self.agent_policy
279 }
280}
281
282pub 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}