diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index dd558495ce..00446f0419 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -397,6 +397,7 @@ enum NetworkModeSchema { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct NetworkMitmActionToml { + pub aws_sigv4: Option, pub strip_request_headers: Vec, pub inject_request_headers: Vec, } @@ -445,7 +446,7 @@ impl NetworkMitmToml { .inject_request_headers .iter() .any(|header| header.placeholder.is_some()); - if self.version.is_none() && uses_placeholder { + if self.version.is_none() && (uses_placeholder || action.aws_sigv4.is_some()) { return Err("scoped MITM actions require network.mitm.version = 2".to_string()); } if action.is_empty() { @@ -461,6 +462,21 @@ impl NetworkMitmToml { }; for (hook_name, hook) in hooks { + let signing_count = hook + .action + .iter() + .filter(|name| { + self.actions + .as_ref() + .and_then(|actions| actions.get(*name)) + .is_some_and(|action| action.aws_sigv4.is_some()) + }) + .count(); + if signing_count > 1 { + return Err(format!( + "network.mitm.hooks.{hook_name} must select at most one AWS signing action" + )); + } if self.version.is_none() && (!hook.schemes.is_empty() || !hook.ports.is_empty()) { return Err("scoped MITM hooks require network.mitm.version = 2".to_string()); } @@ -492,7 +508,9 @@ impl NetworkMitmToml { impl NetworkMitmActionToml { pub fn is_empty(&self) -> bool { - self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty() + self.aws_sigv4.is_none() + && self.strip_request_headers.is_empty() + && self.inject_request_headers.is_empty() } } @@ -590,6 +608,9 @@ impl NetworkMitmHookToml { let mut selected = MitmHookActionsConfig::default(); for action_name in &self.action { if let Some(action) = actions_by_name.get(action_name) { + if action.aws_sigv4.is_some() { + selected.aws_sigv4 = action.aws_sigv4.clone(); + } selected .strip_request_headers .extend(action.strip_request_headers.clone()); diff --git a/codex-rs/config/src/permissions_toml_tests.rs b/codex-rs/config/src/permissions_toml_tests.rs index 6a7bfb5fec..f3149d4ed8 100644 --- a/codex-rs/config/src/permissions_toml_tests.rs +++ b/codex-rs/config/src/permissions_toml_tests.rs @@ -1,10 +1,19 @@ -//! Trusted network extensions survive TOML resolution with origin-scoped header placeholders. +//! Trusted network extensions survive TOML resolution without ambiguous signing authority. use super::*; +use codex_network_proxy::ProxySecretSource; use pretty_assertions::assert_eq; -const HEADER_ACTION: &str = r#" +const SIGNING: &str = r#" version = 2 +[actions.aws.aws_sigv4] +placeholder_access_key_id = "MASKEDACCESSKEY" +region = "us-east-1" +service = "s3" +access_key_id = { env_var = "PROXY_ACCESS_KEY" } +secret_access_key = { file = "/trusted/aws-secret" } +session_token = { env_var = "PROXY_SESSION_TOKEN" } + [[actions.substitute.inject_request_headers]] name = "x-api-key" secret_env_var = "PROXY_API_KEY" @@ -16,15 +25,21 @@ schemes = ["https"] ports = [443] methods = ["PUT"] path_prefixes = ["/workspace/"] -action = ["substitute"] +action = ["substitute", "aws"] "#; #[test] -fn resolves_origin_scoped_header_substitution_from_toml() { - let config: NetworkMitmToml = toml::from_str(HEADER_ACTION).unwrap(); +fn resolves_native_signing_and_substitution_actions_from_toml() { + let config: NetworkMitmToml = toml::from_str(SIGNING).unwrap(); let hooks = config.to_runtime_hooks(config.actions.as_ref()); assert_eq!(hooks[0].matcher.schemes, vec!["https"]); assert_eq!(hooks[0].matcher.ports, vec![443]); + let signer = hooks[0].actions.aws_sigv4.as_ref().unwrap(); + assert_eq!( + signer.access_key_id, + ProxySecretSource::EnvVar("PROXY_ACCESS_KEY".into()) + ); + assert_eq!(signer.port, 443); assert_eq!( hooks[0].actions.inject_request_headers[0] .placeholder @@ -47,18 +62,20 @@ fn scoped_rules_reject_rollback_to_the_legacy_format() { _actions: toml::Value, } - let unversioned = HEADER_ACTION.replacen("version = 2\n", "", 1); + let unversioned = SIGNING.replacen("version = 2\n", "", 1); assert!(toml::from_str::(&unversioned).is_err()); assert!(toml::from_str::(&unversioned).is_ok()); - assert!(toml::from_str::(HEADER_ACTION).is_err()); + assert!(toml::from_str::(SIGNING).is_err()); } #[test] fn profile_and_managed_requirements_reject_misspelled_mitm_matchers() { - let profile = HEADER_ACTION - .replace("[[actions.", "[[permissions.test.network.mitm.actions.") + let profile = SIGNING + .replace("[actions.", "[permissions.test.network.mitm.actions.") .replace("[hooks.", "[permissions.test.network.mitm.hooks."); let profile = format!("[permissions.test.network.mitm]\n{profile}"); + toml::from_str::(&profile).unwrap(); + toml::from_str::(&profile).unwrap(); for (key, value) in [ ("header", r#"{ x-tenant = ["allowed"] }"#), ("port", "[443]"), @@ -80,7 +97,7 @@ fn profile_and_managed_requirements_reject_misspelled_mitm_matchers() { #[test] fn rejects_misspelled_header_placeholder() { - let input = HEADER_ACTION.replacen("placeholder =", "unknown_placeholder =", 1); + let input = SIGNING.replacen("placeholder =", "unknown_placeholder =", 1); let error = toml::from_str::(&input).unwrap_err(); assert!( error @@ -88,3 +105,21 @@ fn rejects_misspelled_header_placeholder() { .contains("unknown field `unknown_placeholder`") ); } + +#[test] +fn rejects_multiple_signing_actions_and_unknown_signing_parameters() { + assert!( + toml::from_str::(&SIGNING.replace( + "action = [\"substitute\", \"aws\"]", + "action = [\"aws\", \"aws\"]" + )) + .is_err() + ); + assert!( + toml::from_str::(&SIGNING.replace( + "region = \"us-east-1\"", + "region = \"us-east-1\"\nallow_unsigned_payload = true" + )) + .is_err() + ); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 811de91311..b4bb88331b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -521,6 +521,51 @@ ], "type": "object" }, + "AwsSigV4Config": { + "additionalProperties": false, + "description": "AWS SigV4 action selected by an exact-host, method, and path MITM hook.", + "properties": { + "access_key_id": { + "$ref": "#/definitions/ProxySecretSource" + }, + "max_body_bytes": { + "default": 8388608, + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "placeholder_access_key_id": { + "description": "Nonsecret access key ID installed in the guest AWS profile.", + "type": "string" + }, + "port": { + "default": 443, + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "region": { + "type": "string" + }, + "secret_access_key": { + "$ref": "#/definitions/ProxySecretSource" + }, + "service": { + "type": "string" + }, + "session_token": { + "$ref": "#/definitions/ProxySecretSource" + } + }, + "required": [ + "access_key_id", + "placeholder_access_key_id", + "region", + "secret_access_key", + "service" + ], + "type": "object" + }, "BrowserUseConfigToml": { "additionalProperties": false, "properties": { @@ -3155,6 +3200,14 @@ "NetworkMitmActionToml": { "additionalProperties": false, "properties": { + "aws_sigv4": { + "allOf": [ + { + "$ref": "#/definitions/AwsSigV4Config" + } + ], + "default": null + }, "inject_request_headers": { "default": [], "items": { @@ -3960,6 +4013,35 @@ }, "type": "object" }, + "ProxySecretSource": { + "description": "A credential source in the trusted proxy process, never in the executor environment.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "env_var": { + "type": "string" + } + }, + "required": [ + "env_var" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "file": { + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + } + ] + }, "RawMcpServerConfig": { "additionalProperties": false, "description": "Raw MCP config shape used for deserialization and supported-field JSON Schema generation.\n\nFields that are accepted only to produce targeted validation errors should be skipped in the generated schema.\n\nKeep `TryFrom for McpServerConfig` exhaustively destructuring this struct so new TOML fields cannot be added here without updating the validation/mapping logic that produces [`McpServerConfig`].", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index d253342031..674101920a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1683,6 +1683,7 @@ strip_request_headers = ["authorization"] actions: Some(IndexMap::from([( "strip_auth".to_string(), NetworkMitmActionToml { + aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: Vec::new(), }, @@ -1765,6 +1766,7 @@ fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() { actions: Some(IndexMap::from([( "strip_auth".to_string(), NetworkMitmActionToml { + aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: Vec::new(), }, diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 44e6087a9b..3d4591c1e4 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -174,6 +174,19 @@ impl NetworkProxySpec { } else { (config, NetworkProxyConstraints::default()) }; + if config.enabled + && config.mitm + && !config.credential_broker + && config + .mitm_hooks + .iter() + .any(|hook| hook.actions.aws_sigv4.is_some()) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "local AWS SigV4 hooks require features.credential_masking for protected shell snapshots", + )); + } validate_policy_against_constraints(&config, &constraints).map_err(|err| { std::io::Error::new( std::io::ErrorKind::InvalidInput, diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 66d0dccf21..89a0271d7c 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -274,12 +274,42 @@ what it can reasonably guarantee. transport layer. If your threat model includes hostile DNS, enforce network egress at a lower layer too (e.g., firewall / VPC / corporate proxy policies). -### Native origin and exact secret substitution +### Native AWS signing and exact secret substitution -MITM hook `match.schemes` and `match.ports` constrain the trusted tunnel origin; -empty lists preserve existing behavior. An injected header can set `placeholder` -to require one exact whole-header value before substitution. Missing, mismatched, -or duplicate values fail closed. This form requires HTTPS port 443 and cannot -modify routing or framing headers. Secret sources remain trusted host references; -resolved values are marked sensitive in diagnostics and file sources are bounded -to 64 KiB. Ambiguous semicolon paths cannot authorize scoped credentials. +A trusted `aws_sigv4` action pairs a `placeholder_access_key_id` with a real +`access_key_id`, `secret_access_key`, and optional `session_token`. The latter +three reference files or environment variables in the proxy process. Resolve them +at startup and replace the proxy installation to rotate them. + +For local Codex, enable `credential_masking = true` under `[features]` in +`config.toml` and set `placeholder_access_key_id = "CODEXMASKEDWORKSPACE"` on the +proxy action. Local managed commands receive the dummy access key, secret and, +for temporary credentials, session token in their standard AWS environment. +An empty AWS environment selects a single configured pair. With multiple pairs, +set `AWS_ACCESS_KEY_ID` to the intended placeholder. An unrelated access key or +an explicit `AWS_PROFILE` keeps its own selection. Inherited copies of configured +credentials are masked in child environment values and protected shell snapshots. +File-backed credentials require filesystem permissions that prevent child access. +The AWS CLI or SDK signs with the dummy secret. After the exact +host, method and path hook authorizes the request, the proxy recognizes the +placeholder in the SigV4 credential scope and re-signs for the configured HTTPS +port (443 by default), region and service. The dummy ID selects the pair and is +visible to the workload. Unsigned requests and requests using other credentials +follow the ordinary network and hook policy with their original AWS identity. +A matched placeholder with an invalid scope or unsupported signature is denied. + +Re-signing preserves the client timestamp and signed headers and substitutes the +real session token when configured. The AWS SDK signs the forwarded bytes after +header mutations. Literal payloads use a 30-second deadline and an 8 MiB default +buffer limit (16 MiB maximum). An explicit `UNSIGNED-PAYLOAD` streams without +using the signing buffer. OpenSearch Serverless requires a literal payload hash. +Masked presigned URLs, SigV4a, AWS signed streams and declared trailers are denied. + +An injected header can additionally set `placeholder` to require one exact +whole-header value before substituting the secret. Missing, mismatched, or +duplicate values fail closed. This form requires HTTPS port 443 and cannot +modify routing or framing headers. Resolved secret headers and signing headers +are marked sensitive in diagnostics. +All native buffering stages share eight request slots, with a five-second bounded +wait. Each lease follows the prepared body until consumed or dropped; the 16 MiB +body cap bounds retained memory even when upstream connections are slow. diff --git a/codex-rs/network-proxy/src/aws_sigv4.rs b/codex-rs/network-proxy/src/aws_sigv4.rs index 717a3fccc5..d362b0030b 100644 --- a/codex-rs/network-proxy/src/aws_sigv4.rs +++ b/codex-rs/network-proxy/src/aws_sigv4.rs @@ -1,6 +1,9 @@ //! Trusted, destination-scoped AWS signing after network and HTTP hook authorization. //! Credentials never enter the workload; unsupported streaming signatures fail closed. +use crate::credential_broker::env_value; +use crate::credential_broker::remove_env_value; +use crate::credential_broker::set_env_value; use anyhow::Result; use anyhow::anyhow; use anyhow::ensure; @@ -29,6 +32,9 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use sha2::Digest as _; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; use std::time::Duration; use std::time::SystemTime; @@ -153,17 +159,6 @@ impl AwsSigV4Config { /*expires_after*/ None, "network-proxy", ); - ensure!( - [ - Some(credentials.access_key_id()), - Some(credentials.secret_access_key()), - credentials.session_token(), - ] - .into_iter() - .flatten() - .all(|value| !self.placeholder_access_key_id.contains(value)), - "AWS guest placeholders overlap a real credential" - ); Ok(AwsSigV4Signer { config: self.clone(), credentials, @@ -236,6 +231,132 @@ pub(crate) struct GuestSignature { unsigned_payload: bool, } +const DUMMY_SECRET: &str = "codex-local-aws-secret"; +const DUMMY_TOKEN: &str = "codex-local-aws-token"; +pub(crate) const AWS_CA_BUNDLE: &str = "AWS_CA_BUNDLE"; + +/// Every value exposed to a child must be safe with respect to every configured pair. +pub(crate) fn validate_guest_placeholders<'a>( + signers: impl Iterator + Clone, +) -> Result<()> { + for signer in signers.clone() { + let credentials = &signer.credentials; + let mut placeholders = [DUMMY_SECRET, DUMMY_TOKEN].into_iter().chain( + signers + .clone() + .map(|other| other.config.placeholder_access_key_id.as_str()), + ); + ensure!( + placeholders.all(|placeholder| { + [ + Some(credentials.access_key_id()), + Some(credentials.secret_access_key()), + credentials.session_token(), + ] + .into_iter() + .flatten() + .all(|real| !placeholder.contains(real)) + }), + "AWS guest placeholders overlap a real credential" + ); + } + Ok(()) +} + +pub(crate) fn snapshot_env_keys(env: &HashMap) -> Option<[&'static str; 5]> { + (env_value(env, "AWS_SECRET_ACCESS_KEY") == Some(DUMMY_SECRET) + || env_value(env, AWS_CA_BUNDLE) + .is_some_and(crate::certs::is_managed_mitm_ca_trust_bundle_path)) + .then_some([ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + AWS_CA_BUNDLE, + ]) +} + +/// Prepare the standard AWS environment for a local process before generic credential discovery. +pub(crate) fn mask_local_aws_env( + signers: &[Arc], + env: &mut HashMap, + ca_path: Option<&Path>, +) { + if signers.is_empty() { + return; + } + if env_value(env, AWS_CA_BUNDLE).is_none_or(str::is_empty) + && let Some(path) = ca_path + { + set_env_value(env, AWS_CA_BUNDLE, path.to_string_lossy().into_owned()); + } + let access = env_value(env, "AWS_ACCESS_KEY_ID").filter(|id| !id.is_empty()); + let use_default = access.is_none() + && env_value(env, "AWS_PROFILE").is_none() + && env_value(env, "AWS_DEFAULT_PROFILE").is_none(); + let mut candidates = signers.iter().filter(|s| { + use_default + || access.is_some_and(|id| { + id == s.credentials.access_key_id() || id == s.config.placeholder_access_key_id + }) + }); + let first = candidates.next(); + let selected = first.filter(|first| { + candidates.all(|s| { + s.config.placeholder_access_key_id == first.config.placeholder_access_key_id + && s.credentials.session_token().is_some() + == first.credentials.session_token().is_some() + }) + }); + let ambiguous_access = access.is_some() && first.is_some() && selected.is_none(); + // Mask aliases of every configured pair, including pairs the SDK didn't select. + for value in env.values_mut() { + mask_local_aws_text(signers, value); + } + if ambiguous_access { + for key in [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + ] { + remove_env_value(env, key); + } + } else if let Some(signer) = selected { + set_env_value( + env, + "AWS_ACCESS_KEY_ID", + signer.config.placeholder_access_key_id.clone(), + ); + set_env_value(env, "AWS_SECRET_ACCESS_KEY", DUMMY_SECRET.to_string()); + remove_env_value(env, "AWS_SECURITY_TOKEN"); + if signer.credentials.session_token().is_some() { + set_env_value(env, "AWS_SESSION_TOKEN", DUMMY_TOKEN.to_string()); + } else { + remove_env_value(env, "AWS_SESSION_TOKEN"); + } + } +} + +pub(crate) fn mask_local_aws_text(signers: &[Arc], text: &mut String) { + for signer in signers { + for (real, dummy) in [ + ( + Some(signer.credentials.access_key_id()), + signer.config.placeholder_access_key_id.as_str(), + ), + (Some(signer.credentials.secret_access_key()), DUMMY_SECRET), + (signer.credentials.session_token(), DUMMY_TOKEN), + ] { + if let Some(real) = real + && text.contains(real) + { + *text = text.replace(real, dummy); + } + } + } +} + impl AwsSigV4Signer { pub(crate) fn plan(&self, req: &Request) -> Result> { let placeholder = &self.config.placeholder_access_key_id; diff --git a/codex-rs/network-proxy/src/aws_sigv4_tests.rs b/codex-rs/network-proxy/src/aws_sigv4_tests.rs index fcde5d2389..05e164f135 100644 --- a/codex-rs/network-proxy/src/aws_sigv4_tests.rs +++ b/codex-rs/network-proxy/src/aws_sigv4_tests.rs @@ -92,7 +92,33 @@ fn matches_aws_published_iam_signature_vector() { async fn signs_actual_payload_and_replaces_untrusted_signing_headers() { let mut config = config("s3"); config.session_token = Some(ProxySecretSource::EnvVar("SESSION_TOKEN".into())); - let signer = signer(config); + let signer = Arc::new(signer(config)); + let mut env = HashMap::from([ + ( + "AWS_ACCESS_KEY_ID".to_string(), + signer.credentials.access_key_id().to_string(), + ), + ( + "SESSION_TOKEN".to_string(), + "secret-session-token".to_string(), + ), + ]); + mask_local_aws_env( + std::slice::from_ref(&signer), + &mut env, + /*ca_path*/ None, + ); + assert_eq!(env["AWS_ACCESS_KEY_ID"], "MASKEDACCESSKEY"); + assert_eq!(env["SESSION_TOKEN"], env["AWS_SESSION_TOKEN"]); + assert_ne!(env["AWS_SESSION_TOKEN"], "secret-session-token"); + env.insert("AWS_ACCESS_KEY_ID".to_string(), "OTHERKEY".to_string()); + env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "unrelated".to_string()); + mask_local_aws_env( + std::slice::from_ref(&signer), + &mut env, + /*ca_path*/ None, + ); + assert_eq!(env["AWS_SECRET_ACCESS_KEY"], "unrelated"); let mut cookie = HeaderValue::from_static("b=2"); cookie.set_sensitive(true); let request = Request::builder() @@ -289,6 +315,16 @@ fn credentials_are_required_and_configured_limits_are_bounded() { .compile(&|_| None, &|_| panic!("unexpected file")) .is_err() ); + let first = signer(config("s3")); + let mut other = config("sts"); + other.placeholder_access_key_id = format!("GUEST{}", first.credentials.access_key_id()); + let other = other + .compile(&|key| Some(format!("other-{key}")), &|_| { + panic!("unexpected file") + }) + .unwrap(); + assert!(validate_guest_placeholders([&first, &other].into_iter()).is_err()); + let mut config = config("s3"); config.max_body_bytes = 16 * 1024 * 1024 + 1; assert_eq!( diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 1248a11052..80730ecb0d 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -51,8 +51,17 @@ static MANAGED_MITM_CAS: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); const MAX_EXTERNAL_CA_FILE_BYTES: u64 = 1024 * 1024; +#[cfg(test)] +tokio::task_local! { + pub(super) static TEST_MITM_CA: Arc; +} + impl ManagedMitmCa { pub(super) fn load_or_create() -> Result> { + #[cfg(test)] + if let Ok(ca) = TEST_MITM_CA.try_with(Arc::clone) { + return Ok(ca); + } let proxy_dir = managed_ca_dir()?; let mut managed_cas = MANAGED_MITM_CAS .lock() diff --git a/codex-rs/network-proxy/src/credential_broker.rs b/codex-rs/network-proxy/src/credential_broker.rs index cd0dfa0e7f..727e9df5a0 100644 --- a/codex-rs/network-proxy/src/credential_broker.rs +++ b/codex-rs/network-proxy/src/credential_broker.rs @@ -258,14 +258,14 @@ pub(super) fn env_value<'a>(env: &'a HashMap, key: &str) -> Opti env_entry(env, key).map(|(_, value)| value) } -fn set_env_value(env: &mut HashMap, key: &str, value: String) { +pub(super) fn set_env_value(env: &mut HashMap, key: &str, value: String) { if cfg!(windows) { env.retain(|candidate, _| !env_key_matches(candidate, key)); } env.insert(key.to_string(), value); } -fn remove_env_value(env: &mut HashMap, key: &str) { +pub(super) fn remove_env_value(env: &mut HashMap, key: &str) { if cfg!(windows) { env.retain(|candidate, _| !env_key_matches(candidate, key)); } else { diff --git a/codex-rs/network-proxy/src/credential_broker/environment.rs b/codex-rs/network-proxy/src/credential_broker/environment.rs index 4455e217b0..52081880b4 100644 --- a/codex-rs/network-proxy/src/credential_broker/environment.rs +++ b/codex-rs/network-proxy/src/credential_broker/environment.rs @@ -455,6 +455,9 @@ pub fn brokered_credential_env_keys( let active = env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY).is_some_and(|value| value == "1"); let mut keys = Vec::new(); if active { + if let Some(aws_keys) = crate::aws_sigv4::snapshot_env_keys(env) { + keys.extend(aws_keys); + } let brokered_keys = brokered_credential_dummy_env_keys(env); keys.extend(providers::credential_env_keys().filter(|key| { brokered_keys diff --git a/codex-rs/network-proxy/src/mitm_forwarding_tests.rs b/codex-rs/network-proxy/src/mitm_forwarding_tests.rs index f52d792556..e920c22537 100644 --- a/codex-rs/network-proxy/src/mitm_forwarding_tests.rs +++ b/codex-rs/network-proxy/src/mitm_forwarding_tests.rs @@ -94,6 +94,7 @@ async fn tls_origin( }], ..Default::default() }; + config.set_credential_broker_enabled(/*enabled*/ true); config.set_allowed_domains(vec!["localhost".to_string()]); let app_state = Arc::new(network_proxy_state_for_policy(config)); let roots = Arc::new(roots); @@ -194,6 +195,34 @@ fn request( async fn forwards_aoss_payload_hashes_and_signatures_over_tls() { let dir = tempdir().expect("TLS fixture directory"); let (context, listener, acceptor) = tls_origin(dir.path(), "aoss", b"http/1.1").await; + let builder = crate::NetworkProxy::builder() + .state(Arc::clone(&context.policy.app_state)) + .managed_proxy_routing(crate::ManagedProxyRouting::DedicatedListeners); + let proxy = crate::certs::TEST_MITM_CA + .scope(Arc::clone(&context.mitm.ca), builder.build()) + .await + .expect("local proxy"); + let mut env = HashMap::from([("ALIAS".to_string(), "prefix:test-secret-key".to_string())]); + proxy.apply_to_env_for_snapshot(&mut env); + assert_eq!(env["AWS_ACCESS_KEY_ID"], "MASKEDACCESSKEY"); + assert_eq!(env["AWS_CA_BUNDLE"], env["SSL_CERT_FILE"]); + assert_eq!(Path::new(&env["AWS_CA_BUNDLE"]).parent(), Some(dir.path())); + assert!(crate::brokered_credential_env_keys(&env).any(|key| key == "AWS_ACCESS_KEY_ID")); + assert_eq!( + env["ALIAS"], + format!("prefix:{}", env["AWS_SECRET_ACCESS_KEY"]) + ); + let mut snapshot = "export COPY=test-secret-key".to_string(); + assert!(proxy.virtualize_brokered_text(&mut snapshot, &env)); + assert_eq!( + snapshot, + format!("export COPY={}", env["AWS_SECRET_ACCESS_KEY"]) + ); + let remote = proxy + .prepare_for_remote_environment(HashMap::new(), "remote") + .expect("remote environment"); + assert!(!remote.env.contains_key("AWS_ACCESS_KEY_ID")); + assert!(!remote.env.contains_key("AWS_CA_BUNDLE")); let cases: [(&str, &[u8]); 4] = [ ("/unsigned", b""), ("/other-key", b""), diff --git a/codex-rs/network-proxy/src/mitm_hook.rs b/codex-rs/network-proxy/src/mitm_hook.rs index 49e87de77d..61e385949d 100644 --- a/codex-rs/network-proxy/src/mitm_hook.rs +++ b/codex-rs/network-proxy/src/mitm_hook.rs @@ -389,6 +389,12 @@ where }); } + crate::aws_sigv4::validate_guest_placeholders( + hooks_by_host + .values() + .flatten() + .filter_map(|hook| hook.actions.aws_sigv4.as_deref()), + )?; Ok(hooks_by_host) } diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 9c412f31e0..5b1240ae10 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -683,7 +683,7 @@ pub fn is_managed_proxy_env_var(key: &str, value: &str) -> bool { if PROXY_ENV_KEYS.contains(&key) { return true; } - if crate::certs::CUSTOM_CA_ENV_KEYS.contains(&key) { + if key == crate::aws_sigv4::AWS_CA_BUNDLE || crate::certs::CUSTOM_CA_ENV_KEYS.contains(&key) { return crate::certs::is_managed_mitm_ca_trust_bundle_path(value); } #[cfg(target_os = "macos")] @@ -1153,7 +1153,6 @@ impl NetworkProxy { &self, mut env: HashMap, addrs: EnvironmentProxyAddrs, - #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] client: EnvironmentProxyClient, environment_id: Option<&str>, ) -> PreparedManagedNetwork { @@ -1171,6 +1170,15 @@ impl NetworkProxy { ..addrs }; let runtime_settings = self.runtime_settings(); + if client == EnvironmentProxyClient::SandboxedProcess { + self.state.mask_local_aws_credentials( + &mut env, + runtime_settings + .mitm_ca_trust_bundle + .as_ref() + .map(|bundle| bundle.path.as_path()), + ); + } // Enforce proxying for child processes. Proxy endpoint values are always rewritten; // managed MITM CA vars preserve child-scoped overrides after proxy startup. apply_proxy_env_overrides( diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index cd09549cb4..2c826c4eb3 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -1,3 +1,6 @@ +use crate::aws_sigv4::AwsSigV4Signer; +use crate::aws_sigv4::mask_local_aws_env; +use crate::aws_sigv4::mask_local_aws_text; use crate::config::NetworkDomainPermission; use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; @@ -189,6 +192,19 @@ pub struct ConfigState { pub blocked_total: u64, } +impl ConfigState { + fn local_aws_signers(&self) -> Vec> { + if !self.config.enabled || !self.config.credential_broker || !self.config.mitm { + return Vec::new(); + } + self.mitm_hooks + .values() + .flatten() + .filter_map(|hook| hook.actions.aws_sigv4.clone()) + .collect() + } +} + pub trait ConfigReloader: Send + Sync { /// Human-readable description of where config is loaded from, for logs. fn source_label(&self) -> String; @@ -278,6 +294,7 @@ pub struct NetworkProxyState { pub(crate) process_log_metadata: crate::NetworkProxyProcessLogMetadata, /// Shared by this proxy's listeners; rebinding leaves other proxies' cached brokers intact. credential_broker: Arc>, + local_aws_signers: Arc>>>, audit_metadata: NetworkProxyAuditMetadata, execution_attributions: Arc>>, environment_id: Option>, @@ -317,6 +334,7 @@ impl Clone for NetworkProxyState { launch_span_context: self.launch_span_context.clone(), process_log_metadata: self.process_log_metadata.clone(), credential_broker: self.credential_broker.clone(), + local_aws_signers: self.local_aws_signers.clone(), audit_metadata: self.audit_metadata.clone(), execution_attributions: self.execution_attributions.clone(), environment_id: self.environment_id.clone(), @@ -424,6 +442,7 @@ impl NetworkProxyState { credential_broker.configure(&local_binding_policy.resolved_config(&state.config)); Self { credential_broker: Arc::new(std::sync::RwLock::new(credential_broker)), + local_aws_signers: Arc::new(std::sync::RwLock::new(state.local_aws_signers())), state: Arc::new(RwLock::new(state)), local_binding_policy, proxy_private_ips_via_upstream: false, @@ -561,6 +580,27 @@ impl NetworkProxyState { Ok(()) } + fn install_config(&self, current: &mut ConfigState, next: ConfigState) { + let mut signers = self + .local_aws_signers + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *signers = next.local_aws_signers(); + *current = next; + } + + pub(crate) fn mask_local_aws_credentials( + &self, + env: &mut HashMap, + ca_path: Option<&Path>, + ) { + let signers = self + .local_aws_signers + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + mask_local_aws_env(&signers, env, ca_path); + } + pub fn virtualize_child_credentials(&self, env: &mut HashMap) { self.virtualize_child_credentials_for_environment(env, self.environment_id()); } @@ -624,6 +664,11 @@ impl NetworkProxyState { text: &mut String, env: &HashMap, ) -> bool { + let signers = self + .local_aws_signers + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + mask_local_aws_text(&signers, text); self.credential_broker().virtualize_text(text, env) } @@ -742,7 +787,7 @@ impl NetworkProxyState { // Log policy diffs without dumping potentially sensitive config values. log_policy_changes(&guard.config, &new_state.config); new_state.blocked = guard.blocked.clone(); - *guard = new_state; + self.install_config(&mut guard, new_state); } let source = self.reloader.source_label(); info!("reloaded config from {source}"); @@ -764,7 +809,7 @@ impl NetworkProxyState { log_policy_changes(&guard.config, &new_state.config); new_state.blocked = guard.blocked.clone(); new_state.blocked_total = guard.blocked_total; - *guard = new_state; + self.install_config(&mut guard, new_state); info!("updated network proxy config state"); Ok(()) } @@ -1176,7 +1221,7 @@ impl NetworkProxyState { } log_policy_changes(&guard.config, &candidate); - *guard = new_state; + self.install_config(&mut guard, new_state); info!("updated network {list_name} with {normalized_host}"); return Ok(()); } @@ -1193,7 +1238,7 @@ impl NetworkProxyState { log_policy_changes(&guard.config, &new_state.config); new_state.blocked = guard.blocked.clone(); new_state.blocked_total = guard.blocked_total; - *guard = new_state; + self.install_config(&mut guard, new_state); } let source = self.reloader.source_label(); info!("reloaded config from {source}");