1use std::process::{Command, ExitCode, Stdio};
2
3use crate::cli::{Cli, ExecCommand, RunCommand};
4use crate::config::{LoadOptions, load_config};
5use crate::error::SboxError;
6use crate::resolve::{ExecutionPlan, ResolutionTarget, resolve_execution_plan};
7
8pub fn execute_run(cli: &Cli, command: &RunCommand) -> Result<ExitCode, SboxError> {
9 execute(cli, ResolutionTarget::Run, &command.command)
10}
11
12pub fn execute_exec(cli: &Cli, command: &ExecCommand) -> Result<ExitCode, SboxError> {
13 execute(
14 cli,
15 ResolutionTarget::Exec {
16 profile: &command.profile,
17 },
18 &command.command,
19 )
20}
21
22fn execute(
23 cli: &Cli,
24 target: ResolutionTarget<'_>,
25 command: &[String],
26) -> Result<ExitCode, SboxError> {
27 let loaded = load_config(&LoadOptions {
28 workspace: cli.workspace.clone(),
29 config: cli.config.clone(),
30 })?;
31 let plan = resolve_execution_plan(cli, &loaded, target, command)?;
32 validate_execution_safety(&plan, strict_security_enabled(cli, &loaded.config))?;
33 run_pre_run_commands(&plan)?;
34
35 execute_plan(&plan)
36}
37
38fn execute_plan(plan: &ExecutionPlan) -> Result<ExitCode, SboxError> {
39 match plan.mode {
40 crate::config::model::ExecutionMode::Host => execute_host(plan),
41 crate::config::model::ExecutionMode::Sandbox => execute_sandbox(plan),
42 }
43}
44
45pub(crate) fn execute_host(plan: &ExecutionPlan) -> Result<ExitCode, SboxError> {
46 let (program, args) = plan
47 .command
48 .split_first()
49 .expect("command vectors are validated by clap");
50
51 let mut child = Command::new(program);
52 child.args(args);
53 child.current_dir(&plan.workspace.effective_host_dir);
54 child.stdin(Stdio::inherit());
55 child.stdout(Stdio::inherit());
56 child.stderr(Stdio::inherit());
57
58 for denied in &plan.environment.denied {
59 child.env_remove(denied);
60 }
61
62 for variable in &plan.environment.variables {
63 child.env(&variable.name, &variable.value);
64 }
65
66 let status = child.status().map_err(|source| SboxError::CommandSpawn {
67 program: program.clone(),
68 source,
69 })?;
70
71 Ok(status_to_exit_code(status))
72}
73
74pub(crate) fn status_to_exit_code(status: std::process::ExitStatus) -> ExitCode {
75 match status.code() {
76 Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
77 None => ExitCode::from(1),
78 }
79}
80
81pub(crate) fn execute_sandbox(plan: &ExecutionPlan) -> Result<ExitCode, SboxError> {
82 match plan.backend {
83 crate::config::BackendKind::Podman => crate::backend::podman::execute(plan),
84 crate::config::BackendKind::Docker => crate::backend::docker::execute(plan),
85 }
86}
87
88pub(crate) fn validate_execution_safety(
89 plan: &ExecutionPlan,
90 strict_security: bool,
91) -> Result<(), SboxError> {
92 if !matches!(plan.mode, crate::config::model::ExecutionMode::Sandbox) {
93 return Ok(());
94 }
95
96 if trusted_image_required(plan, strict_security)
97 && matches!(
98 plan.image.trust,
99 crate::resolve::ImageTrust::MutableReference
100 )
101 {
102 return Err(SboxError::UnsafeExecutionPolicy {
103 command: plan.command_string.clone(),
104 reason: "strict security requires a pinned image digest or local build for sandbox execution".to_string(),
105 });
106 }
107
108 if strict_security && !plan.audit.sensitive_pass_through_vars.is_empty() {
109 return Err(SboxError::UnsafeExecutionPolicy {
110 command: plan.command_string.clone(),
111 reason: format!(
112 "strict security forbids sensitive host pass-through vars in sandbox mode: {}",
113 plan.audit.sensitive_pass_through_vars.join(", ")
114 ),
115 });
116 }
117
118 if strict_security
119 && plan.audit.install_style
120 && plan.audit.lockfile.applicable
121 && plan.audit.lockfile.required
122 && !plan.audit.lockfile.present
123 {
124 return Err(SboxError::UnsafeExecutionPolicy {
125 command: plan.command_string.clone(),
126 reason: format!(
127 "strict security requires a lockfile for install-style commands: expected {}",
128 plan.audit.lockfile.expected_files.join(" or ")
129 ),
130 });
131 }
132
133 if plan.policy.network == "off" || !plan.audit.install_style {
134 return Ok(());
135 }
136
137 if plan.audit.sensitive_pass_through_vars.is_empty() {
138 return Ok(());
139 }
140
141 Err(SboxError::UnsafeExecutionPolicy {
142 command: plan.command_string.clone(),
143 reason: format!(
144 "install-style sandbox command has network enabled and sensitive pass-through vars: {}",
145 plan.audit.sensitive_pass_through_vars.join(", ")
146 ),
147 })
148}
149
150pub(crate) fn run_pre_run_commands(plan: &ExecutionPlan) -> Result<(), SboxError> {
151 for argv in &plan.audit.pre_run {
152 let (program, args) = argv
153 .split_first()
154 .expect("pre_run commands are non-empty after parse");
155
156 let status = Command::new(program)
157 .args(args)
158 .current_dir(&plan.workspace.effective_host_dir)
159 .stdin(Stdio::inherit())
160 .stdout(Stdio::inherit())
161 .stderr(Stdio::inherit())
162 .status()
163 .map_err(|source| SboxError::CommandSpawn {
164 program: program.clone(),
165 source,
166 })?;
167
168 if !status.success() {
169 return Err(SboxError::PreRunFailed {
170 pre_run: argv.join(" "),
171 command: plan.command_string.clone(),
172 status: status.code().unwrap_or(1) as u8,
173 });
174 }
175 }
176
177 Ok(())
178}
179
180pub(crate) fn trusted_image_required(plan: &ExecutionPlan, strict_security: bool) -> bool {
181 matches!(plan.mode, crate::config::model::ExecutionMode::Sandbox)
182 && (strict_security || plan.audit.trusted_image_required)
183}
184
185pub(crate) fn strict_security_enabled(cli: &Cli, config: &crate::config::model::Config) -> bool {
186 cli.strict_security
187 || config
188 .runtime
189 .as_ref()
190 .and_then(|runtime| runtime.strict_security)
191 .unwrap_or(false)
192}
193
194#[cfg(test)]
195mod tests {
196 use super::{strict_security_enabled, trusted_image_required, validate_execution_safety};
197 use crate::config::model::ExecutionMode;
198 use crate::resolve::{
199 CwdMapping, EnvVarSource, ExecutionAudit, ExecutionPlan, LockfileAudit, ModeSource,
200 ProfileSource, ResolvedEnvVar, ResolvedEnvironment, ResolvedImage, ResolvedImageSource,
201 ResolvedPolicy, ResolvedUser, ResolvedWorkspace,
202 };
203 use std::path::PathBuf;
204
205 fn sample_plan() -> ExecutionPlan {
206 ExecutionPlan {
207 command: vec!["npm".into(), "install".into()],
208 command_string: "npm install".into(),
209 backend: crate::config::BackendKind::Podman,
210 image: ResolvedImage {
211 description: "ref:node:22-bookworm-slim".into(),
212 source: ResolvedImageSource::Reference("node:22-bookworm-slim".into()),
213 trust: crate::resolve::ImageTrust::MutableReference,
214 verify_signature: false,
215 },
216 profile_name: "install".into(),
217 profile_source: ProfileSource::DefaultProfile,
218 mode: ExecutionMode::Sandbox,
219 mode_source: ModeSource::Profile,
220 workspace: ResolvedWorkspace {
221 root: PathBuf::from("/tmp/project"),
222 invocation_dir: PathBuf::from("/tmp/project"),
223 effective_host_dir: PathBuf::from("/tmp/project"),
224 mount: "/workspace".into(),
225 sandbox_cwd: "/workspace".into(),
226 cwd_mapping: CwdMapping::InvocationMapped,
227 },
228 policy: ResolvedPolicy {
229 network: "on".into(),
230 writable: true,
231 ports: Vec::new(),
232 no_new_privileges: true,
233 read_only_rootfs: false,
234 reuse_container: false,
235 reusable_session_name: None,
236 cap_drop: Vec::new(),
237 cap_add: Vec::new(),
238 pull_policy: None,
239 network_allow: Vec::new(),
240 network_allow_patterns: Vec::new(),
241 },
242 environment: ResolvedEnvironment {
243 variables: vec![ResolvedEnvVar {
244 name: "NPM_TOKEN".into(),
245 value: "secret".into(),
246 source: EnvVarSource::PassThrough,
247 }],
248 denied: Vec::new(),
249 },
250 mounts: Vec::new(),
251 caches: Vec::new(),
252 secrets: Vec::new(),
253 user: ResolvedUser::KeepId,
254 audit: ExecutionAudit {
255 install_style: true,
256 trusted_image_required: false,
257 sensitive_pass_through_vars: vec!["NPM_TOKEN".into()],
258 lockfile: LockfileAudit {
259 applicable: true,
260 required: true,
261 present: true,
262 expected_files: vec!["package-lock.json".into()],
263 },
264 pre_run: Vec::new(),
265 },
266 }
267 }
268
269 #[test]
270 fn rejects_networked_install_with_sensitive_pass_through_envs() {
271 let error =
272 validate_execution_safety(&sample_plan(), false).expect_err("policy should reject");
273 assert!(error.to_string().contains("unsafe sandbox execution"));
274 }
275
276 #[test]
277 fn allows_networked_install_without_sensitive_pass_through_envs() {
278 let mut plan = sample_plan();
279 plan.audit.sensitive_pass_through_vars.clear();
280 validate_execution_safety(&plan, false).expect("policy should allow");
281 }
282
283 #[test]
284 fn strict_security_rejects_sensitive_pass_through_even_without_install_pattern() {
285 let mut plan = sample_plan();
286 plan.command = vec!["node".into(), "--version".into()];
287 plan.command_string = "node --version".into();
288 plan.audit.install_style = false;
289
290 let error = validate_execution_safety(&plan, true).expect_err("strict mode should reject");
291 assert!(error.to_string().contains("requires a pinned image digest"));
292 }
293
294 #[test]
295 fn strict_security_requires_trusted_image() {
296 let error =
297 validate_execution_safety(&sample_plan(), true).expect_err("strict mode should reject");
298 assert!(error.to_string().contains("pinned image digest"));
299 }
300
301 #[test]
302 fn strict_security_allows_pinned_image() {
303 let mut plan = sample_plan();
304 plan.image.source =
305 ResolvedImageSource::Reference("node:22-bookworm-slim@sha256:deadbeef".into());
306 plan.image.trust = crate::resolve::ImageTrust::PinnedDigest;
307 plan.audit.sensitive_pass_through_vars.clear();
308
309 validate_execution_safety(&plan, true).expect("strict mode should allow pinned images");
310 }
311
312 #[test]
313 fn strict_security_marks_trusted_image_requirement() {
314 assert!(trusted_image_required(&sample_plan(), true));
315 assert!(!trusted_image_required(&sample_plan(), false));
316 }
317
318 #[test]
319 fn profile_policy_requires_trusted_image_without_strict_mode() {
320 let mut plan = sample_plan();
321 plan.audit.trusted_image_required = true;
322
323 let error =
324 validate_execution_safety(&plan, false).expect_err("profile policy should reject");
325 assert!(error.to_string().contains("pinned image digest"));
326 }
327
328 #[test]
329 fn strict_security_requires_lockfile_for_install_flows() {
330 let mut plan = sample_plan();
331 plan.image.source =
332 ResolvedImageSource::Reference("node:22-bookworm-slim@sha256:deadbeef".into());
333 plan.image.trust = crate::resolve::ImageTrust::PinnedDigest;
334 plan.audit.sensitive_pass_through_vars.clear();
335 plan.audit.lockfile.present = false;
336
337 let error =
338 validate_execution_safety(&plan, true).expect_err("missing lockfile should reject");
339 assert!(
340 error
341 .to_string()
342 .contains("requires a lockfile for install-style")
343 );
344 }
345
346 #[test]
347 fn cli_flag_enables_strict_security() {
348 let cli = crate::cli::Cli {
349 config: None,
350 workspace: None,
351 backend: None,
352 image: None,
353 profile: None,
354 mode: None,
355 verbose: 0,
356 quiet: false,
357 strict_security: true,
358 command: crate::cli::Commands::Doctor(crate::cli::DoctorCommand::default()),
359 };
360 let config = crate::config::model::Config {
361 version: 1,
362 runtime: None,
363 workspace: None,
364 identity: None,
365 image: None,
366 environment: None,
367 mounts: Vec::new(),
368 caches: Vec::new(),
369 secrets: Vec::new(),
370 profiles: Default::default(),
371 dispatch: Default::default(),
372
373 package_manager: None,
374 };
375
376 assert!(strict_security_enabled(&cli, &config));
377 }
378}