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
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}