Skip to main content

runx_runtime/
sandbox.rs

1// rust-style-allow: large-file because the sandbox root owns orchestration
2// tests that exercise the split backend, command, env, metadata, and policy
3// modules together.
4mod 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}