Skip to content

Commit 6609467

Browse files
committed
feat(providersv2): suppress v2-injected credentials from sandbox env and add E2E coverage
Signed-off-by: Calum Murray <cmurray@redhat.com>
1 parent 7f6decf commit 6609467

3 files changed

Lines changed: 638 additions & 2 deletions

File tree

crates/openshell-core/src/provider_credentials.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
242269
fn 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\t443\t\tmy-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\t443\t\tmy-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\t443\t\tmy-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\t443\t\tprov: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
}

crates/openshell-core/src/secrets.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
use crate::time::now_ms;
55
use base64::Engine as _;
6-
use std::collections::HashMap;
6+
use std::collections::{HashMap, HashSet};
77
use std::fmt;
88

99
const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:";
@@ -127,13 +127,22 @@ impl SecretResolver {
127127
credential_expires_at_ms,
128128
revision,
129129
false,
130+
&HashSet::new(),
130131
)
131132
}
132133

134+
/// Build a child environment and secret resolvers from provider credentials.
135+
///
136+
/// Keys in `suppress_from_child_env` are stored in the resolver (so the
137+
/// proxy can resolve them for injection) but omitted from `child_env` so
138+
/// sandbox processes never see them — not even as placeholders. This is
139+
/// the mechanism that keeps providers-v2 proxy-injected credentials
140+
/// invisible to the sandbox.
133141
pub fn from_provider_env_for_current_revision(
134142
provider_env: HashMap<String, String>,
135143
credential_expires_at_ms: HashMap<String, i64>,
136144
revision: u64,
145+
suppress_from_child_env: &HashSet<String>,
137146
) -> (HashMap<String, String>, Option<Self>, Option<Self>) {
138147
if revision == 0 {
139148
let (child_env, current_resolver) =
@@ -142,6 +151,7 @@ impl SecretResolver {
142151
credential_expires_at_ms,
143152
0,
144153
true,
154+
suppress_from_child_env,
145155
);
146156
return (child_env, None, current_resolver);
147157
}
@@ -153,12 +163,14 @@ impl SecretResolver {
153163
credential_expires_at_ms,
154164
revision,
155165
false,
166+
suppress_from_child_env,
156167
);
157168
let (_, current_resolver) = Self::from_provider_env_for_revision_with_current_aliases(
158169
provider_env_for_current,
159170
credential_expires_at_ms_for_current,
160171
revision,
161172
true,
173+
suppress_from_child_env,
162174
);
163175
(child_env, revision_resolver, current_resolver)
164176
}
@@ -168,6 +180,7 @@ impl SecretResolver {
168180
credential_expires_at_ms: HashMap<String, i64>,
169181
revision: u64,
170182
include_current_aliases: bool,
183+
suppress_from_child_env: &HashSet<String>,
171184
) -> (HashMap<String, String>, Option<Self>) {
172185
if provider_env.is_empty() {
173186
return (HashMap::new(), None);
@@ -185,7 +198,9 @@ impl SecretResolver {
185198
.copied()
186199
.unwrap_or_default(),
187200
};
188-
child_env.insert(key.clone(), placeholder.clone());
201+
if !suppress_from_child_env.contains(&key) {
202+
child_env.insert(key.clone(), placeholder.clone());
203+
}
189204
by_placeholder.insert(placeholder, secret.clone());
190205
if include_current_aliases && revision != 0 {
191206
by_placeholder.insert(placeholder_for_env_key(&key), secret.clone());

0 commit comments

Comments
 (0)