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