From 4c5c661f84930973c46b00f7e628ffe4934bc183 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 16 Sep 2026 12:31:05 -0700 Subject: [PATCH] fix(sandbox): pass declared environment to the initial process Apply the workload environment before stripping supervisor-only values and injecting provider placeholders in both canonical launch paths. The RFC 0012 bootstrap carries these values separately from inherited supervisor environment. Add child-process coverage for declared values, explicit HOME, stripped identity material, and provider precedence. Add a Docker-backed lifecycle regression for initial and exec environments in TTY and non-TTY modes. Validation: the child-process test fails without env application; the Docker regression fails with the pinned unpatched runtime and passes with the rebuilt runtime. Sandbox library tests: 201 passed, one ignored on Rust 1.95.0. mise run pre-commit passed. Full mise run ci is blocked by the host linker missing libz3. Fixes #3377. Signed-off-by: Carlos Villela --- architecture/sandbox.md | 2 + crates/openshell-sandbox/src/process.rs | 65 ++++++++++++++++++++++--- docs/sandboxes/manage-sandboxes.mdx | 2 +- e2e/rust/tests/sandbox_lifecycle.rs | 38 +++++++++++++++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 60b8aa245e..6d828752f6 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -381,6 +381,8 @@ enhancement and out of scope.) Workload proxy variables are removed from the protected launch environment; transparent socket mediation does not depend on them. +The canonical main process receives the declared workload environment before +supervisor-only values are stripped and provider placeholders are injected. Template environment is treated like user-provided sandbox environment. It can shape the workload child, but it cannot override driver-controlled identity, gateway callback, TLS, relay socket, proxy, provider, or supervisor coordination diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 5e07337214..56d4f244ad 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -247,6 +247,7 @@ fn apply_canonical_process_environment( interactive: bool, user_environment: &HashMap, ) { + cmd.envs(user_environment); let (session_user, session_home) = session_user_and_home(policy, workspace.home()); // Resolve a shell present in the sandbox image (minimal images such as // Alpine ship only `/bin/sh`, not bash). Runs in the supervisor. @@ -562,9 +563,6 @@ impl ProcessHandle { // inherited environment. The entrypoint drops to the sandbox user // before `exec`; without this strip, sandbox code could recover // supervisor credentials from its inherited environment. - strip_supervisor_only_env(&mut cmd); - - inject_provider_env(&mut cmd, provider_env); apply_canonical_process_environment( &mut cmd, policy, @@ -572,6 +570,9 @@ impl ProcessHandle { interactive, &configured_user_environment(), ); + strip_supervisor_only_env(&mut cmd); + + inject_provider_env(&mut cmd, provider_env); if let Some(dir) = workspace.root() { cmd.current_dir(dir); @@ -727,9 +728,6 @@ impl ProcessHandle { // Strip supervisor-only identity material from the entrypoint's // inherited environment. - strip_supervisor_only_env(&mut cmd); - - inject_provider_env(&mut cmd, provider_env); apply_canonical_process_environment( &mut cmd, policy, @@ -737,6 +735,9 @@ impl ProcessHandle { interactive, &configured_user_environment(), ); + strip_supervisor_only_env(&mut cmd); + + inject_provider_env(&mut cmd, provider_env); if let Some(dir) = workspace.root() { cmd.current_dir(dir); @@ -2241,6 +2242,58 @@ mod tests { assert_eq!(variables.get("TERM"), Some(&"xterm-256color")); } + #[cfg(unix)] + #[tokio::test] + async fn canonical_process_receives_declared_environment_and_home() { + let current_user = User::from_uid(nix::unistd::geteuid()).unwrap().unwrap(); + let policy = policy_with_process(ProcessPolicy { + run_as_user: Some(current_user.name), + run_as_group: None, + }); + for interactive in [false, true] { + let mut cmd = Command::new("/usr/bin/env"); + cmd.env_clear().stdout(StdStdio::piped()); + let declared = HashMap::from([ + ("APPLICATION_AGENT".into(), "researcher".into()), + ( + openshell_core::sandbox_env::SANDBOX_TOKEN.into(), + "must-not-reach-child".into(), + ), + ("ANTHROPIC_API_KEY".into(), "caller-value".into()), + ("HOME".into(), "/sandbox".into()), + ]); + apply_canonical_process_environment( + &mut cmd, + &policy, + &ResolvedWorkspace::default(), + interactive, + &declared, + ); + strip_supervisor_only_env(&mut cmd); + inject_provider_env( + &mut cmd, + &HashMap::from([( + "ANTHROPIC_API_KEY".into(), + "openshell:resolve:env:ANTHROPIC_API_KEY".into(), + )]), + ); + let output = cmd.output().await.expect("run environment probe"); + assert!(output.status.success()); + let environment = String::from_utf8(output.stdout).unwrap(); + let variables: HashMap<_, _> = environment + .lines() + .filter_map(|line| line.split_once('=')) + .collect(); + assert_eq!(variables.get("APPLICATION_AGENT"), Some(&"researcher")); + assert!(!variables.contains_key(openshell_core::sandbox_env::SANDBOX_TOKEN)); + assert_eq!( + variables.get("ANTHROPIC_API_KEY"), + Some(&"openshell:resolve:env:ANTHROPIC_API_KEY") + ); + assert_eq!(variables.get("HOME"), Some(&"/sandbox")); + } + } + /// Unknown names may yield `Ok(None)` (`… not found …`) or `Err` when NSS fails first /// (e.g. `ENOENT: No such file or directory`). fn assert_unknown_identity_lookup_failed(msg: &str) { diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 730b7da794..d18ec52a94 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -371,7 +371,7 @@ Inject environment variables into the sandbox at creation time: openshell sandbox create --env API_KEY=sk-test --env DEBUG=1 -- my-agent ``` -Variables set with `--env` are available to all processes in the sandbox, including interactive shells and exec commands. +Variables set with `--env` are available to all processes in the sandbox, including the initial command, interactive shells, and exec commands. When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [profile-backed provider](/providers/profiles) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. diff --git a/e2e/rust/tests/sandbox_lifecycle.rs b/e2e/rust/tests/sandbox_lifecycle.rs index 5ba9a67674..0a6c33f3a6 100644 --- a/e2e/rust/tests/sandbox_lifecycle.rs +++ b/e2e/rust/tests/sandbox_lifecycle.rs @@ -470,6 +470,44 @@ async fn detached_canonical_main_nonzero_exit_reaches_error() { sandbox.cleanup().await; } +#[tokio::test] +async fn canonical_main_and_exec_receive_declared_environment() { + for mode in ["--tty", "--no-tty"] { + let script = r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}"; while true; do sleep 1; done"#; + let mut sandbox = SandboxGuard::create_keep_with_args( + &[ + mode, + "--no-auto-providers", + "--env", + "REPRO_SENTINEL=present", + ], + &["sh", "-c", script], + "declared_env=", + ) + .await + .expect("create canonical process with declared environment"); + let initial = normalize_output(&sandbox.create_output); + let later = sandbox + .exec(&[ + "sh", + "-c", + r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}""#, + ]) + .await; + sandbox.cleanup().await; + + assert!( + initial.lines().any(|line| line == "declared_env=present"), + "initial process must receive declared environment ({mode}): {initial}" + ); + let later = normalize_output(&later.expect("exec environment probe")); + assert!( + later.lines().any(|line| line == "declared_env=present"), + "exec must receive the same declared environment ({mode}): {later}" + ); + } +} + #[tokio::test] async fn canonical_tty_main_uses_sandbox_environment() { let script = r#"printf 'canonical_env home=%s user=%s term=%s\n' "$HOME" "$USER" "$TERM"; while true; do sleep 1; done"#;