Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,12 @@ protocols remain raw passthrough.

## Live Updates

The gateway stores policy revisions and exposes effective sandbox configuration.
The supervisor polls for config revisions and attempts to load new dynamic
policy into the in-process OPA engine.
The gateway stores sandbox-authored policy revisions separately from derived
effective sandbox configuration. Effective configuration can include
gateway-global policy overrides and provider-profile policy layers. The
supervisor polls for config revisions and attempts to load new dynamic policy
into the in-process OPA engine; CLI reads of the latest sandbox policy use the
same effective configuration path.

If a new policy fails validation or loading, the supervisor reports the failure
and keeps the last-known-good policy. Static controls, such as filesystem
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1598,14 +1598,14 @@ enum PolicyCommands {
timeout: u64,
},

/// Show current active policy for a sandbox or the global policy.
/// Show current effective policy for a sandbox or a stored global policy.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Get {
/// Sandbox name (defaults to last-used sandbox). Ignored with --global.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option<String>,

/// Show a specific policy revision (default: latest).
/// Show a specific stored policy revision. Default shows the current effective policy.
#[arg(long = "rev", default_value_t = 0)]
rev: u32,

Expand Down
173 changes: 168 additions & 5 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4481,14 +4481,16 @@ pub async fn provider_list_profiles(server: &str, output: &str, tls: &TlsOptions
}

println!("{}", "Available Provider Profiles:".cyan().bold());
let id_width = provider_profile_id_width(&profiles);
let display_width = provider_profile_display_width(&profiles);
let mut current_category = i32::MIN;
for profile in profiles {
if profile.category != current_category {
current_category = profile.category;
println!();
println!(" {}", display_provider_category(current_category).bold());
}
print_provider_type_row(&profile);
print_provider_type_row(&profile, id_width, display_width);
}

Ok(())
Expand Down Expand Up @@ -4959,21 +4961,65 @@ fn display_provider_category(category: i32) -> &'static str {
}
}

fn print_provider_type_row(profile: &ProviderProfile) {
const PROVIDER_PROFILE_ID_MAX_WIDTH: usize = 32;
const PROVIDER_PROFILE_DISPLAY_MAX_WIDTH: usize = 40;

fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize {
profiles
.iter()
.map(|profile| {
profile
.id
.chars()
.count()
.min(PROVIDER_PROFILE_ID_MAX_WIDTH)
})
.max()
.unwrap_or(2)
.max(2)
}

fn provider_profile_display_width(profiles: &[ProviderProfile]) -> usize {
profiles
.iter()
.map(|profile| {
profile
.display_name
.chars()
.count()
.min(PROVIDER_PROFILE_DISPLAY_MAX_WIDTH)
})
.max()
.unwrap_or(4)
.max(4)
}

fn print_provider_type_row(profile: &ProviderProfile, id_width: usize, display_width: usize) {
let inference = if profile.inference_capable {
" inference"
} else {
""
};
let id = truncate_display(&profile.id, PROVIDER_PROFILE_ID_MAX_WIDTH);
let display_name = truncate_display(&profile.display_name, PROVIDER_PROFILE_DISPLAY_MAX_WIDTH);
println!(
" {:<12} {:<42} endpoints: {:<2}{}",
profile.id,
profile.display_name,
" {id:<id_width$} {display_name:<display_width$} endpoints: {:<2}{}",
profile.endpoints.len(),
inference
);
}

fn truncate_display(value: &str, max_width: usize) -> String {
if value.chars().count() <= max_width {
return value.to_string();
}

let keep = max_width.saturating_sub(3);
let mut truncated = value.chars().take(keep).collect::<String>();
truncated.push_str("...");
truncated
}

pub async fn provider_update(
server: &str,
name: &str,
Expand Down Expand Up @@ -6269,6 +6315,11 @@ where
W: Write + Send,
E: Write + Send,
{
if version == 0 {
return sandbox_policy_get_effective_to_writer(server, name, full, output, tls, writers)
.await;
}

let (stdout, stderr) = writers;
let mut client = grpc_client(server, tls).await?;

Expand Down Expand Up @@ -6338,6 +6389,118 @@ where
Ok(())
}

async fn sandbox_policy_get_effective_to_writer<W, E>(
server: &str,
name: &str,
full: bool,
output: &str,
tls: &TlsOptions,
writers: (&mut W, &mut E),
) -> Result<()>
where
W: Write + Send,
E: Write + Send,
{
let (stdout, _stderr) = writers;
let mut client = grpc_client(server, tls).await?;

let sandbox = client
.get_sandbox(GetSandboxRequest {
name: name.to_string(),
})
.await
.into_diagnostic()?
.into_inner()
.sandbox
.ok_or_else(|| miette!("sandbox missing from response"))?;
let sandbox_id = sandbox.object_id();
if sandbox_id.is_empty() {
return Err(miette!("sandbox missing metadata"));
}

let config = client
.get_sandbox_config(GetSandboxConfigRequest {
sandbox_id: sandbox_id.to_string(),
})
.await
.into_diagnostic()?
.into_inner();
let policy = config
.policy
.as_ref()
.ok_or_else(|| miette!("no active policy configured for sandbox '{name}'"))?;
let policy_source =
PolicySource::try_from(config.policy_source).unwrap_or(PolicySource::Sandbox);
let policy_source_label = match policy_source {
PolicySource::Global => "global",
PolicySource::Sandbox => "sandbox",
PolicySource::Unspecified => "unspecified",
};
let version = if policy_source == PolicySource::Global && config.global_policy_version > 0 {
config.global_policy_version
} else {
config.version
};

match output {
"json" => {
let mut obj = serde_json::Map::new();
obj.insert("scope".to_string(), serde_json::json!("sandbox"));
obj.insert("sandbox".to_string(), serde_json::json!(name));
obj.insert("version".to_string(), serde_json::json!(version));
obj.insert("active_version".to_string(), serde_json::json!(version));
obj.insert("hash".to_string(), serde_json::json!(config.policy_hash));
obj.insert("status".to_string(), serde_json::json!("effective"));
obj.insert(
"config_revision".to_string(),
serde_json::json!(config.config_revision),
);
obj.insert(
"policy_source".to_string(),
serde_json::json!(policy_source_label),
);
if config.global_policy_version > 0 {
obj.insert(
"global_policy_version".to_string(),
serde_json::json!(config.global_policy_version),
);
}
if full {
obj.insert(
"policy".to_string(),
openshell_policy::sandbox_policy_to_json_value(policy)?,
);
}
writeln!(
stdout,
"{}",
serde_json::to_string_pretty(&serde_json::Value::Object(obj)).into_diagnostic()?
)
.into_diagnostic()?;
}
"table" => {
writeln!(stdout, "Version: {version}").into_diagnostic()?;
writeln!(stdout, "Hash: {}", config.policy_hash).into_diagnostic()?;
writeln!(stdout, "Status: Effective").into_diagnostic()?;
writeln!(stdout, "Source: {policy_source_label}").into_diagnostic()?;
writeln!(stdout, "Config rev: {}", config.config_revision).into_diagnostic()?;
if config.global_policy_version > 0 {
writeln!(stdout, "Global: {}", config.global_policy_version)
.into_diagnostic()?;
}
if full {
writeln!(stdout, "---").into_diagnostic()?;
let yaml_str = openshell_policy::serialize_sandbox_policy(policy)
.wrap_err("failed to serialize policy to YAML")?;
write!(stdout, "{yaml_str}").into_diagnostic()?;
}
}
_ => return Err(miette!("unsupported output format: {output}")),
}

Ok(())
}

pub async fn sandbox_policy_get_global(
server: &str,
version: u32,
Expand Down
70 changes: 67 additions & 3 deletions crates/openshell-cli/tests/sandbox_name_fallback_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,33 @@ impl OpenShell for TestOpenShell {
let req = request.into_inner();
assert_eq!(
req.sandbox_id, "test-id",
"sandbox_get --policy-only should pass the id from GetSandbox"
"GetSandboxConfig should pass the id from GetSandbox"
);
Ok(Response::new(GetSandboxConfigResponse {
policy: Some(SandboxPolicy {
version: 1,
version: 9,
network_policies: std::iter::once((
"_provider_api".to_string(),
NetworkPolicyRule {
name: "_provider_api".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.provider.example.com".to_string(),
port: 443,
protocol: "rest".to_string(),
enforcement: "enforce".to_string(),
access: "read-only".to_string(),
..Default::default()
}],
..Default::default()
},
))
.collect(),
..Default::default()
}),
version: 9,
policy_hash: "sha256:effective-policy".to_string(),
config_revision: 42,
policy_source: openshell_core::proto::PolicySource::Sandbox.into(),
..Default::default()
}))
}
Expand Down Expand Up @@ -346,7 +366,7 @@ impl OpenShell for TestOpenShell {
) -> Result<Response<GetSandboxPolicyStatusResponse>, Status> {
let req = request.into_inner();
assert_eq!(req.name, "my-sandbox");
assert_eq!(req.version, 0);
assert_eq!(req.version, 3);
assert!(!req.global);

let policy = SandboxPolicy {
Expand Down Expand Up @@ -662,6 +682,50 @@ async fn policy_get_full_json_cli_prints_policy_payload() {
String::from_utf8_lossy(&stderr)
);

let json: serde_json::Value =
serde_json::from_slice(&stdout).expect("stdout should be valid JSON");
assert_eq!(json["scope"], "sandbox");
assert_eq!(json["sandbox"], "my-sandbox");
assert_eq!(json["version"], 9);
assert_eq!(json["active_version"], 9);
assert_eq!(json["hash"], "sha256:effective-policy");
assert_eq!(json["status"], "effective");
assert_eq!(json["config_revision"], 42);
assert_eq!(json["policy_source"], "sandbox");
assert_eq!(
json["policy"]["network_policies"]["_provider_api"]["name"],
"_provider_api"
);
assert_eq!(
json["policy"]["network_policies"]["_provider_api"]["endpoints"][0]["host"],
"api.provider.example.com"
);
}

#[tokio::test]
async fn policy_get_explicit_revision_uses_stored_policy_status() {
let ts = run_server().await;
let mut stdout = Vec::new();
let mut stderr = Vec::new();

run::sandbox_policy_get_to_writer(
&ts.endpoint,
"my-sandbox",
3,
true,
"json",
&ts.tls,
(&mut stdout, &mut stderr),
)
.await
.expect("policy get --rev should succeed");

assert!(
stderr.is_empty(),
"policy get --rev should not print stderr: {}",
String::from_utf8_lossy(&stderr)
);

let json: serde_json::Value =
serde_json::from_slice(&stdout).expect("stdout should be valid JSON");
assert_eq!(json["scope"], "sandbox");
Expand Down
4 changes: 4 additions & 0 deletions crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ use std::sync::OnceLock;

const BUILT_IN_PROFILE_YAMLS: &[&str] = &[
include_str!("../../../providers/claude-code.yaml"),
include_str!("../../../providers/codex.yaml"),
include_str!("../../../providers/copilot.yaml"),
include_str!("../../../providers/cursor.yaml"),
include_str!("../../../providers/github.yaml"),
include_str!("../../../providers/nvidia.yaml"),
include_str!("../../../providers/pypi.yaml"),
];

#[derive(Debug, thiserror::Error)]
Expand Down
6 changes: 3 additions & 3 deletions crates/openshell-sandbox/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use std::os::unix::io::RawFd;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::{Child, Command};
use tracing::{debug, warn};
use tracing::debug;

fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap<String, String>) {
for (key, value) in provider_env {
Expand Down Expand Up @@ -89,7 +89,7 @@ fn check_runtime_pid_limit_status(
if matches!(mode, RuntimePidLimitMode::Require) {
Err(miette::miette!(message))
} else {
warn!("{message}");
tracing::warn!("{message}");
Ok(())
}
}
Expand All @@ -100,7 +100,7 @@ fn check_runtime_pid_limit_status(
if matches!(mode, RuntimePidLimitMode::Require) {
Err(miette::miette!(message))
} else {
warn!("{message}");
tracing::warn!("{message}");
Ok(())
}
}
Expand Down
Loading
Loading