Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 59 additions & 6 deletions crates/openshell-sandbox/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ fn apply_canonical_process_environment(
interactive: bool,
user_environment: &HashMap<String, String>,
) {
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.
Expand Down Expand Up @@ -562,16 +563,16 @@ 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,
workspace,
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);
Expand Down Expand Up @@ -727,16 +728,16 @@ 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,
workspace,
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);
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion docs/sandboxes/manage-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
38 changes: 38 additions & 0 deletions e2e/rust/tests/sandbox_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"#;
Expand Down
Loading