diff --git a/Cargo.lock b/Cargo.lock index 967cce7f9..ad3c229b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3338,6 +3338,7 @@ dependencies = [ "openshell-driver-podman", "openshell-ocsf", "openshell-policy", + "openshell-providers", "openshell-router", "petname", "pin-project-lite", diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md index 088bd7592..e7bdf37dd 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -35,15 +35,63 @@ Provider is defined in `proto/datamodel.proto`: - `type`: canonical provider slug (`claude`, `gitlab`, `github`, etc.) - `credentials`: `map` for secret values - `config`: `map` for non-secret settings +- `profile_id`: provider type profile used for profile-backed policy composition +- `profile_policy_enabled`: explicit opt-in for provider-generated policy entries The gRPC surface is defined in `proto/openshell.proto`: - `CreateProvider` - `GetProvider` - `ListProviders` +- `ListProviderProfiles` +- `GetProviderProfile` - `UpdateProvider` - `DeleteProvider` +## Provider Type Profiles + +Provider type profiles are declarative metadata for provider types. Profiles live in +`crates/openshell-providers` and are exposed through `ListProviderProfiles` and +`GetProviderProfile`. They describe credential names and environment variables, +known network endpoints, expected binaries, category, and whether the provider is +inference-capable. + +Profiles are additive to provider records. A provider record with only `type`, +`credentials`, and `config` remains a legacy credential-only provider. The gateway +does not infer provider-managed network policy from `provider.type` alone. A provider +contributes profile-generated policy only when `profile_policy_enabled` is true and +`profile_id` names a known profile. Existing stored provider records deserialize with +`profile_policy_enabled = false`, so OpenShell upgrades do not silently broaden network +access for existing sandboxes. + +New providers created by current clients can set `profile_id` to the provider type and +enable `profile_policy_enabled`. This makes the compatibility boundary explicit while +keeping the normal CLI experience simple. + +### Provider Policy Composition + +Sandbox policy fetch uses just-in-time composition: + +```text +effective policy = base/static policy + enabled provider profile rules + user rules +``` + +The composed policy is derived data. The sandbox still receives one normal +`SandboxPolicy`, but provider-generated entries are not persisted as user-authored +policy revisions. Full policy replacement and incremental policy updates continue to +mutate the user-authored policy layer. Provider-generated rules are re-added during +composition for each attached provider whose profile policy is enabled. + +Provider-generated network rules use reserved `_provider_*` names derived from the +provider record name. If a user policy already has the same key, composition keeps the +user entry and adds a numeric suffix to the provider entry. Duplicate host/port +endpoints across user and provider rules are valid; OPA evaluates all rules, so allow +decisions are the union of matching allows and deny rules continue to win globally. + +Gateway-global policy remains a full override. When a global policy is active, the +gateway serves the global policy as-is rather than composing provider layers into it, +and the existing blocks on sandbox-scoped policy mutations remain unchanged. + ## Components - `crates/openshell-providers` @@ -174,6 +222,7 @@ Also supported: - `openshell provider get ` - `openshell provider list` +- `openshell provider list-types` - `openshell provider update ...` - `openshell provider delete [...]` @@ -232,6 +281,8 @@ Key behaviors: - Only `credentials` are injected, not `config`. - Invalid env var keys (containing `.`, `-`, spaces, etc.) are skipped. - Credentials are never persisted in the sandbox spec's environment map. +- Provider profiles do not change credential injection in the first iteration. + Injection still uses the existing placeholder environment path. ### Sandbox Supervisor: Fetching Credentials diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 3502c2b07..638d990f8 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -700,6 +700,10 @@ enum ProviderCommands { names: bool, }, + /// List available provider types. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + ListTypes, + /// Update an existing provider's credentials or config. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Update { @@ -2619,6 +2623,9 @@ async fn main() -> Result<()> { } => { run::provider_list(endpoint, limit, offset, names, &tls).await?; } + ProviderCommands::ListTypes => { + run::provider_list_types(endpoint, &tls).await?; + } ProviderCommands::Update { name, from_existing, diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 87489014a..f06733f8f 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -27,11 +27,12 @@ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest, ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, - GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest, - ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, PolicyStatus, Provider, - RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, - SetClusterInferenceRequest, SettingScope, SettingValue, UpdateConfigRequest, - UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, + GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProviderProfilesRequest, + ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, + PolicyStatus, Provider, ProviderProfile, RejectDraftChunkRequest, Sandbox, SandboxPhase, + SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, + SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest, + exec_sandbox_event, setting_value, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_core::{ObjectId, ObjectName}; @@ -1964,7 +1965,7 @@ pub async fn sandbox_create_with_bootstrap( tty_override, Some(false), auto_providers_override, - &std::collections::HashMap::new(), + &HashMap::new(), &tls, ) .await @@ -2020,7 +2021,7 @@ pub async fn sandbox_create( tty_override: Option, bootstrap_override: Option, auto_providers_override: Option, - labels: &std::collections::HashMap, + labels: &HashMap, tls: &TlsOptions, ) -> Result<()> { if editor.is_some() && !command.is_empty() { @@ -3371,11 +3372,13 @@ async fn auto_create_provider( id: String::new(), name: exact_name.to_string(), created_at_ms: 0, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), + profile_id: provider_type.to_string(), + profile_policy_enabled: true, }), }; @@ -3411,11 +3414,13 @@ async fn auto_create_provider( id: String::new(), name: name.clone(), created_at_ms: 0, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), + profile_id: provider_type.to_string(), + profile_policy_enabled: true, }), }; @@ -3569,11 +3574,13 @@ pub async fn provider_create( id: String::new(), name: name.to_string(), created_at_ms: 0, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), }), - r#type: provider_type, + r#type: provider_type.clone(), credentials: credential_map, config: config_map, + profile_id: provider_type, + profile_policy_enabled: true, }), }) .await @@ -3702,6 +3709,60 @@ pub async fn provider_list( Ok(()) } +pub async fn provider_list_types(server: &str, tls: &TlsOptions) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .list_provider_profiles(ListProviderProfilesRequest { + limit: 100, + offset: 0, + }) + .await + .into_diagnostic()?; + let mut profiles = response.into_inner().profiles; + profiles.sort_by(|left, right| { + left.category + .cmp(&right.category) + .then_with(|| left.id.cmp(&right.id)) + }); + + if profiles.is_empty() { + println!("No provider types found."); + return Ok(()); + } + + println!("{}", "Available Provider Types:".cyan().bold()); + let mut current_category = String::new(); + for profile in profiles { + if profile.category != current_category { + current_category = profile.category.clone(); + println!(); + println!(" {}", display_provider_category(¤t_category).bold()); + } + print_provider_type_row(&profile); + } + + Ok(()) +} + +fn display_provider_category(category: &str) -> String { + category.replace('-', " ").to_ascii_uppercase() +} + +fn print_provider_type_row(profile: &ProviderProfile) { + let inference = if profile.inference_capable { + " inference" + } else { + "" + }; + println!( + " {:<12} {:<42} endpoints: {:<2}{}", + profile.id, + profile.display_name, + profile.endpoints.len(), + inference + ); +} + pub async fn provider_update( server: &str, name: &str, @@ -3759,11 +3820,13 @@ pub async fn provider_update( id: String::new(), name: name.to_string(), created_at_ms: 0, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), }), r#type: String::new(), credentials: credential_map, config: config_map, + profile_id: String::new(), + profile_policy_enabled: false, }), }) .await diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 34485377d..02bb952df 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -115,6 +115,8 @@ impl TestOpenShell { r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ); } @@ -251,6 +253,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListProvidersResponse { providers })) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, request: tonic::Request, @@ -293,6 +309,8 @@ impl OpenShell for TestOpenShell { r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), config: merge(existing.config, provider.config), + profile_id: existing.profile_id, + profile_policy_enabled: existing.profile_policy_enabled, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index e78c91578..5a071d957 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -177,6 +177,20 @@ impl OpenShell for TestOpenShell { )) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 9bda696c1..b658e1f5f 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -201,6 +201,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListProvidersResponse { providers })) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, request: tonic::Request, @@ -243,6 +257,8 @@ impl OpenShell for TestOpenShell { r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), config: merge(existing.config, provider.config), + profile_id: existing.profile_id, + profile_policy_enabled: existing.profile_policy_enabled, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 79d482fdb..f91fb9fb4 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -231,6 +231,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListProvidersResponse::default())) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 5c463de9e..e06d2f536 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -207,6 +207,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListProvidersResponse::default())) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-policy/src/compose.rs b/crates/openshell-policy/src/compose.rs new file mode 100644 index 000000000..7831aece2 --- /dev/null +++ b/crates/openshell-policy/src/compose.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Policy layer composition helpers. + +use openshell_core::proto::{NetworkPolicyRule, SandboxPolicy}; + +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderPolicyLayer { + pub rule_name: String, + pub rule: NetworkPolicyRule, +} + +#[must_use] +pub fn provider_rule_name(provider_name: &str) -> String { + let sanitized = provider_name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .to_string(); + + if sanitized.is_empty() { + "_provider_unnamed".to_string() + } else { + format!("_provider_{sanitized}") + } +} + +/// Compose a normal sandbox policy from user-authored policy plus provider +/// policy layers. +/// +/// The returned policy is derived data. It preserves the source policy's +/// static fields and user-authored network policies, then concatenates each +/// provider rule under a reserved `_provider_*` key. Existing user keys are not +/// overwritten; a numeric suffix is added if needed. +#[must_use] +pub fn compose_effective_policy( + source_policy: &SandboxPolicy, + provider_layers: &[ProviderPolicyLayer], +) -> SandboxPolicy { + let mut effective = source_policy.clone(); + + for layer in provider_layers { + let key = unique_provider_rule_key(&effective, &layer.rule_name); + let mut rule = layer.rule.clone(); + if rule.name.is_empty() { + rule.name = key.clone(); + } + effective.network_policies.insert(key, rule); + } + + effective +} + +fn unique_provider_rule_key(policy: &SandboxPolicy, preferred: &str) -> String { + if !policy.network_policies.contains_key(preferred) { + return preferred.to_string(); + } + + for suffix in 2_u32.. { + let candidate = format!("{preferred}_{suffix}"); + if !policy.network_policies.contains_key(&candidate) { + return candidate; + } + } + + unreachable!("unbounded suffix search must find an unused provider policy key") +} + +#[cfg(test)] +mod tests { + use super::{ProviderPolicyLayer, compose_effective_policy, provider_rule_name}; + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; + + fn rule(name: &str, host: &str) -> NetworkPolicyRule { + NetworkPolicyRule { + name: name.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + protocol: "rest".to_string(), + tls: String::new(), + enforcement: "enforce".to_string(), + access: "read-write".to_string(), + rules: Vec::new(), + allowed_ips: Vec::new(), + ports: Vec::new(), + deny_rules: Vec::new(), + allow_encoded_slash: false, + }], + binaries: Vec::new(), + } + } + + #[test] + fn provider_rule_name_sanitizes_provider_names() { + assert_eq!(provider_rule_name("my-github"), "_provider_my_github"); + assert_eq!(provider_rule_name("Work GitHub!"), "_provider_work_github"); + assert_eq!(provider_rule_name("..."), "_provider_unnamed"); + } + + #[test] + fn compose_concatenates_provider_rules_without_overwriting_user_rules() { + let mut source = SandboxPolicy::default(); + source.network_policies.insert( + "custom_github".to_string(), + rule("custom_github", "api.github.com"), + ); + source.network_policies.insert( + "_provider_work_github".to_string(), + rule("_provider_work_github", "example.com"), + ); + + let effective = compose_effective_policy( + &source, + &[ProviderPolicyLayer { + rule_name: "_provider_work_github".to_string(), + rule: rule("_provider_work_github", "github.com"), + }], + ); + + assert!(effective.network_policies.contains_key("custom_github")); + assert!( + effective + .network_policies + .contains_key("_provider_work_github") + ); + assert!( + effective + .network_policies + .contains_key("_provider_work_github_2") + ); + assert_eq!(source.network_policies.len(), 2); + assert_eq!(effective.network_policies.len(), 3); + } +} diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 0296879af..e071e23b3 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -9,6 +9,7 @@ //! policy schema. Both parsing (YAML→proto) and serialization (proto→YAML) use //! these types, ensuring round-trip fidelity. +mod compose; mod merge; use std::collections::{BTreeMap, HashMap}; @@ -22,6 +23,7 @@ use openshell_core::proto::{ }; use serde::{Deserialize, Serialize}; +pub use compose::{ProviderPolicyLayer, compose_effective_policy, provider_rule_name}; pub use merge::{ PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, generated_rule_name, merge_policy, diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index e2bcc0c09..b2bf1e234 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -5,6 +5,7 @@ mod context; mod discovery; +mod profiles; mod providers; #[cfg(test)] mod test_helpers; @@ -16,6 +17,7 @@ pub use openshell_core::proto::Provider; pub use context::{DiscoveryContext, RealDiscoveryContext}; pub use discovery::discover_with_spec; +pub use profiles::{ProviderTypeProfile, default_profiles, get_default_profile}; #[derive(Debug, thiserror::Error)] pub enum ProviderError { @@ -115,6 +117,16 @@ impl ProviderRegistry { .map_or(&[], ProviderPlugin::credential_env_vars) } + #[must_use] + pub fn profile(&self, id: &str) -> Option<&'static ProviderTypeProfile> { + get_default_profile(id) + } + + #[must_use] + pub fn profiles(&self) -> Vec<&'static ProviderTypeProfile> { + default_profiles().iter().collect() + } + #[must_use] pub fn known_types(&self) -> Vec<&'static str> { let mut types = self.plugins.keys().copied().collect::>(); diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs new file mode 100644 index 000000000..174602c9c --- /dev/null +++ b/crates/openshell-providers/src/profiles.rs @@ -0,0 +1,464 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Declarative provider type profiles. + +#![allow(deprecated)] // NetworkBinary::harness remains in the public proto for compatibility. + +use openshell_core::proto::{ + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderProfile, ProviderProfileCredential, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialProfile { + pub name: &'static str, + pub description: &'static str, + pub env_vars: &'static [&'static str], + pub required: bool, + pub auth_style: &'static str, + pub header_name: &'static str, + pub query_param: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndpointProfile { + pub host: &'static str, + pub port: u32, + pub protocol: &'static str, + pub access: &'static str, + pub enforcement: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderTypeProfile { + pub id: &'static str, + pub display_name: &'static str, + pub description: &'static str, + pub category: &'static str, + pub credentials: &'static [CredentialProfile], + pub endpoints: &'static [EndpointProfile], + pub binaries: &'static [&'static str], + pub inference_capable: bool, +} + +impl ProviderTypeProfile { + #[must_use] + pub fn credential_env_vars(&self) -> Vec<&'static str> { + let mut vars = Vec::new(); + for credential in self.credentials { + for env_var in credential.env_vars { + if !vars.contains(env_var) { + vars.push(*env_var); + } + } + } + vars + } + + #[must_use] + pub fn to_proto(&self) -> ProviderProfile { + ProviderProfile { + id: self.id.to_string(), + display_name: self.display_name.to_string(), + description: self.description.to_string(), + category: self.category.to_string(), + credentials: self + .credentials + .iter() + .map(|credential| ProviderProfileCredential { + name: credential.name.to_string(), + description: credential.description.to_string(), + env_vars: credential + .env_vars + .iter() + .map(|env_var| (*env_var).to_string()) + .collect(), + required: credential.required, + auth_style: credential.auth_style.to_string(), + header_name: credential.header_name.to_string(), + query_param: credential.query_param.to_string(), + }) + .collect(), + endpoints: self + .endpoints + .iter() + .map(|endpoint| NetworkEndpoint { + host: endpoint.host.to_string(), + port: endpoint.port, + protocol: endpoint.protocol.to_string(), + tls: String::new(), + enforcement: endpoint.enforcement.to_string(), + access: endpoint.access.to_string(), + rules: Vec::new(), + allowed_ips: Vec::new(), + ports: Vec::new(), + deny_rules: Vec::new(), + allow_encoded_slash: false, + }) + .collect(), + binaries: self + .binaries + .iter() + .map(|path| NetworkBinary { + path: (*path).to_string(), + harness: false, + }) + .collect(), + inference_capable: self.inference_capable, + } + } + + #[must_use] + pub fn network_policy_rule(&self, rule_name: &str) -> NetworkPolicyRule { + NetworkPolicyRule { + name: rule_name.to_string(), + endpoints: self.to_proto().endpoints, + binaries: self + .binaries + .iter() + .map(|path| NetworkBinary { + path: (*path).to_string(), + harness: false, + }) + .collect(), + } + } +} + +const CLAUDE_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "Anthropic API key used by Claude Code", + env_vars: &["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"], + required: true, + auth_style: "header", + header_name: "x-api-key", + query_param: "", +}]; + +const ANTHROPIC_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "Anthropic API key", + env_vars: &["ANTHROPIC_API_KEY"], + required: true, + auth_style: "header", + header_name: "x-api-key", + query_param: "", +}]; + +const OPENAI_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "OpenAI API key", + env_vars: &["OPENAI_API_KEY"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const OPENCODE_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "OpenCode-compatible API key", + env_vars: &["OPENCODE_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const NVIDIA_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "NVIDIA API key", + env_vars: &["NVIDIA_API_KEY"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const GITHUB_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_token", + description: "GitHub token", + env_vars: &["GITHUB_TOKEN", "GH_TOKEN"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const COPILOT_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "github_token", + description: "GitHub token used by Copilot tooling", + env_vars: &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const GITLAB_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_token", + description: "GitLab token", + env_vars: &["GITLAB_TOKEN", "GLAB_TOKEN", "CI_JOB_TOKEN"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const GENERIC_CREDENTIALS: &[CredentialProfile] = &[]; +const OUTLOOK_CREDENTIALS: &[CredentialProfile] = &[]; + +const CLAUDE_ENDPOINTS: &[EndpointProfile] = &[ + EndpointProfile { + host: "api.anthropic.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + EndpointProfile { + host: "statsig.anthropic.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + EndpointProfile { + host: "sentry.io", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, +]; + +const ANTHROPIC_ENDPOINTS: &[EndpointProfile] = &[EndpointProfile { + host: "api.anthropic.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", +}]; + +const OPENAI_ENDPOINTS: &[EndpointProfile] = &[EndpointProfile { + host: "api.openai.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", +}]; + +const NVIDIA_ENDPOINTS: &[EndpointProfile] = &[EndpointProfile { + host: "integrate.api.nvidia.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", +}]; + +const GITHUB_ENDPOINTS: &[EndpointProfile] = &[ + EndpointProfile { + host: "api.github.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + EndpointProfile { + host: "github.com", + port: 443, + protocol: "rest", + access: "read-only", + enforcement: "enforce", + }, +]; + +const GITLAB_ENDPOINTS: &[EndpointProfile] = &[ + EndpointProfile { + host: "gitlab.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + EndpointProfile { + host: "api.gitlab.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, +]; + +const EMPTY_ENDPOINTS: &[EndpointProfile] = &[]; + +const DEFAULT_PROFILES: &[ProviderTypeProfile] = &[ + ProviderTypeProfile { + id: "anthropic", + display_name: "Anthropic API", + description: "Anthropic API access for Claude models", + category: "inference", + credentials: ANTHROPIC_CREDENTIALS, + endpoints: ANTHROPIC_ENDPOINTS, + binaries: &["/usr/bin/curl", "/usr/local/bin/curl"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "claude", + display_name: "Claude Code", + description: "Claude Code CLI", + category: "inference", + credentials: CLAUDE_CREDENTIALS, + endpoints: CLAUDE_ENDPOINTS, + binaries: &["/usr/bin/claude", "/usr/local/bin/claude"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "codex", + display_name: "Codex", + description: "Codex CLI using OpenAI-compatible API credentials", + category: "inference", + credentials: OPENAI_CREDENTIALS, + endpoints: OPENAI_ENDPOINTS, + binaries: &["/usr/bin/codex", "/usr/local/bin/codex"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "copilot", + display_name: "GitHub Copilot", + description: "GitHub Copilot tooling", + category: "inference", + credentials: COPILOT_CREDENTIALS, + endpoints: GITHUB_ENDPOINTS, + binaries: &["/usr/bin/copilot", "/usr/local/bin/copilot"], + inference_capable: false, + }, + ProviderTypeProfile { + id: "generic", + display_name: "Generic", + description: "Generic provider record without managed policy defaults", + category: "custom", + credentials: GENERIC_CREDENTIALS, + endpoints: EMPTY_ENDPOINTS, + binaries: &[], + inference_capable: false, + }, + ProviderTypeProfile { + id: "github", + display_name: "GitHub", + description: "GitHub API and Git operations", + category: "source-control", + credentials: GITHUB_CREDENTIALS, + endpoints: GITHUB_ENDPOINTS, + binaries: &[ + "/usr/bin/gh", + "/usr/local/bin/gh", + "/usr/bin/git", + "/usr/local/bin/git", + ], + inference_capable: false, + }, + ProviderTypeProfile { + id: "gitlab", + display_name: "GitLab", + description: "GitLab API and Git operations", + category: "source-control", + credentials: GITLAB_CREDENTIALS, + endpoints: GITLAB_ENDPOINTS, + binaries: &[ + "/usr/bin/glab", + "/usr/local/bin/glab", + "/usr/bin/git", + "/usr/local/bin/git", + ], + inference_capable: false, + }, + ProviderTypeProfile { + id: "nvidia", + display_name: "NVIDIA", + description: "NVIDIA inference endpoints", + category: "inference", + credentials: NVIDIA_CREDENTIALS, + endpoints: NVIDIA_ENDPOINTS, + binaries: &["/usr/bin/curl", "/usr/local/bin/curl"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "openai", + display_name: "OpenAI", + description: "OpenAI API access", + category: "inference", + credentials: OPENAI_CREDENTIALS, + endpoints: OPENAI_ENDPOINTS, + binaries: &["/usr/bin/curl", "/usr/local/bin/curl"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "opencode", + display_name: "OpenCode", + description: "OpenCode-compatible inference provider", + category: "inference", + credentials: OPENCODE_CREDENTIALS, + endpoints: OPENAI_ENDPOINTS, + binaries: &["/usr/bin/opencode", "/usr/local/bin/opencode"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "outlook", + display_name: "Outlook", + description: "Outlook provider record without managed policy defaults", + category: "messaging", + credentials: OUTLOOK_CREDENTIALS, + endpoints: EMPTY_ENDPOINTS, + binaries: &[], + inference_capable: false, + }, +]; + +#[must_use] +pub const fn default_profiles() -> &'static [ProviderTypeProfile] { + DEFAULT_PROFILES +} + +#[must_use] +pub fn get_default_profile(id: &str) -> Option<&'static ProviderTypeProfile> { + default_profiles() + .iter() + .find(|profile| profile.id.eq_ignore_ascii_case(id)) +} + +#[cfg(test)] +mod tests { + use super::{default_profiles, get_default_profile}; + + #[test] + fn default_profiles_are_sorted_by_id() { + let ids = default_profiles() + .iter() + .map(|profile| profile.id) + .collect::>(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + assert_eq!(ids, sorted); + } + + #[test] + fn github_profile_materializes_policy_metadata() { + let profile = get_default_profile("github").expect("github profile"); + let proto = profile.to_proto(); + + assert_eq!(proto.id, "github"); + assert_eq!(proto.category, "source-control"); + assert_eq!(proto.endpoints.len(), 2); + assert_eq!(proto.binaries.len(), 4); + } + + #[test] + fn credential_env_vars_are_deduplicated_in_profile_order() { + let profile = get_default_profile("copilot").expect("copilot profile"); + assert_eq!( + profile.credential_env_vars(), + vec!["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] + ); + } +} diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index c2c392f33..0049420d5 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -20,6 +20,7 @@ openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } openshell-driver-podman = { path = "../openshell-driver-podman" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } +openshell-providers = { path = "../openshell-providers" } openshell-router = { path = "../openshell-router" } # Async runtime diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 969204dad..0eee0afe8 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -15,13 +15,15 @@ use openshell_core::proto::{ DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent, ExecSandboxRequest, GatewayMessage, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, - GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, - GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, - GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, + GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderProfileRequest, GetProviderRequest, GetSandboxConfigRequest, + GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, + GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, - HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, - ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxesRequest, - ListSandboxesResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, + HealthRequest, HealthResponse, ListProviderProfilesRequest, ListProviderProfilesResponse, + ListProvidersRequest, ListProvidersResponse, ListSandboxPoliciesRequest, + ListSandboxPoliciesResponse, ListSandboxesRequest, ListSandboxesResponse, + ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, @@ -250,6 +252,20 @@ impl OpenShell for OpenShellService { provider::handle_list_providers(&self.state, request).await } + async fn list_provider_profiles( + &self, + request: Request, + ) -> Result, Status> { + provider::handle_list_provider_profiles(&self.state, request).await + } + + async fn get_provider_profile( + &self, + request: Request, + ) -> Result, Status> { + provider::handle_get_provider_profile(&self.state, request).await + } + async fn update_provider( &self, request: Request, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index b0cfc6f38..a7d024711 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -32,7 +32,7 @@ use openshell_core::proto::{ UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, }; use openshell_core::proto::{ - L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Sandbox, + L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox, SandboxPolicy as ProtoSandboxPolicy, }; use openshell_core::{ @@ -42,7 +42,10 @@ use openshell_core::{ use openshell_ocsf::{ ConfigStateChangeBuilder, OCSF_TARGET, OcsfEvent, SandboxContext, SeverityId, StateId, StatusId, }; -use openshell_policy::{PolicyMergeOp, merge_policy}; +use openshell_policy::{ + PolicyMergeOp, ProviderPolicyLayer, compose_effective_policy, merge_policy, +}; +use openshell_providers::get_default_profile; use prost::Message; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap}; @@ -312,6 +315,11 @@ pub(super) async fn handle_get_sandbox_config( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox_provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.clone()) + .unwrap_or_default(); // Try to get the latest policy from the policy history table. let latest = state @@ -406,6 +414,18 @@ pub(super) async fn handle_get_sandbox_config( } } + if policy_source == PolicySource::Sandbox + && let Some(source_policy) = policy.as_ref() + { + let provider_layers = + profile_provider_policy_layers(state.store.as_ref(), &sandbox_provider_names).await?; + if !provider_layers.is_empty() { + let effective_policy = compose_effective_policy(source_policy, &provider_layers); + policy_hash = deterministic_policy_hash(&effective_policy); + policy = Some(effective_policy); + } + } + let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); @@ -420,6 +440,51 @@ pub(super) async fn handle_get_sandbox_config( })) } +async fn profile_provider_policy_layers( + store: &Store, + provider_names: &[String], +) -> Result, Status> { + let mut layers = Vec::new(); + + for name in provider_names { + let provider = store + .get_message_by_name::(name) + .await + .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? + .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; + + if !provider.profile_policy_enabled { + continue; + } + + let profile_id = provider.profile_id.trim(); + if profile_id.is_empty() { + warn!( + provider_name = %name, + "provider profile policy enabled without a profile id; skipping provider policy layer" + ); + continue; + } + + let Some(profile) = get_default_profile(profile_id) else { + warn!( + provider_name = %name, + profile_id, + "provider profile id is unknown; skipping provider policy layer" + ); + continue; + }; + + let rule_name = openshell_policy::provider_rule_name(provider.object_name()); + layers.push(ProviderPolicyLayer { + rule_name: rule_name.clone(), + rule: profile.network_policy_rule(&rule_name), + }); + } + + Ok(layers) +} + pub(super) async fn handle_get_gateway_config( state: &Arc, _request: Request, @@ -2616,6 +2681,66 @@ mod tests { assert!(loaded.spec.unwrap().policy.is_none()); } + fn test_provider(name: &str, profile_enabled: bool) -> Provider { + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("provider-{name}"), + name: name.to_string(), + created_at_ms: 1000000, + labels: HashMap::new(), + }), + r#type: "github".to_string(), + credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) + .collect(), + config: HashMap::new(), + profile_id: if profile_enabled { + "github".to_string() + } else { + String::new() + }, + profile_policy_enabled: profile_enabled, + } + } + + #[tokio::test] + async fn provider_policy_layers_skip_legacy_providers() { + let store = Store::connect("sqlite::memory:").await.unwrap(); + store + .put_message(&test_provider("legacy-github", false)) + .await + .unwrap(); + + let layers = profile_provider_policy_layers(&store, &["legacy-github".to_string()]) + .await + .unwrap(); + + assert!(layers.is_empty()); + } + + #[tokio::test] + async fn provider_policy_layers_include_profile_enabled_providers() { + let store = Store::connect("sqlite::memory:").await.unwrap(); + store + .put_message(&test_provider("work-github", true)) + .await + .unwrap(); + + let layers = profile_provider_policy_layers(&store, &["work-github".to_string()]) + .await + .unwrap(); + + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].rule_name, "_provider_work_github"); + assert_eq!(layers[0].rule.endpoints.len(), 2); + assert!( + layers[0] + .rule + .endpoints + .iter() + .any(|endpoint| endpoint.host == "api.github.com") + ); + } + #[tokio::test] async fn sandbox_policy_backfill_on_update_when_no_baseline() { use openshell_core::proto::{FilesystemPolicy, LandlockPolicy, SandboxPhase, SandboxSpec}; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 2e3876fb8..d21ba9dee 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -155,6 +155,8 @@ pub(super) async fn update_provider_record( r#type: existing.r#type, credentials: merge_map(existing.credentials, provider.credentials), config: merge_map(existing.config, provider.config), + profile_id: existing.profile_id, + profile_policy_enabled: existing.profile_policy_enabled, }; // Ensure metadata is valid (defense in depth - existing.metadata should always be valid) @@ -273,9 +275,12 @@ impl ObjectType for Provider { use crate::ServerState; use openshell_core::proto::{ - CreateProviderRequest, DeleteProviderRequest, DeleteProviderResponse, GetProviderRequest, - ListProvidersRequest, ListProvidersResponse, ProviderResponse, UpdateProviderRequest, + CreateProviderRequest, DeleteProviderRequest, DeleteProviderResponse, + GetProviderProfileRequest, GetProviderRequest, ListProviderProfilesRequest, + ListProviderProfilesResponse, ListProvidersRequest, ListProvidersResponse, + ProviderProfileResponse, ProviderResponse, UpdateProviderRequest, }; +use openshell_providers::{default_profiles, get_default_profile}; use std::sync::Arc; use tonic::{Request, Response}; @@ -317,6 +322,40 @@ pub(super) async fn handle_list_providers( Ok(Response::new(ListProvidersResponse { providers })) } +pub(super) async fn handle_list_provider_profiles( + _state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; + let offset = request.offset as usize; + let profiles = default_profiles() + .iter() + .skip(offset) + .take(limit) + .map(|profile| profile.to_proto()) + .collect(); + + Ok(Response::new(ListProviderProfilesResponse { profiles })) +} + +pub(super) async fn handle_get_provider_profile( + _state: &Arc, + request: Request, +) -> Result, Status> { + let id = request.into_inner().id; + if id.trim().is_empty() { + return Err(Status::invalid_argument("id is required")); + } + let profile = get_default_profile(id.trim()) + .ok_or_else(|| Status::not_found("provider profile not found"))? + .to_proto(); + + Ok(Response::new(ProviderProfileResponse { + profile: Some(profile), + })) +} + pub(super) async fn handle_update_provider( state: &Arc, request: Request, @@ -392,6 +431,8 @@ mod tests { ] .into_iter() .collect(), + profile_id: String::new(), + profile_policy_enabled: false, } } @@ -427,7 +468,7 @@ mod tests { id: String::new(), name: "gitlab-local".to_string(), created_at_ms: 1000000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -437,6 +478,8 @@ mod tests { .collect(), config: std::iter::once(("endpoint".to_string(), "https://gitlab.com".to_string())) .collect(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -505,6 +548,8 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -529,6 +574,8 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -557,6 +604,8 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -604,6 +653,8 @@ mod tests { r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), config: std::iter::once(("region".to_string(), String::new())).collect(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -655,6 +706,8 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -684,6 +737,8 @@ mod tests { r#type: "openai".to_string(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -715,6 +770,8 @@ mod tests { r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -752,6 +809,8 @@ mod tests { "https://api.anthropic.com".to_string(), )) .collect(), + profile_id: String::new(), + profile_policy_enabled: false, }; create_provider_record(&store, provider).await.unwrap(); @@ -792,6 +851,8 @@ mod tests { .into_iter() .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }; create_provider_record(&store, provider).await.unwrap(); @@ -822,6 +883,8 @@ mod tests { )) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -839,6 +902,8 @@ mod tests { credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -870,6 +935,8 @@ mod tests { credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -890,6 +957,8 @@ mod tests { )) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -926,6 +995,8 @@ mod tests { )) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -936,7 +1007,7 @@ mod tests { id: "sandbox-001".to_string(), name: "test-sandbox".to_string(), created_at_ms: 1000000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["my-claude".to_string()], @@ -972,7 +1043,7 @@ mod tests { id: "sandbox-002".to_string(), name: "empty-sandbox".to_string(), created_at_ms: 1000000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), }), spec: Some(SandboxSpec::default()), status: None, diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 5e4a48cb5..d49896b18 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -883,6 +883,8 @@ mod tests { r#type: provider_type.to_string(), credentials, config, + profile_id: String::new(), + profile_policy_enabled: false, } } diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index be983667b..4638092b1 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -506,6 +506,8 @@ mod tests { r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), config: std::collections::HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, } } @@ -670,6 +672,8 @@ mod tests { "https://station.example.com/v1".to_string(), )) .collect(), + profile_id: String::new(), + profile_policy_enabled: false, }; store .put_message(&provider) @@ -744,6 +748,8 @@ mod tests { credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-rotated".to_string())) .collect(), config: provider.config.clone(), + profile_id: provider.profile_id.clone(), + profile_policy_enabled: provider.profile_policy_enabled, }; store .put_message(&rotated_provider) diff --git a/crates/openshell-server/tests/auth_endpoint_integration.rs b/crates/openshell-server/tests/auth_endpoint_integration.rs index 12f302b63..e5f9dc4e9 100644 --- a/crates/openshell-server/tests/auth_endpoint_integration.rs +++ b/crates/openshell-server/tests/auth_endpoint_integration.rs @@ -508,6 +508,22 @@ impl openshell_core::proto::open_shell_server::OpenShell for TestOpenShell { Err(tonic::Status::unimplemented("test")) } + async fn list_provider_profiles( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> + { + Err(tonic::Status::unimplemented("test")) + } + + async fn get_provider_profile( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> + { + Err(tonic::Status::unimplemented("test")) + } + async fn update_provider( &self, _: tonic::Request, diff --git a/crates/openshell-server/tests/edge_tunnel_auth.rs b/crates/openshell-server/tests/edge_tunnel_auth.rs index e8c7e0038..50783a406 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -171,6 +171,20 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/multiplex_integration.rs b/crates/openshell-server/tests/multiplex_integration.rs index 561ea2ba7..3db605d52 100644 --- a/crates/openshell-server/tests/multiplex_integration.rs +++ b/crates/openshell-server/tests/multiplex_integration.rs @@ -135,6 +135,20 @@ impl OpenShell for TestOpenShell { )) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/multiplex_tls_integration.rs b/crates/openshell-server/tests/multiplex_tls_integration.rs index dc51e6118..84c01b5bc 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -148,6 +148,20 @@ impl OpenShell for TestOpenShell { )) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 85d263223..d77cfd375 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -172,6 +172,21 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + + async fn list_provider_profiles( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn get_provider_profile( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn delete_provider( &self, _: tonic::Request, diff --git a/crates/openshell-server/tests/ws_tunnel_integration.rs b/crates/openshell-server/tests/ws_tunnel_integration.rs index 584f09281..1e3391b17 100644 --- a/crates/openshell-server/tests/ws_tunnel_integration.rs +++ b/crates/openshell-server/tests/ws_tunnel_integration.rs @@ -165,6 +165,20 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 0a8caf675..4147c0506 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1573,6 +1573,8 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { r#type: ptype.clone(), credentials: credentials.clone(), config: Default::default(), + profile_id: ptype.clone(), + profile_policy_enabled: true, }), }; @@ -1663,6 +1665,8 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { r#type: ptype, credentials, config: Default::default(), + profile_id: String::new(), + profile_policy_enabled: false, }), }; diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index fbfc4d380..052688ef9 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -12,6 +12,13 @@ AI agents typically need credentials to access external services: an API key for Create and manage providers that supply credentials to sandboxes. +Provider types include profile metadata for known endpoints and binaries. View +the available provider types before creating a provider: + +```shell +openshell provider list-types +``` + ## Create a Provider Providers can be created from local environment variables or with explicit credential values. @@ -47,6 +54,11 @@ openshell provider create --name my-api --type generic --credential API_KEY This looks up the current value of `$API_KEY` in your shell and stores it. +Providers created by current clients use the default profile for their type when +one exists. Profile-managed providers add their known network endpoints to the +sandbox's effective policy when attached at sandbox creation. Providers created +before profile support remain credential-only until recreated or upgraded. + ## Manage Providers List, inspect, update, and delete providers from the active cluster. @@ -84,7 +96,9 @@ openshell sandbox create --provider my-claude --provider my-github -- claude ``` Each `--provider` flag attaches one provider. The sandbox receives all -credentials from every attached provider at runtime. +credentials from every attached provider at runtime. Profile-managed providers +also contribute provider-generated network policy entries. Legacy providers keep +the previous behavior and only provide credentials. Providers cannot be added to a running sandbox. If you need to attach an diff --git a/proto/datamodel.proto b/proto/datamodel.proto index 534b043ae..3aa2c3df8 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -34,4 +34,14 @@ message Provider { map credentials = 3; // Non-secret provider configuration. map config = 4; + // Provider type profile used for profile-backed policy composition. + // + // Empty means the provider is legacy/manual and must not contribute + // provider-generated network policy. + string profile_id = 5; + // Whether this provider contributes its profile's generated policy layer. + // + // This is explicit for backwards compatibility: existing provider records + // deserialize to false and keep the legacy credential-only behavior. + bool profile_policy_enabled = 6; } diff --git a/proto/openshell.proto b/proto/openshell.proto index 48a8fece2..8fe4b6155 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -51,6 +51,14 @@ service OpenShell { // List providers. rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); + // List available provider type profiles. + rpc ListProviderProfiles(ListProviderProfilesRequest) + returns (ListProviderProfilesResponse); + + // Fetch one provider type profile by id. + rpc GetProviderProfile(GetProviderProfileRequest) + returns (ProviderProfileResponse); + // Update an existing provider by name. rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse); @@ -558,6 +566,50 @@ message ListProvidersResponse { repeated openshell.datamodel.v1.Provider providers = 1; } +// List provider type profiles request. +message ListProviderProfilesRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Fetch provider type profile request. +message GetProviderProfileRequest { + string id = 1; +} + +// Provider credential declaration. +message ProviderProfileCredential { + string name = 1; + string description = 2; + repeated string env_vars = 3; + bool required = 4; + string auth_style = 5; + string header_name = 6; + string query_param = 7; +} + +// Provider type profile metadata exposed to clients. +message ProviderProfile { + string id = 1; + string display_name = 2; + string description = 3; + string category = 4; + repeated ProviderProfileCredential credentials = 5; + repeated openshell.sandbox.v1.NetworkEndpoint endpoints = 6; + repeated openshell.sandbox.v1.NetworkBinary binaries = 7; + bool inference_capable = 8; +} + +// Provider profile response. +message ProviderProfileResponse { + ProviderProfile profile = 1; +} + +// List provider profiles response. +message ListProviderProfilesResponse { + repeated ProviderProfile profiles = 1; +} + // Delete provider response. message DeleteProviderResponse { bool deleted = 1;