Skip to content

Commit 5cc865e

Browse files
committed
shell env
1 parent 6a1ce40 commit 5cc865e

3 files changed

Lines changed: 153 additions & 3 deletions

File tree

crates/openfang-runtime/src/subprocess_sandbox.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ pub const SAFE_ENV_VARS_WINDOWS: &[&str] = &[
3535
/// - On Windows, the Windows-specific safe variables (`SAFE_ENV_VARS_WINDOWS`)
3636
/// - Any additional variables the caller explicitly allows via `allowed_env_vars`
3737
///
38+
/// `allowed_env_vars` accepts either explicit variable names or the special
39+
/// wildcard entry `"*"`, which forwards every variable present in the parent
40+
/// process. Use the wildcard only when the operator has explicitly opted in
41+
/// (e.g. `exec_policy.shell_env_passthrough = ["*"]`) — it will leak any
42+
/// secret the parent holds into the child.
43+
///
3844
/// Variables that are not set in the current process environment are silently
3945
/// skipped (rather than being set to empty strings).
4046
pub fn sandbox_command(cmd: &mut tokio::process::Command, allowed_env_vars: &[String]) {
@@ -55,6 +61,14 @@ pub fn sandbox_command(cmd: &mut tokio::process::Command, allowed_env_vars: &[St
5561
}
5662
}
5763

64+
// Wildcard: forward every var from the parent process.
65+
if allowed_env_vars.iter().any(|v| v == "*") {
66+
for (key, val) in std::env::vars() {
67+
cmd.env(key, val);
68+
}
69+
return;
70+
}
71+
5872
// Re-add caller-specified allowed vars.
5973
for var in allowed_env_vars {
6074
if let Ok(val) = std::env::var(var) {
@@ -63,6 +77,22 @@ pub fn sandbox_command(cmd: &mut tokio::process::Command, allowed_env_vars: &[St
6377
}
6478
}
6579

80+
/// Merge two env-passthrough lists (hand-granted + exec-policy-granted),
81+
/// deduplicating entries. If either contains `"*"`, the result is just `["*"]`
82+
/// (wildcard subsumes anything else).
83+
pub fn merge_env_passthrough(a: &[String], b: &[String]) -> Vec<String> {
84+
if a.iter().any(|v| v == "*") || b.iter().any(|v| v == "*") {
85+
return vec!["*".to_string()];
86+
}
87+
let mut out: Vec<String> = Vec::with_capacity(a.len() + b.len());
88+
for v in a.iter().chain(b.iter()) {
89+
if !out.iter().any(|existing| existing == v) {
90+
out.push(v.clone());
91+
}
92+
}
93+
out
94+
}
95+
6696
/// Validates that an executable path does not contain directory traversal
6797
/// components (`..`).
6898
///
@@ -711,6 +741,40 @@ pub async fn wait_or_kill_with_idle(
711741
mod tests {
712742
use super::*;
713743

744+
// ── Env passthrough merge (issue #1169) ────────────────────────────
745+
746+
#[test]
747+
fn test_merge_env_passthrough_empty() {
748+
let merged = merge_env_passthrough(&[], &[]);
749+
assert!(merged.is_empty());
750+
}
751+
752+
#[test]
753+
fn test_merge_env_passthrough_dedup() {
754+
let a = vec!["TZ".to_string(), "HOME".to_string()];
755+
let b = vec!["TZ".to_string(), "PATH".to_string()];
756+
let merged = merge_env_passthrough(&a, &b);
757+
assert_eq!(merged, vec!["TZ", "HOME", "PATH"]);
758+
}
759+
760+
#[test]
761+
fn test_merge_env_passthrough_wildcard_a() {
762+
let merged = merge_env_passthrough(&["*".to_string()], &["TZ".to_string()]);
763+
assert_eq!(merged, vec!["*"]);
764+
}
765+
766+
#[test]
767+
fn test_merge_env_passthrough_wildcard_b() {
768+
let merged = merge_env_passthrough(&["TZ".to_string()], &["*".to_string()]);
769+
assert_eq!(merged, vec!["*"]);
770+
}
771+
772+
#[test]
773+
fn test_exec_policy_default_has_empty_passthrough() {
774+
let policy = openfang_types::config::ExecPolicy::default();
775+
assert!(policy.shell_env_passthrough.is_empty());
776+
}
777+
714778
#[test]
715779
fn test_validate_path() {
716780
// Clean paths should be accepted.

crates/openfang-runtime/src/tool_runner.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ pub async fn execute_tool(
332332
"media_transcribe" => tool_media_transcribe(input, media_engine).await,
333333

334334
// Image generation tool
335-
"image_generate" => tool_image_generate(input, workspace_root).await,
335+
"image_generate" => tool_image_generate(input, workspace_root, media_engine).await,
336336

337337
// TTS/STT tools
338338
"text_to_speech" => tool_text_to_speech(input, tts_engine, workspace_root).await,
@@ -1667,7 +1667,18 @@ async fn tool_shell_exec(
16671667

16681668
// SECURITY: Isolate environment to prevent credential leakage.
16691669
// Hand settings may grant access to specific provider API keys.
1670-
crate::subprocess_sandbox::sandbox_command(&mut cmd, allowed_env);
1670+
//
1671+
// Operators can also forward additional vars via
1672+
// `exec_policy.shell_env_passthrough` (issue #1169). This is the path
1673+
// Docker users hit: their container env (TZ, GOG_*, etc.) is present
1674+
// in PID 1 but `env_clear()` strips it. Listing names (or `"*"`) here
1675+
// re-adds them to the child.
1676+
let policy_env_passthrough: &[String] = exec_policy
1677+
.map(|p| p.shell_env_passthrough.as_slice())
1678+
.unwrap_or(&[]);
1679+
let merged_env =
1680+
crate::subprocess_sandbox::merge_env_passthrough(allowed_env, policy_env_passthrough);
1681+
crate::subprocess_sandbox::sandbox_command(&mut cmd, &merged_env);
16711682

16721683
// Ensure UTF-8 output on Windows
16731684
#[cfg(windows)]
@@ -3035,6 +3046,7 @@ async fn tool_media_transcribe(
30353046
async fn tool_image_generate(
30363047
input: &serde_json::Value,
30373048
workspace_root: Option<&Path>,
3049+
media_engine: Option<&crate::media_understanding::MediaEngine>,
30383050
) -> Result<String, String> {
30393051
let prompt = input["prompt"]
30403052
.as_str()
@@ -3064,7 +3076,11 @@ async fn tool_image_generate(
30643076
count,
30653077
};
30663078

3067-
let result = crate::image_gen::generate_image(&request).await?;
3079+
// Closes #1051: route to a local OpenAI-compatible image generation
3080+
// service when `media.image_gen_base_url` is set.
3081+
let base_url_override = media_engine
3082+
.and_then(|e| e.config().image_gen_base_url.as_deref());
3083+
let result = crate::image_gen::generate_image(&request, base_url_override).await?;
30683084

30693085
// Save images to workspace if available
30703086
let saved_paths = if let Some(workspace) = workspace_root {

crates/openfang-types/src/config.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,25 @@ pub struct ExecPolicy {
969969
/// produce no stdout/stderr output for this duration. Default: 30.
970970
#[serde(default = "default_no_output_timeout")]
971971
pub no_output_timeout_secs: u64,
972+
/// Environment variables to forward from the OpenFang process into
973+
/// `shell_exec` subprocesses.
974+
///
975+
/// By default, subprocesses run with `env_clear()` and only receive a
976+
/// minimal safe set (PATH, HOME, TMPDIR, LANG, TERM, etc. — see
977+
/// `subprocess_sandbox::SAFE_ENV_VARS`). Anything else — including
978+
/// user-defined variables present in the container/host environment —
979+
/// is stripped. This list lets operators explicitly re-add specific
980+
/// variables to the subprocess environment.
981+
///
982+
/// Each entry is an env var name. A single entry of `"*"` forwards
983+
/// every variable present in the parent process. Use with care — `*`
984+
/// will leak API keys and other secrets into child processes.
985+
///
986+
/// Aliases `env_passthrough` and `env_allowlist` are accepted for
987+
/// backwards compatibility with users who configured these names
988+
/// before the field existed (issue #1169).
989+
#[serde(default, alias = "env_passthrough", alias = "env_allowlist")]
990+
pub shell_env_passthrough: Vec<String>,
972991
}
973992

974993
fn default_no_output_timeout() -> u64 {
@@ -990,6 +1009,7 @@ impl Default for ExecPolicy {
9901009
timeout_secs: 30,
9911010
max_output_bytes: 100 * 1024,
9921011
no_output_timeout_secs: default_no_output_timeout(),
1012+
shell_env_passthrough: Vec::new(),
9931013
}
9941014
}
9951015
}
@@ -4600,4 +4620,54 @@ mod tests {
46004620
let config: KernelConfig = toml::from_str(toml_str).unwrap();
46014621
assert_eq!(config.heartbeat.default_timeout_secs, 300);
46024622
}
4623+
4624+
// ── Issue #1169: shell_env_passthrough on ExecPolicy ──────────────
4625+
4626+
#[test]
4627+
fn test_exec_policy_passthrough_default_empty() {
4628+
let policy = ExecPolicy::default();
4629+
assert!(policy.shell_env_passthrough.is_empty());
4630+
}
4631+
4632+
#[test]
4633+
fn test_exec_policy_passthrough_deserializes() {
4634+
let toml_str = r#"
4635+
mode = "full"
4636+
shell_env_passthrough = ["TZ", "GOG_ACCOUNT"]
4637+
"#;
4638+
let policy: ExecPolicy = toml::from_str(toml_str).unwrap();
4639+
assert_eq!(policy.shell_env_passthrough, vec!["TZ", "GOG_ACCOUNT"]);
4640+
}
4641+
4642+
#[test]
4643+
fn test_exec_policy_passthrough_alias_env_passthrough() {
4644+
// Backwards-compat alias from the issue body (#1169).
4645+
let toml_str = r#"
4646+
mode = "full"
4647+
env_passthrough = ["TZ", "GOG_ACCOUNT"]
4648+
"#;
4649+
let policy: ExecPolicy = toml::from_str(toml_str).unwrap();
4650+
assert_eq!(policy.shell_env_passthrough, vec!["TZ", "GOG_ACCOUNT"]);
4651+
}
4652+
4653+
#[test]
4654+
fn test_exec_policy_passthrough_alias_env_allowlist() {
4655+
// Backwards-compat alias from the issue body (#1169).
4656+
let toml_str = r#"
4657+
mode = "full"
4658+
env_allowlist = ["TZ", "HOME"]
4659+
"#;
4660+
let policy: ExecPolicy = toml::from_str(toml_str).unwrap();
4661+
assert_eq!(policy.shell_env_passthrough, vec!["TZ", "HOME"]);
4662+
}
4663+
4664+
#[test]
4665+
fn test_exec_policy_passthrough_wildcard() {
4666+
let toml_str = r#"
4667+
mode = "full"
4668+
shell_env_passthrough = ["*"]
4669+
"#;
4670+
let policy: ExecPolicy = toml::from_str(toml_str).unwrap();
4671+
assert_eq!(policy.shell_env_passthrough, vec!["*"]);
4672+
}
46034673
}

0 commit comments

Comments
 (0)