@@ -37,11 +37,13 @@ impl ProviderCredentialState {
3737 credential_expires_at_ms : HashMap < String , i64 > ,
3838 dynamic_credentials : HashMap < String , crate :: proto:: ProviderProfileCredential > ,
3939 ) -> Self {
40+ let suppress = v2_injected_env_keys ( & dynamic_credentials) ;
4041 let ( child_env, generation_resolver, current_resolver) =
4142 SecretResolver :: from_provider_env_for_current_revision (
4243 env,
4344 credential_expires_at_ms,
4445 revision,
46+ & suppress,
4547 ) ;
4648 let snapshot = Arc :: new ( ProviderCredentialSnapshot {
4749 revision,
@@ -205,11 +207,13 @@ impl ProviderCredentialState {
205207 credential_expires_at_ms : HashMap < String , i64 > ,
206208 dynamic_credentials : HashMap < String , crate :: proto:: ProviderProfileCredential > ,
207209 ) -> usize {
210+ let suppress = v2_injected_env_keys ( & dynamic_credentials) ;
208211 let ( mut child_env, generation_resolver, current_resolver) =
209212 SecretResolver :: from_provider_env_for_current_revision (
210213 env,
211214 credential_expires_at_ms,
212215 revision,
216+ & suppress,
213217 ) ;
214218 let mut inner = self
215219 . inner
@@ -239,6 +243,29 @@ impl ProviderCredentialState {
239243 }
240244}
241245
246+ /// Collect env var keys that are covered by providers-v2 static proxy
247+ /// injection. These credentials have no `token_grant` (token grants use
248+ /// the env var placeholder path) and will be injected by the proxy
249+ /// transparently — the sandbox process must not see them.
250+ ///
251+ /// Credentials with a `token_grant` always pass through `can_inject_credential`
252+ /// regardless of `providers_v2_enabled`, so they are never suppressed here.
253+ /// Static credentials only enter `dynamic_credentials` when `providers_v2_enabled`
254+ /// is true on the server side, so this function does not need to re-check the
255+ /// flag.
256+ fn v2_injected_env_keys (
257+ dynamic_credentials : & HashMap < String , crate :: proto:: ProviderProfileCredential > ,
258+ ) -> HashSet < String > {
259+ let mut keys = HashSet :: new ( ) ;
260+ for cred in dynamic_credentials. values ( ) {
261+ if cred. token_grant . is_some ( ) {
262+ continue ;
263+ }
264+ keys. extend ( cred. env_vars . iter ( ) . cloned ( ) ) ;
265+ }
266+ keys
267+ }
268+
242269fn merge_resolvers (
243270 generations : & VecDeque < Arc < SecretResolver > > ,
244271 current_resolver : Option < & Arc < SecretResolver > > ,
@@ -593,4 +620,100 @@ mod tests {
593620 "suppressed key must not reappear after install_environment"
594621 ) ;
595622 }
623+
624+ #[ test]
625+ fn v2_injected_env_keys_suppresses_static_but_not_token_grants ( ) {
626+ use crate :: proto:: { ProviderCredentialTokenGrant , ProviderProfileCredential } ;
627+
628+ let dynamic_credentials = HashMap :: from ( [
629+ // Static bearer credential — should be suppressed
630+ (
631+ "host\t 443\t \t my-provider:api_key" . to_string ( ) ,
632+ ProviderProfileCredential {
633+ name : "api_key" . to_string ( ) ,
634+ env_vars : vec ! [ "API_KEY" . to_string( ) ] ,
635+ auth_style : "bearer" . to_string ( ) ,
636+ header_name : "Authorization" . to_string ( ) ,
637+ ..Default :: default ( )
638+ } ,
639+ ) ,
640+ // Static header credential — should be suppressed
641+ (
642+ "host\t 443\t \t my-provider:custom_key" . to_string ( ) ,
643+ ProviderProfileCredential {
644+ name : "custom_key" . to_string ( ) ,
645+ env_vars : vec ! [ "CUSTOM_KEY" . to_string( ) ] ,
646+ auth_style : "header" . to_string ( ) ,
647+ header_name : "X-Custom" . to_string ( ) ,
648+ ..Default :: default ( )
649+ } ,
650+ ) ,
651+ // Token grant credential — must NOT be suppressed
652+ (
653+ "host\t 443\t \t my-provider:token" . to_string ( ) ,
654+ ProviderProfileCredential {
655+ name : "token" . to_string ( ) ,
656+ env_vars : vec ! [ "GRANT_TOKEN" . to_string( ) ] ,
657+ auth_style : "bearer" . to_string ( ) ,
658+ header_name : "Authorization" . to_string ( ) ,
659+ token_grant : Some ( ProviderCredentialTokenGrant {
660+ token_endpoint : "https://auth.example.com/token" . to_string ( ) ,
661+ ..Default :: default ( )
662+ } ) ,
663+ ..Default :: default ( )
664+ } ,
665+ ) ,
666+ ] ) ;
667+
668+ let keys = v2_injected_env_keys ( & dynamic_credentials) ;
669+ assert ! ( keys. contains( "API_KEY" ) , "static bearer should be suppressed" ) ;
670+ assert ! ( keys. contains( "CUSTOM_KEY" ) , "static header should be suppressed" ) ;
671+ assert ! ( !keys. contains( "GRANT_TOKEN" ) , "token grant must not be suppressed" ) ;
672+ }
673+
674+ #[ test]
675+ fn v2_injected_credentials_hidden_from_child_env ( ) {
676+ use crate :: proto:: ProviderProfileCredential ;
677+
678+ let env = HashMap :: from ( [
679+ ( "API_KEY" . to_string ( ) , "secret-key" . to_string ( ) ) ,
680+ ( "OTHER_VAR" . to_string ( ) , "visible-value" . to_string ( ) ) ,
681+ ] ) ;
682+ let dynamic_credentials = HashMap :: from ( [ (
683+ "host\t 443\t \t prov:api_key" . to_string ( ) ,
684+ ProviderProfileCredential {
685+ name : "api_key" . to_string ( ) ,
686+ env_vars : vec ! [ "API_KEY" . to_string( ) ] ,
687+ auth_style : "bearer" . to_string ( ) ,
688+ ..Default :: default ( )
689+ } ,
690+ ) ] ) ;
691+
692+ let state = ProviderCredentialState :: from_environment (
693+ 1 ,
694+ env,
695+ HashMap :: new ( ) ,
696+ dynamic_credentials,
697+ ) ;
698+ let snapshot = state. snapshot ( ) ;
699+
700+ assert ! (
701+ !snapshot. child_env. contains_key( "API_KEY" ) ,
702+ "v2-injected key must not appear in child_env"
703+ ) ;
704+ assert ! (
705+ snapshot. child_env. contains_key( "OTHER_VAR" ) ,
706+ "non-injected key must still appear in child_env"
707+ ) ;
708+
709+ // The resolver must still be able to resolve the suppressed key
710+ // so the proxy can inject it.
711+ let resolver = state. resolver ( ) . expect ( "resolver should exist" ) ;
712+ let resolved = resolver. resolve_placeholder ( "openshell:resolve:env:v1_API_KEY" ) ;
713+ assert_eq ! (
714+ resolved,
715+ Some ( "secret-key" ) ,
716+ "resolver must still resolve suppressed credential for proxy injection"
717+ ) ;
718+ }
596719}
0 commit comments