1mod backend;
5mod command;
6mod env;
7mod metadata;
8mod policy;
9mod template;
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use runx_contracts::JsonObject;
15use runx_parser::{SkillMcpServer, SkillSource};
16
17use crate::RuntimeError;
18
19use self::backend::resolve_sandbox_runtime;
20use self::command::{SandboxSpawnCommand, sandbox_network_enabled, sandbox_spawn_command};
21use self::env::{
22 child_base_env, child_env, cleanup_paths_quietly, prepare_sandbox_tmp_env,
23 sandbox_private_tmp_enabled,
24};
25use self::metadata::sandbox_metadata_with_runtime;
26use self::policy::{
27 resolve_cwd, resolve_cwd_value, resolved_writable_paths, validate_sandbox,
28 validated_writable_paths, workspace_cwd,
29};
30use self::template::resolve_template;
31
32pub use self::metadata::sandbox_metadata;
33
34pub(crate) const RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY_ENV: &str =
35 "RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY";
36
37#[derive(Clone, Debug, PartialEq)]
38pub struct SandboxPlan {
39 pub command: String,
40 pub args: Vec<String>,
41 pub cwd: PathBuf,
42 pub env: BTreeMap<String, String>,
43 pub metadata: JsonObject,
44 pub cleanup_paths: Vec<PathBuf>,
45}
46
47#[cfg(feature = "cli-tool")]
48pub(crate) struct SandboxProcessPlan {
49 pub(crate) command: String,
50 pub(crate) args: Vec<String>,
51 pub(crate) cwd: PathBuf,
52 pub(crate) env: BTreeMap<String, String>,
53 pub(crate) metadata: JsonObject,
54 pub(crate) cleanup_paths: Vec<PathBuf>,
55}
56
57impl SandboxPlan {
58 #[cfg(feature = "cli-tool")]
59 pub(crate) fn into_process_plan(mut self) -> SandboxProcessPlan {
60 SandboxProcessPlan {
61 command: std::mem::take(&mut self.command),
62 args: std::mem::take(&mut self.args),
63 cwd: std::mem::take(&mut self.cwd),
64 env: std::mem::take(&mut self.env),
65 metadata: std::mem::take(&mut self.metadata),
66 cleanup_paths: std::mem::take(&mut self.cleanup_paths),
67 }
68 }
69}
70
71impl Drop for SandboxPlan {
72 fn drop(&mut self) {
73 cleanup_paths_quietly(&self.cleanup_paths);
74 }
75}
76
77pub fn prepare_process_sandbox(
78 source: &SkillSource,
79 skill_directory: &Path,
80 inputs: &JsonObject,
81 base_env: &BTreeMap<String, String>,
82) -> Result<SandboxPlan, RuntimeError> {
83 let command = source.command.clone().ok_or(RuntimeError::MissingCommand)?;
84 let sandbox = source.sandbox.as_ref();
85 validate_sandbox(sandbox)?;
86 let workspace_cwd = workspace_cwd(base_env)?;
87 let cwd = resolve_cwd(source, sandbox, skill_directory, workspace_cwd.as_deref())?;
88 let args = source
89 .args
90 .iter()
91 .map(|arg| resolve_template(arg, inputs, base_env))
92 .collect();
93 let writable_paths = resolved_writable_paths(sandbox, inputs, base_env);
94 let validated_writable_paths =
95 validated_writable_paths(sandbox, &writable_paths, &cwd, workspace_cwd.as_deref())?;
96 let runtime = resolve_sandbox_runtime(sandbox, base_env)?;
97 let private_tmp_enabled = sandbox_private_tmp_enabled(sandbox, runtime.as_ref());
98 let mut cleanup_paths = Vec::new();
99 let mut sandbox_base_env = base_env.clone();
100 prepare_sandbox_tmp_env(sandbox, &runtime, &mut sandbox_base_env, &mut cleanup_paths)?;
101 let env = match child_env(sandbox, &sandbox_base_env, inputs, &mut cleanup_paths) {
102 Ok(env) => env,
103 Err(error) => {
104 cleanup_paths_quietly(&cleanup_paths);
105 return Err(error);
106 }
107 };
108 let (command, args) = sandbox_spawn_command(SandboxSpawnCommand {
109 runtime: runtime.as_ref(),
110 command,
111 args,
112 cwd: &cwd,
113 skill_directory,
114 workspace_cwd: workspace_cwd.as_deref(),
115 writable_paths: &validated_writable_paths,
116 network: sandbox_network_enabled(sandbox),
117 private_tmp: cleanup_paths.first().map(PathBuf::as_path),
118 });
119 Ok(SandboxPlan {
120 command,
121 args,
122 cwd,
123 env,
124 metadata: sandbox_metadata_with_runtime(
125 sandbox,
126 &writable_paths,
127 runtime.as_ref(),
128 private_tmp_enabled,
129 ),
130 cleanup_paths,
131 })
132}
133
134pub fn prepare_mcp_process_sandbox(
135 source: &SkillSource,
136 server: &SkillMcpServer,
137 skill_directory: &Path,
138 base_env: &BTreeMap<String, String>,
139) -> Result<SandboxPlan, RuntimeError> {
140 let sandbox = source.sandbox.as_ref();
141 validate_sandbox(sandbox)?;
142 let workspace_cwd = workspace_cwd(base_env)?;
143 let cwd = resolve_cwd_value(
144 server.cwd.as_deref(),
145 sandbox,
146 skill_directory,
147 workspace_cwd.as_deref(),
148 )?;
149 let writable_paths = resolved_writable_paths(sandbox, &JsonObject::new(), base_env);
150 let validated_writable_paths =
151 validated_writable_paths(sandbox, &writable_paths, &cwd, workspace_cwd.as_deref())?;
152 let runtime = resolve_sandbox_runtime(sandbox, base_env)?;
153 let private_tmp_enabled = sandbox_private_tmp_enabled(sandbox, runtime.as_ref());
154 let mut cleanup_paths = Vec::new();
155 let mut sandbox_base_env = base_env.clone();
156 prepare_sandbox_tmp_env(sandbox, &runtime, &mut sandbox_base_env, &mut cleanup_paths)?;
157 let env = match child_base_env(sandbox, &sandbox_base_env) {
158 Ok(env) => env,
159 Err(error) => {
160 cleanup_paths_quietly(&cleanup_paths);
161 return Err(error);
162 }
163 };
164 let (command, args) = sandbox_spawn_command(SandboxSpawnCommand {
165 runtime: runtime.as_ref(),
166 command: server.command.clone(),
167 args: server.args.clone(),
168 cwd: &cwd,
169 skill_directory,
170 workspace_cwd: workspace_cwd.as_deref(),
171 writable_paths: &validated_writable_paths,
172 network: sandbox_network_enabled(sandbox),
173 private_tmp: cleanup_paths.first().map(PathBuf::as_path),
174 });
175 Ok(SandboxPlan {
176 command,
177 args,
178 cwd,
179 env,
180 metadata: sandbox_metadata_with_runtime(
181 sandbox,
182 &writable_paths,
183 runtime.as_ref(),
184 private_tmp_enabled,
185 ),
186 cleanup_paths,
187 })
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use std::fs;
194 use std::path::{Path, PathBuf};
195
196 use runx_contracts::{JsonObject, JsonValue};
197 use runx_core::policy::SandboxProfile;
198 use runx_parser::{SkillSandbox, SourceKind};
199
200 use super::backend::{SandboxRuntime, find_trusted_executable};
201 use super::command::{
202 sandbox_exec_path_filter_path, sandbox_exec_profile, sandbox_profile_string,
203 };
204 use super::env::{cleanup_paths_quietly, prepare_sandbox_tmp_env};
205 use super::policy::{resolved_writable_paths, validated_writable_paths};
206
207 #[test]
208 fn writable_paths_omit_unresolved_optional_templates() {
209 let sandbox = SkillSandbox {
210 profile: SandboxProfile::WorkspaceWrite,
211 cwd_policy: None,
212 env_allowlist: None,
213 network: None,
214 writable_paths: vec![
215 "{{workspace_path}}".to_owned(),
216 "{{ fixture }}".to_owned(),
217 "{{ env.RUNX_EFFECT_COUNT_PATH }}".to_owned(),
218 "logs".to_owned(),
219 ],
220 require_enforcement: None,
221 approved_escalation: None,
222 raw: JsonObject::new(),
223 };
224 let inputs = [(
225 "fixture".to_owned(),
226 JsonValue::String("/tmp/runx-fixture".to_owned()),
227 )]
228 .into_iter()
229 .collect();
230 let env = [(
231 "RUNX_EFFECT_COUNT_PATH".to_owned(),
232 "/tmp/runx-effect-count.txt".to_owned(),
233 )]
234 .into_iter()
235 .collect();
236
237 assert_eq!(
238 resolved_writable_paths(Some(&sandbox), &inputs, &env),
239 vec![
240 "/tmp/runx-fixture".to_owned(),
241 "/tmp/runx-effect-count.txt".to_owned(),
242 "logs".to_owned()
243 ]
244 );
245 }
246
247 #[test]
248 fn trusted_enforcer_lookup_ignores_caller_path() {
249 let trusted = find_trusted_executable("runx-test-enforcer-that-should-not-exist");
250 assert!(trusted.is_none());
251 }
252
253 #[test]
254 fn sandbox_exec_runtime_gets_private_writable_tmp_env() -> Result<(), String> {
255 let sandbox = readonly_sandbox();
256 let runtime = Some(SandboxRuntime::SandboxExec {
257 path: PathBuf::from("/usr/bin/sandbox-exec"),
258 });
259 let mut env = BTreeMap::new();
260 let mut cleanup_paths = Vec::new();
261 prepare_sandbox_tmp_env(Some(&sandbox), &runtime, &mut env, &mut cleanup_paths)
262 .map_err(|source| source.to_string())?;
263
264 let tmpdir = env
265 .get("TMPDIR")
266 .ok_or_else(|| "TMPDIR was not set".to_owned())?;
267 assert_eq!(env.get("TMP"), Some(tmpdir));
268 assert_eq!(env.get("TEMP"), Some(tmpdir));
269 assert_eq!(cleanup_paths, vec![PathBuf::from(tmpdir)]);
270 assert!(Path::new(tmpdir).is_dir());
271
272 let profile =
273 sandbox_exec_profile(Path::new("/workspace"), &[], true, Some(Path::new(tmpdir)));
274 assert!(profile.contains("(allow file-write* (literal \"/dev/null\"))"));
275 assert!(profile.contains("(allow mach-lookup)"));
276 let tmp_filter_path = sandbox_exec_path_filter_path(Path::new(tmpdir));
277 assert!(profile.contains(&format!(
278 "(subpath \"{}\")",
279 sandbox_profile_string(&tmp_filter_path)
280 )));
281 cleanup_paths_quietly(&cleanup_paths);
282 Ok(())
283 }
284
285 #[test]
286 fn sandbox_exec_profile_keeps_legitimate_writable_path() {
287 let profile = sandbox_exec_profile(
288 Path::new("/workspace"),
289 &[PathBuf::from("/workspace/logs/output")],
290 false,
291 None,
292 );
293
294 assert!(profile.contains("(allow file-write* (literal \"/workspace/logs/output\"))"));
295 assert!(!profile.contains("(allow network*)"));
296 }
297
298 #[test]
299 fn sandbox_exec_profile_sanitizes_metacharacters_if_validation_is_bypassed() {
300 let profile = sandbox_exec_profile(
301 Path::new("/workspace"),
302 &[PathBuf::from("safe\")) (allow network*)")],
303 false,
304 None,
305 );
306
307 assert!(!profile.contains("(allow network*)"));
308 assert!(!profile.contains("(subpath \"/\""));
309 }
310
311 #[test]
312 fn declared_policy_runtime_gets_private_tmp_env() -> Result<(), String> {
313 let sandbox = readonly_sandbox();
314 let runtime = Some(SandboxRuntime::DeclaredPolicyOnly {
315 reason: "missing test backend".to_owned(),
316 });
317 let mut env = BTreeMap::new();
318 let mut cleanup_paths = Vec::new();
319 prepare_sandbox_tmp_env(Some(&sandbox), &runtime, &mut env, &mut cleanup_paths)
320 .map_err(|source| source.to_string())?;
321
322 let tmpdir = env
323 .get("TMPDIR")
324 .ok_or_else(|| "TMPDIR was not set".to_owned())?;
325 assert_eq!(env.get("TMP"), Some(tmpdir));
326 assert_eq!(env.get("TEMP"), Some(tmpdir));
327 assert_eq!(cleanup_paths, vec![PathBuf::from(tmpdir)]);
328 assert!(Path::new(tmpdir).is_dir());
329
330 cleanup_paths_quietly(&cleanup_paths);
331 Ok(())
332 }
333
334 #[test]
335 fn process_child_env_strips_receipt_signing_env() -> Result<(), String> {
336 let temp = tempfile::tempdir().map_err(|source| source.to_string())?;
337 let source = source_for_child_env(SourceKind::CliTool);
338 let base_env = signing_env(temp.path());
339
340 let plan = prepare_process_sandbox(&source, temp.path(), &JsonObject::new(), &base_env)
341 .map_err(|source| source.to_string())?;
342
343 assert_child_env_has_no_receipt_signing_env(&plan.env);
344 Ok(())
345 }
346
347 #[test]
348 fn mcp_process_child_env_strips_receipt_signing_env() -> Result<(), String> {
349 let temp = tempfile::tempdir().map_err(|source| source.to_string())?;
350 let source = source_for_child_env(SourceKind::Mcp);
351 let server = SkillMcpServer {
352 command: "node".to_owned(),
353 args: vec!["server.mjs".to_owned()],
354 cwd: Some(temp.path().to_string_lossy().into_owned()),
355 };
356 let base_env = signing_env(temp.path());
357
358 let plan = prepare_mcp_process_sandbox(&source, &server, temp.path(), &base_env)
359 .map_err(|source| source.to_string())?;
360
361 assert_child_env_has_no_receipt_signing_env(&plan.env);
362 Ok(())
363 }
364
365 fn readonly_sandbox() -> SkillSandbox {
366 SkillSandbox {
367 profile: SandboxProfile::Readonly,
368 cwd_policy: None,
369 env_allowlist: None,
370 network: None,
371 writable_paths: Vec::new(),
372 require_enforcement: None,
373 approved_escalation: None,
374 raw: JsonObject::new(),
375 }
376 }
377
378 fn source_for_child_env(source_type: SourceKind) -> SkillSource {
379 SkillSource {
380 act: None,
381 source_type,
382 command: Some("node".to_owned()),
383 args: vec!["script.mjs".to_owned()],
384 cwd: None,
385 timeout_seconds: None,
386 input_mode: None,
387 sandbox: None,
388 server: None,
389 catalog_ref: None,
390 tool: None,
391 arguments: None,
392 agent_card_url: None,
393 agent_identity: None,
394 agent: None,
395 task: None,
396 hook: None,
397 outputs: None,
398 graph: None,
399 http: None,
400 raw: JsonObject::new(),
401 }
402 }
403
404 fn signing_env(workspace: &Path) -> BTreeMap<String, String> {
405 [
406 ("PATH".to_owned(), "/usr/bin".to_owned()),
407 (
408 crate::receipts::RUNX_RECEIPT_SIGN_KID_ENV.to_owned(),
409 "kid_prod".to_owned(),
410 ),
411 (
412 crate::receipts::RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV.to_owned(),
413 "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=".to_owned(),
414 ),
415 (
416 crate::receipts::RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV.to_owned(),
417 "hosted".to_owned(),
418 ),
419 (
420 crate::receipts::paths::RUNX_CWD_ENV.to_owned(),
421 workspace.to_string_lossy().into_owned(),
422 ),
423 ]
424 .into_iter()
425 .collect()
426 }
427
428 fn assert_child_env_has_no_receipt_signing_env(env: &BTreeMap<String, String>) {
429 assert!(!env.contains_key(crate::receipts::RUNX_RECEIPT_SIGN_KID_ENV));
430 assert!(!env.contains_key(crate::receipts::RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV));
431 assert!(!env.contains_key(crate::receipts::RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV));
432 assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_owned()));
433 }
434
435 #[test]
436 fn writable_path_rejects_sexpr_metacharacters() -> Result<(), String> {
437 let temp = tempfile::tempdir().map_err(|source| source.to_string())?;
438 let workspace = temp.path().join("workspace");
439 fs::create_dir_all(&workspace).map_err(|source| source.to_string())?;
440 let sandbox = SkillSandbox {
441 profile: SandboxProfile::WorkspaceWrite,
442 cwd_policy: None,
443 env_allowlist: None,
444 network: None,
445 writable_paths: Vec::new(),
446 require_enforcement: None,
447 approved_escalation: None,
448 raw: JsonObject::new(),
449 };
450
451 let error = validated_writable_paths(
452 Some(&sandbox),
453 &["safe\")) (allow network*)".to_owned()],
454 &workspace,
455 Some(&workspace),
456 )
457 .err()
458 .ok_or_else(|| "sexpr metacharacter path unexpectedly passed".to_owned())?;
459
460 assert!(
461 error.to_string().contains("profile metacharacters"),
462 "unexpected error: {error}"
463 );
464 Ok(())
465 }
466
467 #[test]
468 fn workspace_write_allows_uncreated_nested_workspace_path() -> Result<(), String> {
469 let temp = tempfile::tempdir().map_err(|source| source.to_string())?;
470 let workspace = temp.path().join("workspace");
471 fs::create_dir_all(&workspace).map_err(|source| source.to_string())?;
472 let sandbox = SkillSandbox {
473 profile: SandboxProfile::WorkspaceWrite,
474 cwd_policy: None,
475 env_allowlist: None,
476 network: None,
477 writable_paths: Vec::new(),
478 require_enforcement: None,
479 approved_escalation: None,
480 raw: JsonObject::new(),
481 };
482
483 validated_writable_paths(
484 Some(&sandbox),
485 &["dist/cache/output.json".to_owned()],
486 &workspace,
487 Some(&workspace),
488 )
489 .map(|_| ())
490 .map_err(|source| source.to_string())
491 }
492
493 #[test]
494 #[cfg(unix)]
495 fn workspace_write_rejects_symlink_escape() -> Result<(), String> {
496 let temp = tempfile::tempdir().map_err(|source| source.to_string())?;
497 let workspace = temp.path().join("workspace");
498 let outside = temp.path().join("outside");
499 fs::create_dir_all(&workspace).map_err(|source| source.to_string())?;
500 fs::create_dir_all(&outside).map_err(|source| source.to_string())?;
501 std::os::unix::fs::symlink(&outside, workspace.join("link"))
502 .map_err(|source| source.to_string())?;
503 let sandbox = SkillSandbox {
504 profile: SandboxProfile::WorkspaceWrite,
505 cwd_policy: None,
506 env_allowlist: None,
507 network: None,
508 writable_paths: Vec::new(),
509 require_enforcement: None,
510 approved_escalation: None,
511 raw: JsonObject::new(),
512 };
513
514 let error = validated_writable_paths(
515 Some(&sandbox),
516 &["link/escape.txt".to_owned()],
517 &workspace,
518 Some(&workspace),
519 )
520 .err()
521 .ok_or_else(|| "symlink escape unexpectedly passed".to_owned())?;
522
523 assert!(
524 error.to_string().contains("outside workspace"),
525 "unexpected error: {error}"
526 );
527 Ok(())
528 }
529}