Skip to content
Open
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
24 changes: 24 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,30 @@ Credential placeholders in proxied HTTP requests can be resolved by the proxy
when policy allows the target endpoint. Secrets must not be logged in OCSF or
plain tracing output.

### Selective Passthrough

The canonical placeholder model breaks for credentials that an SDK validates
in-process before any network call (Slack `xoxb-`/`xapp-`, JWT structure,
AWS access-key format), are sent over transports the L7 proxy cannot rewrite
(WebSocket payloads after `101 Switching Protocols`), or are consumed by
in-process crypto (HMAC signing, signature verification).

For these cases an operator can opt specific credential keys into passthrough
via `Provider.passthrough_credentials`. The supervisor injects the real value
into the agent's environment instead of the canonical
`openshell:resolve:env:<KEY>` placeholder, and the resolver does not register
that key, so the L7 proxy performs no substitution for it. Each entry must be
a valid env-var name, present in `Provider.credentials`, and unique. On update,
an empty incoming list preserves the existing list while auto-pruning entries
whose credential was deleted in the same update; a non-empty list replaces the
stored list verbatim. When two providers declare the same credential key, the
first provider's value wins and the passthrough flag follows the winning value.

Passthrough drops the "agent never sees the real secret" invariant for the
listed keys: the real value is at rest in `/proc/<agent-pid>/environ` and any
descendant inherits it. Prefer canonical placeholders; only opt in keys whose
consumer demonstrably fails with the placeholder.

## Connect and Logs

The supervisor runs an SSH server on a Unix socket inside the sandbox. The
Expand Down
22 changes: 22 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,15 @@ enum ProviderCommands {
/// Provider config key/value pair.
#[arg(long = "config", value_name = "KEY=VALUE")]
config: Vec<String>,

/// Credential key whose real value is injected directly into the
/// agent process, bypassing the canonical placeholder substitution
/// and L7 proxy rewriting. Each key must also appear in `--credential`
/// (or be discovered via `--from-existing`). Drops the "agent never
/// sees the real secret" invariant for that key — see provider docs
/// for the security trade-off.
#[arg(long = "passthrough", value_name = "KEY")]
passthrough: Vec<String>,
},

/// Fetch a provider by name.
Expand Down Expand Up @@ -743,6 +752,15 @@ enum ProviderCommands {
/// Provider config key/value pair.
#[arg(long = "config", value_name = "KEY=VALUE")]
config: Vec<String>,

/// Credential key whose real value is injected directly into the
/// agent process, bypassing the canonical placeholder substitution
/// and L7 proxy rewriting. Replaces the existing passthrough list
/// when non-empty. Each key must also be present in the merged
/// credentials. Drops the "agent never sees the real secret"
/// invariant for that key.
#[arg(long = "passthrough", value_name = "KEY")]
passthrough: Vec<String>,
},

/// Delete providers by name.
Expand Down Expand Up @@ -2404,6 +2422,7 @@ async fn main() -> Result<()> {
from_existing,
credentials,
config,
passthrough,
} => {
run::provider_create(
endpoint,
Expand All @@ -2412,6 +2431,7 @@ async fn main() -> Result<()> {
from_existing,
&credentials,
&config,
&passthrough,
&tls,
)
.await?;
Expand Down Expand Up @@ -2460,13 +2480,15 @@ async fn main() -> Result<()> {
from_existing,
credentials,
config,
passthrough,
} => {
run::provider_update(
endpoint,
&name,
from_existing,
&credentials,
&config,
&passthrough,
&tls,
)
.await?;
Expand Down
89 changes: 85 additions & 4 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2776,6 +2776,7 @@ async fn auto_create_provider(
r#type: provider_type.to_string(),
credentials: discovered.credentials.clone(),
config: discovered.config.clone(),
passthrough_credentials: Vec::new(),
}),
};

Expand Down Expand Up @@ -2816,6 +2817,7 @@ async fn auto_create_provider(
r#type: provider_type.to_string(),
credentials: discovered.credentials.clone(),
config: discovered.config.clone(),
passthrough_credentials: Vec::new(),
}),
};

Expand Down Expand Up @@ -2876,6 +2878,50 @@ fn parse_key_value_pairs(items: &[String], flag: &str) -> Result<HashMap<String,
Ok(map)
}

/// Parse `--passthrough KEY` flags into a deduplicated list. Validates that
/// each entry is non-empty and that no key is supplied more than once.
///
/// Cross-referencing against the credential map is intentionally not done
/// here — call [`ensure_passthrough_in_credentials`] when the caller has the
/// full local credential set (e.g. on `provider create` after
/// `--from-existing` discovery). On `provider update` the credential set
/// lives only on the server, and the server re-validates anyway.
fn parse_passthrough_keys(items: &[String]) -> Result<Vec<String>> {
let mut seen = HashSet::new();
let mut out = Vec::with_capacity(items.len());
for item in items {
let key = item.trim();
if key.is_empty() {
return Err(miette::miette!("--passthrough key cannot be empty"));
}
if !seen.insert(key.to_string()) {
return Err(miette::miette!(
"--passthrough key '{key}' was supplied more than once"
));
}
out.push(key.to_string());
}
Ok(out)
}

/// Reject any passthrough key that is not present in `credential_map`. The
/// caller is responsible for assembling the full local credential set before
/// invoking — typically explicit `--credential` entries merged with anything
/// `--from-existing` discovered.
fn ensure_passthrough_in_credentials(
passthrough: &[String],
credential_map: &HashMap<String, String>,
) -> Result<()> {
for key in passthrough {
if !credential_map.contains_key(key) {
return Err(miette::miette!(
"--passthrough '{key}' is not present in resolved credentials"
));
}
}
Ok(())
}

fn parse_credential_pairs(items: &[String]) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();

Expand Down Expand Up @@ -2912,13 +2958,15 @@ fn parse_credential_pairs(items: &[String]) -> Result<HashMap<String, String>> {
Ok(map)
}

#[allow(clippy::too_many_arguments)] // user-facing CLI command
pub async fn provider_create(
server: &str,
name: &str,
provider_type: &str,
from_existing: bool,
credentials: &[String],
config: &[String],
passthrough: &[String],
tls: &TlsOptions,
) -> Result<()> {
if from_existing && !credentials.is_empty() {
Expand Down Expand Up @@ -2959,6 +3007,7 @@ pub async fn provider_create(

let mut credential_map = parse_credential_pairs(credentials)?;
let mut config_map = parse_key_value_pairs(config, "--config")?;
let passthrough_credentials = parse_passthrough_keys(passthrough)?;

if from_existing {
let registry = ProviderRegistry::new();
Expand Down Expand Up @@ -2986,6 +3035,11 @@ pub async fn provider_create(
));
}

// The full credential set is known locally on create (--credential plus
// anything --from-existing discovered), so cross-check passthrough keys
// here for fast feedback. The server re-validates regardless.
ensure_passthrough_in_credentials(&passthrough_credentials, &credential_map)?;

let response = client
.create_provider(CreateProviderRequest {
provider: Some(Provider {
Expand All @@ -2998,6 +3052,7 @@ pub async fn provider_create(
r#type: provider_type.clone(),
credentials: credential_map,
config: config_map,
passthrough_credentials,
}),
})
.await
Expand Down Expand Up @@ -3030,8 +3085,25 @@ pub async fn provider_get(server: &str, name: &str, tls: &TlsOptions) -> Result<
.provider
.ok_or_else(|| miette::miette!("provider missing from response"))?;

let credential_keys = provider.credentials.keys().cloned().collect::<Vec<_>>();
let config_keys = provider.config.keys().cloned().collect::<Vec<_>>();
let passthrough: HashSet<&str> = provider
.passthrough_credentials
.iter()
.map(String::as_str)
.collect();
let mut credential_keys = provider
.credentials
.keys()
.map(|k| {
if passthrough.contains(k.as_str()) {
format!("{k} (passthrough)")
} else {
k.clone()
}
})
.collect::<Vec<_>>();
credential_keys.sort();
let mut config_keys = provider.config.keys().cloned().collect::<Vec<_>>();
config_keys.sort();

println!("{}", "Provider:".cyan().bold());
println!();
Expand Down Expand Up @@ -3102,19 +3174,21 @@ pub async fn provider_list(
.max(4);

println!(
"{:<name_width$} {:<type_width$} {:<16} {}",
"{:<name_width$} {:<type_width$} {:<16} {:<13} {}",
"NAME".bold(),
"TYPE".bold(),
"CREDENTIAL_KEYS".bold(),
"PASSTHROUGH".bold(),
"CONFIG_KEYS".bold(),
);

for provider in providers {
println!(
"{:<name_width$} {:<type_width$} {:<16} {}",
"{:<name_width$} {:<type_width$} {:<16} {:<13} {}",
provider.object_name().to_string(),
provider.r#type,
provider.credentials.len(),
provider.passthrough_credentials.len(),
provider.config.len(),
);
}
Expand Down Expand Up @@ -3442,6 +3516,7 @@ pub async fn provider_update(
from_existing: bool,
credentials: &[String],
config: &[String],
passthrough: &[String],
tls: &TlsOptions,
) -> Result<()> {
if from_existing && !credentials.is_empty() {
Expand All @@ -3454,6 +3529,11 @@ pub async fn provider_update(

let mut credential_map = parse_credential_pairs(credentials)?;
let mut config_map = parse_key_value_pairs(config, "--config")?;
// On update the local --credential set may legitimately be empty (the
// operator is only changing the passthrough list against existing stored
// credentials), so the cross-check against credentials is delegated to
// server-side validation, which sees the post-merge state.
let passthrough_credentials = parse_passthrough_keys(passthrough)?;

if from_existing {
// Fetch the existing provider to discover its type for credential lookup.
Expand Down Expand Up @@ -3498,6 +3578,7 @@ pub async fn provider_update(
r#type: String::new(),
credentials: credential_map,
config: config_map,
passthrough_credentials,
}),
})
.await
Expand Down
6 changes: 6 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ impl TestOpenShell {
r#type: provider_type.to_string(),
credentials: HashMap::new(),
config: HashMap::new(),
passthrough_credentials: Vec::new(),
},
);
}
Expand Down Expand Up @@ -328,6 +329,11 @@ impl OpenShell for TestOpenShell {
r#type: existing.r#type,
credentials: merge(existing.credentials, provider.credentials),
config: merge(existing.config, provider.config),
passthrough_credentials: if provider.passthrough_credentials.is_empty() {
existing.passthrough_credentials
} else {
provider.passthrough_credentials
},
};
let updated_name = updated.object_name().to_string();
providers.insert(updated_name, updated.clone());
Expand Down
13 changes: 13 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,11 @@ impl OpenShell for TestOpenShell {
r#type: existing.r#type,
credentials: merge(existing.credentials, provider.credentials),
config: merge(existing.config, provider.config),
passthrough_credentials: if provider.passthrough_credentials.is_empty() {
existing.passthrough_credentials
} else {
provider.passthrough_credentials
},
};
let updated_name = updated.object_name().to_string();
providers.insert(updated_name, updated.clone());
Expand Down Expand Up @@ -591,6 +596,7 @@ async fn provider_cli_run_functions_support_full_crud_flow() {
false,
&["API_KEY=abc".to_string()],
&["profile=dev".to_string()],
&[],
&ts.tls,
)
.await
Expand All @@ -609,6 +615,7 @@ async fn provider_cli_run_functions_support_full_crud_flow() {
false,
&["API_KEY=rotated".to_string()],
&["profile=prod".to_string()],
&[],
&ts.tls,
)
.await
Expand Down Expand Up @@ -671,6 +678,7 @@ binaries: [/usr/bin/custom]
false,
&["CUSTOM_API_KEY=abc".to_string()],
&[],
&[],
&ts.tls,
)
.await
Expand Down Expand Up @@ -873,6 +881,7 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value()
false,
&["INVALID_PAIR".to_string()],
&[],
&[],
&ts.tls,
)
.await
Expand All @@ -897,6 +906,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() {
false,
&["NAV_GENERIC_TEST_KEY".to_string()],
&[],
&[],
&ts.tls,
)
.await
Expand Down Expand Up @@ -931,6 +941,7 @@ async fn provider_create_rejects_combined_from_existing_and_credentials() {
true,
&["API_KEY=abc".to_string()],
&[],
&[],
&ts.tls,
)
.await
Expand All @@ -955,6 +966,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() {
false,
&["NAV_EMPTY_ENV_KEY".to_string()],
&[],
&[],
&ts.tls,
)
.await
Expand All @@ -979,6 +991,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() {
false,
&["NVIDIA_API_KEY".to_string()],
&[],
&[],
&ts.tls,
)
.await
Expand Down
Loading
Loading