Skip to main content

sbox/
exec.rs

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        // Remove any existing entry with the same name so the override wins
81        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        // Names starting with a digit are invalid
462        assert!(apply_env_overrides(&mut plan, &["1FOO=bar".to_string()]).is_err());
463        // Names with hyphens are invalid
464        assert!(apply_env_overrides(&mut plan, &["MY-VAR=val".to_string()]).is_err());
465        // Empty name is invalid
466        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}