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
6 changes: 5 additions & 1 deletion src/handlers/run/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1213,7 +1213,11 @@ async fn handle_run(
local_session: Option<crate::handlers::agent::sessions::LocalAgentSessionGuard>,
) -> 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!(
Expand Down
60 changes: 60 additions & 0 deletions src/handlers/run/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions src/models/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
41 changes: 39 additions & 2 deletions src/utils/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,36 @@ pub fn expand_and_inject_env(parsed: &mut [SecretWithoutComment]) -> HashMap<Str
expand_and_inject_env_with_process_env(parsed, &process_env)
}

/// Like `expand_and_inject_env`, but a same-named variable in the invoking
/// shell never replaces a secret value. Agent profile bindings rename secrets
/// for the child, and an unrelated host variable with that name must not
/// shadow the credential fetched for the binding.
pub fn expand_env_without_process_override(
parsed: &mut [SecretWithoutComment],
) -> HashMap<String, String> {
let process_env = env::vars().collect::<HashMap<_, _>>();
expand_env_inner(parsed, &process_env, false)
}

fn expand_and_inject_env_with_process_env(
parsed: &mut [SecretWithoutComment],
process_env: &HashMap<String, String>,
) -> HashMap<String, String> {
expand_env_inner(parsed, process_env, true)
}

fn expand_env_inner(
parsed: &mut [SecretWithoutComment],
process_env: &HashMap<String, String>,
allow_process_override: bool,
) -> HashMap<String, String> {
let mut running_parsed = HashMap::<String, String>::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 != &current_value => process_value.clone(),
Expand Down Expand Up @@ -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(""))]);
Expand Down
Loading