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::{
7 EnvVarSource, ExecutionPlan, ResolutionTarget, ResolvedEnvVar, resolve_execution_plan,
8};
9
10pub fn execute_run(cli: &Cli, command: &RunCommand) -> Result<ExitCode, SboxError> {
11 let loaded = load_config(&LoadOptions {
12 workspace: cli.workspace.clone(),
13 config: cli.config.clone(),
14 })?;
15 let mut plan = resolve_execution_plan(cli, &loaded, ResolutionTarget::Run, &command.command)?;
16
17 apply_env_overrides(&mut plan, &command.env)?;
18
19 if command.dry_run {
20 print!(
21 "{}",
22 crate::plan::render_plan(
23 &loaded.config_path,
24 &plan,
25 strict_security_enabled(cli, &loaded.config),
26 true,
27 false
28 )
29 );
30 return Ok(ExitCode::SUCCESS);
31 }
32
33 validate_execution_safety(&plan, strict_security_enabled(cli, &loaded.config))?;
34 run_pre_run_commands(&plan)?;
35 execute_plan(&plan)
36}
37
38pub fn execute_exec(cli: &Cli, command: &ExecCommand) -> Result<ExitCode, SboxError> {
39 execute(
40 cli,
41 ResolutionTarget::Exec {
42 profile: &command.profile,
43 },
44 &command.command,
45 )
46}
47
48fn execute(
49 cli: &Cli,
50 target: ResolutionTarget<'_>,
51 command: &[String],
52) -> Result<ExitCode, SboxError> {
53 let loaded = load_config(&LoadOptions {
54 workspace: cli.workspace.clone(),
55 config: cli.config.clone(),
56 })?;
57 let plan = resolve_execution_plan(cli, &loaded, target, command)?;
58 validate_execution_safety(&plan, strict_security_enabled(cli, &loaded.config))?;
59 run_pre_run_commands(&plan)?;
60
61 execute_plan(&plan)
62}
63
64fn apply_env_overrides(plan: &mut ExecutionPlan, overrides: &[String]) -> Result<(), SboxError> {
65 for entry in overrides {
66 let (name, value) = entry
67 .split_once('=')
68 .ok_or_else(|| SboxError::ConfigValidation {
69 message: format!("-e `{entry}` must be in NAME=VALUE format"),
70 })?;
71 if !is_valid_env_name(name) {
72 return Err(SboxError::ConfigValidation {
73 message: format!(
74 "-e `{entry}`: `{name}` is not a valid environment variable name \
75 (must be non-empty, start with a letter or underscore, and contain \
76 only letters, digits, or underscores)"
77 ),
78 });
79 }
80 plan.environment.variables.retain(|v| v.name != name);
82 plan.environment.variables.push(ResolvedEnvVar {
83 name: name.to_string(),
84 value: value.to_string(),
85 source: EnvVarSource::Set,
86 });
87 }
88 Ok(())
89}
90
91fn is_valid_env_name(name: &str) -> bool {
92 let mut chars = name.chars();
93 match chars.next() {
94 None => false,
95 Some(first) => {
96 (first.is_ascii_alphabetic() || first == '_')
97 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
98 }
99 }
100}
101
102fn execute_plan(plan: &ExecutionPlan) -> Result<ExitCode, SboxError> {
103 match plan.mode {
104 crate::config::model::ExecutionMode::Host => execute_host(plan),
105 crate::config::model::ExecutionMode::Sandbox => execute_sandbox(plan),
106 }
107}
108
109pub(crate) fn execute_host(plan: &ExecutionPlan) -> Result<ExitCode, SboxError> {
110 let (program, args) = plan
111 .command
112 .split_first()
113 .expect("command vectors are validated by clap");
114
115 let mut child = Command::new(program);
116 child.args(args);
117 child.current_dir(&plan.workspace.effective_host_dir);
118 child.stdin(Stdio::inherit());
119 child.stdout(Stdio::inherit());
120 child.stderr(Stdio::inherit());
121
122 for denied in &plan.environment.denied {
123 child.env_remove(denied);
124 }
125
126 for variable in &plan.environment.variables {
127 child.env(&variable.name, &variable.value);
128 }
129
130 let status = child.status().map_err(|source| SboxError::CommandSpawn {
131 program: program.clone(),
132 source,
133 })?;
134
135 Ok(status_to_exit_code(status))
136}
137
138pub(crate) fn status_to_exit_code(status: std::process::ExitStatus) -> ExitCode {
139 match status.code() {
140 Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
141 None => ExitCode::from(1),
142 }
143}
144
145pub(crate) fn execute_sandbox(plan: &ExecutionPlan) -> Result<ExitCode, SboxError> {
146 match plan.backend {
147 crate::config::BackendKind::Podman => crate::backend::podman::execute(plan),
148 crate::config::BackendKind::Docker => crate::backend::docker::execute(plan),
149 }
150}
151
152pub(crate) fn validate_execution_safety(
153 plan: &ExecutionPlan,
154 strict_security: bool,
155) -> Result<(), SboxError> {
156 if !matches!(plan.mode, crate::config::model::ExecutionMode::Sandbox) {
157 return Ok(());
158 }
159
160 if trusted_image_required(plan, strict_security)
161 && matches!(
162 plan.image.trust,
163 crate::resolve::ImageTrust::MutableReference
164 )
165 {
166 return Err(SboxError::UnsafeExecutionPolicy {
167 command: plan.command_string.clone(),
168 reason: "strict security requires a pinned image digest or local build for sandbox execution".to_string(),
169 });
170 }
171
172 if strict_security && !plan.audit.sensitive_pass_through_vars.is_empty() {
173 return Err(SboxError::UnsafeExecutionPolicy {
174 command: plan.command_string.clone(),
175 reason: format!(
176 "strict security forbids sensitive host pass-through vars in sandbox mode: {}",
177 plan.audit.sensitive_pass_through_vars.join(", ")
178 ),
179 });
180 }
181
182 if strict_security
183 && plan.audit.install_style
184 && plan.audit.lockfile.applicable
185 && plan.audit.lockfile.required
186 && !plan.audit.lockfile.present
187 {
188 return Err(SboxError::UnsafeExecutionPolicy {
189 command: plan.command_string.clone(),
190 reason: format!(
191 "strict security requires a lockfile for install-style commands: expected {}",
192 plan.audit.lockfile.expected_files.join(" or ")
193 ),
194 });
195 }
196
197 if plan.policy.network == "off" || !plan.audit.install_style {
198 return Ok(());
199 }
200
201 if plan.audit.sensitive_pass_through_vars.is_empty() {
202 return Ok(());
203 }
204
205 Err(SboxError::UnsafeExecutionPolicy {
206 command: plan.command_string.clone(),
207 reason: format!(
208 "install-style sandbox command has network enabled and sensitive pass-through vars: {}",
209 plan.audit.sensitive_pass_through_vars.join(", ")
210 ),
211 })
212}
213
214pub(crate) fn run_pre_run_commands(plan: &ExecutionPlan) -> Result<(), SboxError> {
215 for argv in &plan.audit.pre_run {
216 let (program, args) = argv
217 .split_first()
218 .expect("pre_run commands are non-empty after parse");
219
220 let status = Command::new(program)
221 .args(args)
222 .current_dir(&plan.workspace.effective_host_dir)
223 .stdin(Stdio::inherit())
224 .stdout(Stdio::inherit())
225 .stderr(Stdio::inherit())
226 .status()
227 .map_err(|source| SboxError::CommandSpawn {
228 program: program.clone(),
229 source,
230 })?;
231
232 if !status.success() {
233 return Err(SboxError::PreRunFailed {
234 pre_run: argv.join(" "),
235 command: plan.command_string.clone(),
236 status: status.code().unwrap_or(1) as u8,
237 });
238 }
239 }
240
241 Ok(())
242}
243
244pub(crate) fn trusted_image_required(plan: &ExecutionPlan, strict_security: bool) -> bool {
245 matches!(plan.mode, crate::config::model::ExecutionMode::Sandbox)
246 && (strict_security || plan.audit.trusted_image_required)
247}
248
249pub(crate) fn strict_security_enabled(cli: &Cli, config: &crate::config::model::Config) -> bool {
250 cli.strict_security
251 || config
252 .runtime
253 .as_ref()
254 .and_then(|runtime| runtime.strict_security)
255 .unwrap_or(false)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::{
261 apply_env_overrides, is_valid_env_name, strict_security_enabled, trusted_image_required,
262 validate_execution_safety,
263 };
264 use crate::config::model::ExecutionMode;
265 use crate::resolve::{
266 CwdMapping, EnvVarSource, ExecutionAudit, ExecutionPlan, LockfileAudit, ModeSource,
267 ProfileSource, ResolvedEnvVar, ResolvedEnvironment, ResolvedImage, ResolvedImageSource,
268 ResolvedPolicy, ResolvedUser, ResolvedWorkspace,
269 };
270 use std::path::PathBuf;
271
272 fn sample_plan() -> ExecutionPlan {
273 ExecutionPlan {
274 command: vec!["npm".into(), "install".into()],
275 command_string: "npm install".into(),
276 backend: crate::config::BackendKind::Podman,
277 image: ResolvedImage {
278 description: "ref:node:22-bookworm-slim".into(),
279 source: ResolvedImageSource::Reference("node:22-bookworm-slim".into()),
280 trust: crate::resolve::ImageTrust::MutableReference,
281 verify_signature: false,
282 },
283 profile_name: "install".into(),
284 profile_source: ProfileSource::DefaultProfile,
285 mode: ExecutionMode::Sandbox,
286 mode_source: ModeSource::Profile,
287 workspace: ResolvedWorkspace {
288 root: PathBuf::from("/tmp/project"),
289 invocation_dir: PathBuf::from("/tmp/project"),
290 effective_host_dir: PathBuf::from("/tmp/project"),
291 mount: "/workspace".into(),
292 sandbox_cwd: "/workspace".into(),
293 cwd_mapping: CwdMapping::InvocationMapped,
294 },
295 policy: ResolvedPolicy {
296 network: "on".into(),
297 writable: true,
298 ports: Vec::new(),
299 no_new_privileges: true,
300 read_only_rootfs: false,
301 reuse_container: false,
302 reusable_session_name: None,
303 cap_drop: Vec::new(),
304 cap_add: Vec::new(),
305 pull_policy: None,
306 network_allow: Vec::new(),
307 network_allow_patterns: Vec::new(),
308 },
309 environment: ResolvedEnvironment {
310 variables: vec![ResolvedEnvVar {
311 name: "NPM_TOKEN".into(),
312 value: "secret".into(),
313 source: EnvVarSource::PassThrough,
314 }],
315 denied: Vec::new(),
316 },
317 mounts: Vec::new(),
318 caches: Vec::new(),
319 secrets: Vec::new(),
320 user: ResolvedUser::KeepId,
321 audit: ExecutionAudit {
322 install_style: true,
323 trusted_image_required: false,
324 sensitive_pass_through_vars: vec!["NPM_TOKEN".into()],
325 lockfile: LockfileAudit {
326 applicable: true,
327 required: true,
328 present: true,
329 expected_files: vec!["package-lock.json".into()],
330 },
331 pre_run: Vec::new(),
332 },
333 }
334 }
335
336 #[test]
337 fn rejects_networked_install_with_sensitive_pass_through_envs() {
338 let error =
339 validate_execution_safety(&sample_plan(), false).expect_err("policy should reject");
340 assert!(error.to_string().contains("unsafe sandbox execution"));
341 }
342
343 #[test]
344 fn allows_networked_install_without_sensitive_pass_through_envs() {
345 let mut plan = sample_plan();
346 plan.audit.sensitive_pass_through_vars.clear();
347 validate_execution_safety(&plan, false).expect("policy should allow");
348 }
349
350 #[test]
351 fn strict_security_rejects_sensitive_pass_through_even_without_install_pattern() {
352 let mut plan = sample_plan();
353 plan.command = vec!["node".into(), "--version".into()];
354 plan.command_string = "node --version".into();
355 plan.audit.install_style = false;
356
357 let error = validate_execution_safety(&plan, true).expect_err("strict mode should reject");
358 assert!(error.to_string().contains("requires a pinned image digest"));
359 }
360
361 #[test]
362 fn strict_security_requires_trusted_image() {
363 let error =
364 validate_execution_safety(&sample_plan(), true).expect_err("strict mode should reject");
365 assert!(error.to_string().contains("pinned image digest"));
366 }
367
368 #[test]
369 fn strict_security_allows_pinned_image() {
370 let mut plan = sample_plan();
371 plan.image.source =
372 ResolvedImageSource::Reference("node:22-bookworm-slim@sha256:deadbeef".into());
373 plan.image.trust = crate::resolve::ImageTrust::PinnedDigest;
374 plan.audit.sensitive_pass_through_vars.clear();
375
376 validate_execution_safety(&plan, true).expect("strict mode should allow pinned images");
377 }
378
379 #[test]
380 fn strict_security_marks_trusted_image_requirement() {
381 assert!(trusted_image_required(&sample_plan(), true));
382 assert!(!trusted_image_required(&sample_plan(), false));
383 }
384
385 #[test]
386 fn profile_policy_requires_trusted_image_without_strict_mode() {
387 let mut plan = sample_plan();
388 plan.audit.trusted_image_required = true;
389
390 let error =
391 validate_execution_safety(&plan, false).expect_err("profile policy should reject");
392 assert!(error.to_string().contains("pinned image digest"));
393 }
394
395 #[test]
396 fn strict_security_requires_lockfile_for_install_flows() {
397 let mut plan = sample_plan();
398 plan.image.source =
399 ResolvedImageSource::Reference("node:22-bookworm-slim@sha256:deadbeef".into());
400 plan.image.trust = crate::resolve::ImageTrust::PinnedDigest;
401 plan.audit.sensitive_pass_through_vars.clear();
402 plan.audit.lockfile.present = false;
403
404 let error =
405 validate_execution_safety(&plan, true).expect_err("missing lockfile should reject");
406 assert!(
407 error
408 .to_string()
409 .contains("requires a lockfile for install-style")
410 );
411 }
412
413 #[test]
414 fn env_override_replaces_existing_variable() {
415 let mut plan = sample_plan();
416 plan.environment.variables.push(ResolvedEnvVar {
417 name: "MY_VAR".into(),
418 value: "old".into(),
419 source: EnvVarSource::Set,
420 });
421 apply_env_overrides(&mut plan, &["MY_VAR=new".to_string()])
422 .expect("override should succeed");
423 let vars: Vec<_> = plan
424 .environment
425 .variables
426 .iter()
427 .filter(|v| v.name == "MY_VAR")
428 .collect();
429 assert_eq!(vars.len(), 1, "duplicate must be removed");
430 assert_eq!(vars[0].value, "new");
431 }
432
433 #[test]
434 fn env_override_accepts_multiple_entries() {
435 let mut plan = sample_plan();
436 apply_env_overrides(
437 &mut plan,
438 &["FOO=bar".to_string(), "BAZ=qux".to_string()],
439 )
440 .expect("multiple overrides should succeed");
441 let names: Vec<_> = plan
442 .environment
443 .variables
444 .iter()
445 .map(|v| v.name.as_str())
446 .collect();
447 assert!(names.contains(&"FOO"));
448 assert!(names.contains(&"BAZ"));
449 }
450
451 #[test]
452 fn env_override_rejects_missing_equals() {
453 let mut plan = sample_plan();
454 let result = apply_env_overrides(&mut plan, &["NOEQUALS".to_string()]);
455 assert!(result.is_err(), "missing = must be rejected");
456 }
457
458 #[test]
459 fn env_override_rejects_invalid_name() {
460 let mut plan = sample_plan();
461 assert!(apply_env_overrides(&mut plan, &["1FOO=bar".to_string()]).is_err());
463 assert!(apply_env_overrides(&mut plan, &["MY-VAR=val".to_string()]).is_err());
465 assert!(apply_env_overrides(&mut plan, &["=value".to_string()]).is_err());
467 }
468
469 #[test]
470 fn is_valid_env_name_accepts_valid_names() {
471 assert!(is_valid_env_name("FOO"));
472 assert!(is_valid_env_name("_PRIVATE"));
473 assert!(is_valid_env_name("MY_VAR_123"));
474 assert!(is_valid_env_name("a"));
475 }
476
477 #[test]
478 fn is_valid_env_name_rejects_invalid_names() {
479 assert!(!is_valid_env_name(""));
480 assert!(!is_valid_env_name("1FOO"));
481 assert!(!is_valid_env_name("MY-VAR"));
482 assert!(!is_valid_env_name("MY VAR"));
483 assert!(!is_valid_env_name("MY.VAR"));
484 }
485
486 #[test]
487 fn cli_flag_enables_strict_security() {
488 let cli = crate::cli::Cli {
489 config: None,
490 workspace: None,
491 backend: None,
492 image: None,
493 profile: None,
494 mode: None,
495 verbose: 0,
496 quiet: false,
497 strict_security: true,
498 command: crate::cli::Commands::Doctor(crate::cli::DoctorCommand::default()),
499 };
500 let config = crate::config::model::Config {
501 version: 1,
502 runtime: None,
503 workspace: None,
504 identity: None,
505 image: None,
506 environment: None,
507 mounts: Vec::new(),
508 caches: Vec::new(),
509 secrets: Vec::new(),
510 profiles: Default::default(),
511 dispatch: Default::default(),
512
513 package_manager: None,
514 };
515
516 assert!(strict_security_enabled(&cli, &config));
517 }
518}