feat(providers): add profile policy composition

This commit is contained in:
John Myers
2026-04-27 14:25:52 -07:00
parent aee744374a
commit da692e0bbf
29 changed files with 1233 additions and 26 deletions
Generated
+1
View File
@@ -3338,6 +3338,7 @@ dependencies = [
"openshell-driver-podman",
"openshell-ocsf",
"openshell-policy",
"openshell-providers",
"openshell-router",
"petname",
"pin-project-lite",
+51
View File
@@ -35,15 +35,63 @@ Provider is defined in `proto/datamodel.proto`:
- `type`: canonical provider slug (`claude`, `gitlab`, `github`, etc.)
- `credentials`: `map<string, string>` for secret values
- `config`: `map<string, string>` 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 <name>`
- `openshell provider list`
- `openshell provider list-types`
- `openshell provider update <name> ...`
- `openshell provider delete <name> [<name>...]`
@@ -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
+7
View File
@@ -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,
+75 -12
View File
@@ -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<bool>,
bootstrap_override: Option<bool>,
auto_providers_override: Option<bool>,
labels: &std::collections::HashMap<String, String>,
labels: &HashMap<String, String>,
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(&current_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
@@ -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<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn update_provider(
&self,
request: tonic::Request<UpdateProviderRequest>,
@@ -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());
@@ -177,6 +177,20 @@ impl OpenShell for TestOpenShell {
))
}
async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn update_provider(
&self,
_request: tonic::Request<UpdateProviderRequest>,
@@ -201,6 +201,20 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(ListProvidersResponse { providers }))
}
async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn update_provider(
&self,
request: tonic::Request<UpdateProviderRequest>,
@@ -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());
@@ -231,6 +231,20 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(ListProvidersResponse::default()))
}
async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn update_provider(
&self,
_request: tonic::Request<UpdateProviderRequest>,
@@ -207,6 +207,20 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(ListProvidersResponse::default()))
}
async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn update_provider(
&self,
_request: tonic::Request<UpdateProviderRequest>,
+143
View File
@@ -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::<String>()
.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);
}
}
+2
View File
@@ -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,
+12
View File
@@ -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::<Vec<_>>();
+464
View File
@@ -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::<Vec<_>>();
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"]
);
}
}
+1
View File
@@ -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
+22 -6
View File
@@ -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<ListProviderProfilesRequest>,
) -> Result<Response<ListProviderProfilesResponse>, Status> {
provider::handle_list_provider_profiles(&self.state, request).await
}
async fn get_provider_profile(
&self,
request: Request<GetProviderProfileRequest>,
) -> Result<Response<ProviderProfileResponse>, Status> {
provider::handle_get_provider_profile(&self.state, request).await
}
async fn update_provider(
&self,
request: Request<UpdateProviderRequest>,
+127 -2
View File
@@ -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<Vec<ProviderPolicyLayer>, Status> {
let mut layers = Vec::new();
for name in provider_names {
let provider = store
.get_message_by_name::<Provider>(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<ServerState>,
_request: Request<GetGatewayConfigRequest>,
@@ -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};
+76 -5
View File
@@ -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<ServerState>,
request: Request<ListProviderProfilesRequest>,
) -> Result<Response<ListProviderProfilesResponse>, 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<ServerState>,
request: Request<GetProviderProfileRequest>,
) -> Result<Response<ProviderProfileResponse>, 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<ServerState>,
request: Request<UpdateProviderRequest>,
@@ -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,
@@ -883,6 +883,8 @@ mod tests {
r#type: provider_type.to_string(),
credentials,
config,
profile_id: String::new(),
profile_policy_enabled: false,
}
}
+6
View File
@@ -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)
@@ -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<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<tonic::Response<openshell_core::proto::ListProviderProfilesResponse>, tonic::Status>
{
Err(tonic::Status::unimplemented("test"))
}
async fn get_provider_profile(
&self,
_: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<tonic::Response<openshell_core::proto::ProviderProfileResponse>, tonic::Status>
{
Err(tonic::Status::unimplemented("test"))
}
async fn update_provider(
&self,
_: tonic::Request<openshell_core::proto::UpdateProviderRequest>,
@@ -171,6 +171,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn update_provider(
&self,
_request: tonic::Request<UpdateProviderRequest>,
@@ -135,6 +135,20 @@ impl OpenShell for TestOpenShell {
))
}
async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn update_provider(
&self,
_request: tonic::Request<UpdateProviderRequest>,
@@ -148,6 +148,20 @@ impl OpenShell for TestOpenShell {
))
}
async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn update_provider(
&self,
_request: tonic::Request<UpdateProviderRequest>,
@@ -172,6 +172,21 @@ impl OpenShell for RelayGateway {
) -> Result<Response<openshell_core::proto::ListProvidersResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn list_provider_profiles(
&self,
_: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn get_provider_profile(
&self,
_: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn delete_provider(
&self,
_: tonic::Request<openshell_core::proto::DeleteProviderRequest>,
@@ -165,6 +165,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}
async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}
async fn update_provider(
&self,
_request: tonic::Request<UpdateProviderRequest>,
+4
View File
@@ -1573,6 +1573,8 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender<Event>) {
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<Event>) {
r#type: ptype,
credentials,
config: Default::default(),
profile_id: String::new(),
profile_policy_enabled: false,
}),
};
+15 -1
View File
@@ -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.
<Warning>
Providers cannot be added to a running sandbox. If you need to attach an
+10
View File
@@ -34,4 +34,14 @@ message Provider {
map<string, string> credentials = 3;
// Non-secret provider configuration.
map<string, string> 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;
}
+52
View File
@@ -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;