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
2 changes: 2 additions & 0 deletions crates/openshell-core/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,7 @@ fn provider_environment_result(
.collect::<Result<HashMap<_, _>>>()?;
Ok(ProviderEnvironmentResult {
environment: inner.environment,
files: inner.files,
provider_env_revision: inner.provider_env_revision,
provider_attachment_epoch: inner.provider_attachment_epoch,
policy_hash: inner.policy_hash,
Expand Down Expand Up @@ -1380,6 +1381,7 @@ mod settings_poll_tests {
/// Credential material and the authority snapshot that produced its bindings.
pub struct ProviderEnvironmentResult {
pub environment: HashMap<String, String>,
pub files: HashMap<String, String>,
pub provider_env_revision: u64,
/// Attachment identity captured with the delivered credential records.
pub provider_attachment_epoch: String,
Expand Down
82 changes: 59 additions & 23 deletions crates/openshell-core/src/provider_credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@ pub struct ChildEnvironmentSnapshot {
pub revision: u64,
/// Prepared environment used by future workload processes.
pub environment: HashMap<String, String>,
/// Complete desired set of non-secret files for this installation.
pub files: HashMap<String, String>,
}

#[derive(Debug)]
struct ProviderCredentialStateInner {
current: Arc<ProviderCredentialSnapshot>,
files: HashMap<String, String>,
generations: VecDeque<Arc<SecretResolver>>,
current_resolver: Option<Arc<SecretResolver>>,
combined_resolver: Option<Arc<SecretResolver>>,
Expand Down Expand Up @@ -117,6 +120,7 @@ impl ProviderCredentialState {
Self {
inner: Arc::new(RwLock::new(ProviderCredentialStateInner {
current: snapshot.clone(),
files: HashMap::new(),
generations,
current_resolver,
combined_resolver,
Expand Down Expand Up @@ -172,6 +176,7 @@ impl ProviderCredentialState {
Ok(Self {
inner: Arc::new(RwLock::new(ProviderCredentialStateInner {
current: snapshot.clone(),
files: HashMap::new(),
generations,
current_resolver,
combined_resolver,
Expand Down Expand Up @@ -205,6 +210,7 @@ impl ProviderCredentialState {
Self {
inner: Arc::new(RwLock::new(ProviderCredentialStateInner {
current: snapshot.clone(),
files: HashMap::new(),
generations: VecDeque::new(),
current_resolver: None,
combined_resolver: None,
Expand Down Expand Up @@ -410,22 +416,19 @@ impl ProviderCredentialState {
inner.current = Arc::new(env);
}

/// Return `child_env` with GCP static config vars resolved to real values.
/// Return `child_env` with explicitly non-secret config vars resolved.
///
/// The credential pipeline placeholderizes ALL env values, but GCP SDKs
/// and coding agents read certain vars (project ID, region, metadata host)
/// at process startup before any HTTP request flows through the proxy.
/// This method overrides those vars with resolved real values while
/// keeping secret credentials (like `GCP_ACCESS_TOKEN`) as placeholders.
/// The credential pipeline placeholderizes all env values. Workloads need
/// non-secret configuration, including provider file paths, at process
/// startup before any HTTP request flows through the proxy. Credential
/// values remain placeholders.
///
/// Three layers of env var injection:
/// 1. **Synthetic vars** (`GCE_METADATA_IP`, `METADATA_SERVER_DETECTION`)
/// — sandbox-internal config not from user
/// input, inserted directly here with real values.
/// 2. **`google_cloud::STATIC_CONFIG_KEYS`** — user-provided non-secret config
/// (project ID, region, SA email) that was placeholderized by
/// `ProviderPlugin::inject_env` → `SecretResolver`; un-placeholderized
/// here so SDKs can read them at startup.
/// 2. **Classified non-secret keys** — user-provided config and provider
/// file paths un-placeholderized so workloads can read them at startup.
/// 3. Everything else stays as placeholders for proxy-time resolution.
pub fn child_env_with_gcp_resolved(&self) -> HashMap<String, String> {
let inner = self
Expand Down Expand Up @@ -463,9 +466,18 @@ impl ProviderCredentialState {
installation_id: inner.current.installation_id.clone(),
revision,
environment,
files: inner.files.clone(),
})
}

/// Attach the complete file set to a prepared provider snapshot.
pub fn set_managed_files(&self, files: HashMap<String, String>) {
self.inner
.write()
.expect("provider credential state poisoned")
.files = files;
}

fn resolve_child_env_snapshot(
inner: &ProviderCredentialStateInner,
) -> (u64, HashMap<String, String>) {
Expand All @@ -477,11 +489,7 @@ impl ProviderCredentialState {
&& inner
.non_secret_environment_keys
.contains("GCE_METADATA_HOST");
let has_gcp_config = google_cloud::STATIC_CONFIG_KEYS
.iter()
.any(|key| env.contains_key(*key) && inner.non_secret_environment_keys.contains(*key));

if !has_gcp_metadata && !has_gcp_config {
if !has_gcp_metadata && inner.non_secret_environment_keys.is_empty() {
return (inner.current.revision, env);
}

Expand All @@ -506,16 +514,15 @@ impl ProviderCredentialState {
);
}

// Un-placeholderize non-secret config vars so SDKs can read them
// at process startup before any HTTP flows through the proxy.
// Only explicitly classified non-secret values may be unwrapped.
if let Some(ref resolver) = inner.combined_resolver {
for key in google_cloud::STATIC_CONFIG_KEYS
.iter()
.filter(|key| inner.non_secret_environment_keys.contains(**key))
{
for key in &inner.non_secret_environment_keys {
if !env.contains_key(key) || (has_gcp_metadata && key == "GCE_METADATA_HOST") {
continue;
}
let placeholder = crate::secrets::placeholder_for_env_key(key);
if let Some(value) = resolver.resolve_placeholder(&placeholder) {
env.insert(key.to_string(), value.to_string());
env.insert(key.clone(), value.to_string());
}
}
}
Expand Down Expand Up @@ -738,7 +745,7 @@ impl ProviderCredentialState {
pub fn install_prepared(&self, prepared: &Self) -> usize {
// Release the candidate lock before taking the live lock, including
// when a caller passes another handle to the same state.
let (snapshot, generations, current_resolver, bindings, non_secret_keys) = {
let (snapshot, generations, current_resolver, bindings, non_secret_keys, files) = {
let candidate = prepared
.inner
.read()
Expand All @@ -749,6 +756,7 @@ impl ProviderCredentialState {
candidate.current_resolver.clone(),
candidate.static_credential_bindings.clone(),
candidate.non_secret_environment_keys.clone(),
candidate.files.clone(),
)
};
let mut inner = self
Expand Down Expand Up @@ -781,6 +789,7 @@ impl ProviderCredentialState {
);
inner.static_credential_bindings = bindings;
inner.non_secret_environment_keys = non_secret_keys;
inner.files = files;
inner.body_inventory_available = true;
inner
.known_body_keys
Expand Down Expand Up @@ -2138,6 +2147,33 @@ mod tests {
assert!(!env.contains_key("CLAUDE_CODE_USE_VERTEX"));
}

#[test]
fn child_env_resolves_provider_file_path_but_keeps_credentials_placeholderized() {
let path = "/run/openshell/providers/acme/client.toml";
let state = ProviderCredentialState::from_bound_environment(
1,
HashMap::from([
("ACME_CONFIG_FILE".to_string(), path.to_string()),
("ACME_TOKEN".to_string(), "secret-token".to_string()),
]),
HashMap::new(),
HashMap::new(),
HashMap::from([(
"ACME_TOKEN".to_string(),
binding("api.acme.example", 443, "/**"),
)]),
vec!["ACME_CONFIG_FILE".to_string()],
)
.expect("classified provider environment");

let env = state.child_env_with_gcp_resolved();
assert_eq!(env.get("ACME_CONFIG_FILE").map(String::as_str), Some(path));
assert_eq!(
env.get("ACME_TOKEN").map(String::as_str),
Some("openshell:resolve:env:v1_ACME_TOKEN")
);
}

#[test]
fn child_env_with_gcp_resolved_overrides_gcp_static_vars() {
let state = ProviderCredentialState::from_bound_environment(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,8 @@ pub fn install_listener(syscalls: &[i64]) -> io::Result<NotificationListener> {
/// reconfigure an INET endpoint. Connected `send()`/null-destination
/// `sendto()` retains the audited cBPF fast path.
pub fn install_workload_listener() -> io::Result<NotificationListener> {
install_listener(&[
#[allow(unused_mut)] // SYS_open is unavailable on some architectures.
let mut syscalls = vec![
libc::SYS_socket,
libc::SYS_connect,
libc::SYS_bind,
Expand All @@ -447,7 +448,12 @@ pub fn install_workload_listener() -> io::Result<NotificationListener> {
libc::SYS_kill,
libc::SYS_tkill,
libc::SYS_rt_sigqueueinfo,
])
libc::SYS_openat,
libc::SYS_openat2,
];
#[cfg(target_arch = "x86_64")]
syscalls.push(libc::SYS_open);
install_listener(&syscalls)
}

/// Run a no-capability conformance probe.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,27 @@ mod tests {
})
.expect("launcher result")
.expect("spawn child");
let notification = listener.receive().expect("receive child socket");
let notification = loop {
let notification = listener.receive().expect("receive child syscall");
let syscall = i64::from(notification.syscall);
if syscall == libc::SYS_socket {
break notification;
}
if syscall == libc::SYS_openat || syscall == libc::SYS_openat2 {
listener
.respond_continue(notification.id)
.expect("continue ordinary file open");
continue;
}
#[cfg(target_arch = "x86_64")]
if syscall == libc::SYS_open {
listener
.respond_continue(notification.id)
.expect("continue ordinary file open");
continue;
}
panic!("unexpected child syscall: {syscall}");
};
assert_eq!(i64::from(notification.syscall), libc::SYS_socket);
assert!(
std::path::Path::new(&format!("/proc/{}/task/{}", child.id(), notification.tid))
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-providers/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ mod tests {

fn profile() -> ProviderTypeProfile {
ProviderTypeProfile {
files: Vec::new(),
id: "custom".to_string(),
resource_version: 0,
annotations: std::collections::HashMap::new(),
Expand Down
Loading
Loading