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}