diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index a78b877e..7cb3d2b8 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -1213,7 +1213,11 @@ async fn handle_run( local_session: Option, ) -> anyhow::Result<()> { apply_secret_bindings(&mut secrets, secret_bindings); - let secrets_hash_map = env::expand_and_inject_env(&mut secrets); + let secrets_hash_map = if secret_bindings.is_empty() { + env::expand_and_inject_env(&mut secrets) + } else { + env::expand_env_without_process_override(&mut secrets) + }; if !silent { let mut success_msg = format!( diff --git a/src/handlers/run/proxy.rs b/src/handlers/run/proxy.rs index 76368e9c..4e77b82f 100644 --- a/src/handlers/run/proxy.rs +++ b/src/handlers/run/proxy.rs @@ -5051,6 +5051,66 @@ mod tests { proxy.stop().await; } + #[tokio::test] + async fn rewrites_a_placeholder_when_the_binding_is_renamed_via_env() { + // Mirrors an agent profile binding `[secrets.GH_TOKEN]` with + // `env = "GITHUB_PAT_TOKEN"`: the secret is fetched under the + // source name `GH_TOKEN`, but both the child-visible env var and + // the policy/secret map are keyed by the renamed target name, the + // same way `secret_child_name` renames both in root.rs. + let (address, authorization) = start_backend().await; + let policy = ProxyPolicy { + secret_policies: HashMap::from([( + "GITHUB_PAT_TOKEN".to_owned(), + SecretHttpPolicy::LegacyHosts(HashSet::from(["127.0.0.1".to_owned()])), + )]), + secret_injections: HashMap::new(), + allowed_egress_hosts: HashSet::from(["*".to_owned()]), + denied_hosts: HashSet::new(), + denied_read_paths: Vec::new(), + denied_write_paths: Vec::new(), + allow_network_listeners: false, + egress_hosts_configured: true, + strict_deny: true, + mcp_rules: Vec::new(), + backend: SandboxBackend::Native, + sandbox_image: None, + sandbox_dockerfile: None, + sandbox_memory: None, + sandbox_cpus: None, + }; + let proxy = Proxy::start( + HashMap::from([("GITHUB_PAT_TOKEN".to_owned(), "real-token".to_owned())]), + policy, + None, + ) + .await + .unwrap(); + + assert_eq!( + proxy + .child_env() + .get("GITHUB_PAT_TOKEN") + .map(String::as_str), + Some("**STASHBASE_GITHUB_PAT_TOKEN**") + ); + assert!(!proxy.child_env().contains_key("GH_TOKEN")); + + let response = proxy_client(&proxy) + .get(format!("http://{address}/")) + .header(AUTHORIZATION, "Bearer **STASHBASE_GITHUB_PAT_TOKEN**") + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + authorization.await.unwrap().as_deref(), + Some("Bearer real-token") + ); + proxy.stop().await; + } + #[tokio::test] async fn secret_hosts_do_not_grant_ordinary_egress() { let (address, authorization) = start_backend().await; diff --git a/src/models/agent.rs b/src/models/agent.rs index f1a8c7bd..6f31ec46 100644 --- a/src/models/agent.rs +++ b/src/models/agent.rs @@ -228,6 +228,42 @@ pub enum AgentHttpRuleEffect { mod tests { use super::*; + #[test] + fn parses_env_rename_alongside_project_and_environment_fields() { + let toml = r#" +egress_hosts = ["api.github.com", "*"] + +[sandbox] +backend = "docker" + +[secrets] +project = "project" +environment = "api-development" + +[secrets.GH_TOKEN] +env = "GITHUB_PAT_TOKEN" + +[[secrets.GH_TOKEN.rules]] +effect = "allow" +hosts = ["api.github.com"] +methods = ["GET", "POST"] +paths = ["*"] +"#; + let profile: AgentProfile = toml::from_str(toml).unwrap(); + assert_eq!(profile.secrets.project.as_deref(), Some("project")); + assert_eq!( + profile.secrets.environment.as_deref(), + Some("api-development") + ); + let binding = profile + .secrets + .bindings + .get("GH_TOKEN") + .expect("GH_TOKEN binding should be parsed"); + assert_eq!(binding.env.as_deref(), Some("GITHUB_PAT_TOKEN")); + assert_eq!(binding.rules.len(), 1); + } + #[test] fn cli_override_forces_docker_regardless_of_profile() { assert_eq!( diff --git a/src/utils/env.rs b/src/utils/env.rs index b3f0f61c..ae91e131 100644 --- a/src/utils/env.rs +++ b/src/utils/env.rs @@ -20,15 +20,36 @@ pub fn expand_and_inject_env(parsed: &mut [SecretWithoutComment]) -> HashMap HashMap { + let process_env = env::vars().collect::>(); + expand_env_inner(parsed, &process_env, false) +} + fn expand_and_inject_env_with_process_env( parsed: &mut [SecretWithoutComment], process_env: &HashMap, +) -> HashMap { + expand_env_inner(parsed, process_env, true) +} + +fn expand_env_inner( + parsed: &mut [SecretWithoutComment], + process_env: &HashMap, + allow_process_override: bool, ) -> HashMap { let mut running_parsed = HashMap::::new(); for secret in parsed.iter_mut() { let current_value = secret.value.clone(); - let process_value = process_env.get(&secret.name); + let process_value = allow_process_override + .then(|| process_env.get(&secret.name)) + .flatten(); let value = match process_value { Some(process_value) if process_value != ¤t_value => process_value.clone(), @@ -179,9 +200,25 @@ fn is_var_char(ch: char) -> bool { mod tests { use std::collections::HashMap; - use super::expand_and_inject_env_with_process_env; + use super::{expand_and_inject_env_with_process_env, expand_env_inner}; use crate::models::secrets::SecretWithoutComment; + #[test] + fn host_variable_does_not_override_a_bound_secret() { + let process_env = HashMap::from([( + "GITHUB_PAT_TOKEN".to_string(), + "stale-host-token".to_string(), + )]); + let mut parsed = vec![SecretWithoutComment { + name: "GITHUB_PAT_TOKEN".to_string(), + value: "fetched-token".to_string(), + }]; + + let result = expand_env_inner(&mut parsed, &process_env, false); + + assert_eq!(result["GITHUB_PAT_TOKEN"], "fetched-token"); + } + #[test] fn expands_empty_values_with_shell_compatible_operator_semantics() { let process_env = HashMap::from([(String::from("EMPTY"), String::from(""))]);