Skip to content

Commit 62c421b

Browse files
authored
feat(providers): add profile-backed policy visibility (#1640)
* chore: wip providers v2 tui and codex profile * chore: wip effective policy get and codex profile * chore: wip provider profiles and tui detail views * feat(tui): annotate policy proposal review status
1 parent 8bf667f commit 62c421b

18 files changed

Lines changed: 1487 additions & 99 deletions

File tree

architecture/security-policy.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,12 @@ protocols remain raw passthrough.
7474

7575
## Live Updates
7676

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

8184
If a new policy fails validation or loading, the supervisor reports the failure
8285
and keeps the last-known-good policy. Static controls, such as filesystem

crates/openshell-cli/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,14 +1621,14 @@ enum PolicyCommands {
16211621
timeout: u64,
16221622
},
16231623

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

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

crates/openshell-cli/src/run.rs

Lines changed: 168 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4716,14 +4716,16 @@ pub async fn provider_list_profiles(server: &str, output: &str, tls: &TlsOptions
47164716
}
47174717

47184718
println!("{}", "Available Provider Profiles:".cyan().bold());
4719+
let id_width = provider_profile_id_width(&profiles);
4720+
let display_width = provider_profile_display_width(&profiles);
47194721
let mut current_category = i32::MIN;
47204722
for profile in profiles {
47214723
if profile.category != current_category {
47224724
current_category = profile.category;
47234725
println!();
47244726
println!(" {}", display_provider_category(current_category).bold());
47254727
}
4726-
print_provider_type_row(&profile);
4728+
print_provider_type_row(&profile, id_width, display_width);
47274729
}
47284730

47294731
Ok(())
@@ -5194,21 +5196,65 @@ fn display_provider_category(category: i32) -> &'static str {
51945196
}
51955197
}
51965198

5197-
fn print_provider_type_row(profile: &ProviderProfile) {
5199+
const PROVIDER_PROFILE_ID_MAX_WIDTH: usize = 32;
5200+
const PROVIDER_PROFILE_DISPLAY_MAX_WIDTH: usize = 40;
5201+
5202+
fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize {
5203+
profiles
5204+
.iter()
5205+
.map(|profile| {
5206+
profile
5207+
.id
5208+
.chars()
5209+
.count()
5210+
.min(PROVIDER_PROFILE_ID_MAX_WIDTH)
5211+
})
5212+
.max()
5213+
.unwrap_or(2)
5214+
.max(2)
5215+
}
5216+
5217+
fn provider_profile_display_width(profiles: &[ProviderProfile]) -> usize {
5218+
profiles
5219+
.iter()
5220+
.map(|profile| {
5221+
profile
5222+
.display_name
5223+
.chars()
5224+
.count()
5225+
.min(PROVIDER_PROFILE_DISPLAY_MAX_WIDTH)
5226+
})
5227+
.max()
5228+
.unwrap_or(4)
5229+
.max(4)
5230+
}
5231+
5232+
fn print_provider_type_row(profile: &ProviderProfile, id_width: usize, display_width: usize) {
51985233
let inference = if profile.inference_capable {
51995234
" inference"
52005235
} else {
52015236
""
52025237
};
5238+
let id = truncate_display(&profile.id, PROVIDER_PROFILE_ID_MAX_WIDTH);
5239+
let display_name = truncate_display(&profile.display_name, PROVIDER_PROFILE_DISPLAY_MAX_WIDTH);
52035240
println!(
5204-
" {:<12} {:<42} endpoints: {:<2}{}",
5205-
profile.id,
5206-
profile.display_name,
5241+
" {id:<id_width$} {display_name:<display_width$} endpoints: {:<2}{}",
52075242
profile.endpoints.len(),
52085243
inference
52095244
);
52105245
}
52115246

5247+
fn truncate_display(value: &str, max_width: usize) -> String {
5248+
if value.chars().count() <= max_width {
5249+
return value.to_string();
5250+
}
5251+
5252+
let keep = max_width.saturating_sub(3);
5253+
let mut truncated = value.chars().take(keep).collect::<String>();
5254+
truncated.push_str("...");
5255+
truncated
5256+
}
5257+
52125258
pub async fn provider_update(
52135259
server: &str,
52145260
name: &str,
@@ -6553,6 +6599,11 @@ where
65536599
W: Write + Send,
65546600
E: Write + Send,
65556601
{
6602+
if version == 0 {
6603+
return sandbox_policy_get_effective_to_writer(server, name, full, output, tls, writers)
6604+
.await;
6605+
}
6606+
65566607
let (stdout, stderr) = writers;
65576608
let mut client = grpc_client(server, tls).await?;
65586609

@@ -6622,6 +6673,118 @@ where
66226673
Ok(())
66236674
}
66246675

6676+
async fn sandbox_policy_get_effective_to_writer<W, E>(
6677+
server: &str,
6678+
name: &str,
6679+
full: bool,
6680+
output: &str,
6681+
tls: &TlsOptions,
6682+
writers: (&mut W, &mut E),
6683+
) -> Result<()>
6684+
where
6685+
W: Write + Send,
6686+
E: Write + Send,
6687+
{
6688+
let (stdout, _stderr) = writers;
6689+
let mut client = grpc_client(server, tls).await?;
6690+
6691+
let sandbox = client
6692+
.get_sandbox(GetSandboxRequest {
6693+
name: name.to_string(),
6694+
})
6695+
.await
6696+
.into_diagnostic()?
6697+
.into_inner()
6698+
.sandbox
6699+
.ok_or_else(|| miette!("sandbox missing from response"))?;
6700+
let sandbox_id = sandbox.object_id();
6701+
if sandbox_id.is_empty() {
6702+
return Err(miette!("sandbox missing metadata"));
6703+
}
6704+
6705+
let config = client
6706+
.get_sandbox_config(GetSandboxConfigRequest {
6707+
sandbox_id: sandbox_id.to_string(),
6708+
})
6709+
.await
6710+
.into_diagnostic()?
6711+
.into_inner();
6712+
let policy = config
6713+
.policy
6714+
.as_ref()
6715+
.ok_or_else(|| miette!("no active policy configured for sandbox '{name}'"))?;
6716+
let policy_source =
6717+
PolicySource::try_from(config.policy_source).unwrap_or(PolicySource::Sandbox);
6718+
let policy_source_label = match policy_source {
6719+
PolicySource::Global => "global",
6720+
PolicySource::Sandbox => "sandbox",
6721+
PolicySource::Unspecified => "unspecified",
6722+
};
6723+
let version = if policy_source == PolicySource::Global && config.global_policy_version > 0 {
6724+
config.global_policy_version
6725+
} else {
6726+
config.version
6727+
};
6728+
6729+
match output {
6730+
"json" => {
6731+
let mut obj = serde_json::Map::new();
6732+
obj.insert("scope".to_string(), serde_json::json!("sandbox"));
6733+
obj.insert("sandbox".to_string(), serde_json::json!(name));
6734+
obj.insert("version".to_string(), serde_json::json!(version));
6735+
obj.insert("active_version".to_string(), serde_json::json!(version));
6736+
obj.insert("hash".to_string(), serde_json::json!(config.policy_hash));
6737+
obj.insert("status".to_string(), serde_json::json!("effective"));
6738+
obj.insert(
6739+
"config_revision".to_string(),
6740+
serde_json::json!(config.config_revision),
6741+
);
6742+
obj.insert(
6743+
"policy_source".to_string(),
6744+
serde_json::json!(policy_source_label),
6745+
);
6746+
if config.global_policy_version > 0 {
6747+
obj.insert(
6748+
"global_policy_version".to_string(),
6749+
serde_json::json!(config.global_policy_version),
6750+
);
6751+
}
6752+
if full {
6753+
obj.insert(
6754+
"policy".to_string(),
6755+
openshell_policy::sandbox_policy_to_json_value(policy)?,
6756+
);
6757+
}
6758+
writeln!(
6759+
stdout,
6760+
"{}",
6761+
serde_json::to_string_pretty(&serde_json::Value::Object(obj)).into_diagnostic()?
6762+
)
6763+
.into_diagnostic()?;
6764+
}
6765+
"table" => {
6766+
writeln!(stdout, "Version: {version}").into_diagnostic()?;
6767+
writeln!(stdout, "Hash: {}", config.policy_hash).into_diagnostic()?;
6768+
writeln!(stdout, "Status: Effective").into_diagnostic()?;
6769+
writeln!(stdout, "Source: {policy_source_label}").into_diagnostic()?;
6770+
writeln!(stdout, "Config rev: {}", config.config_revision).into_diagnostic()?;
6771+
if config.global_policy_version > 0 {
6772+
writeln!(stdout, "Global: {}", config.global_policy_version)
6773+
.into_diagnostic()?;
6774+
}
6775+
if full {
6776+
writeln!(stdout, "---").into_diagnostic()?;
6777+
let yaml_str = openshell_policy::serialize_sandbox_policy(policy)
6778+
.wrap_err("failed to serialize policy to YAML")?;
6779+
write!(stdout, "{yaml_str}").into_diagnostic()?;
6780+
}
6781+
}
6782+
_ => return Err(miette!("unsupported output format: {output}")),
6783+
}
6784+
6785+
Ok(())
6786+
}
6787+
66256788
pub async fn sandbox_policy_get_global(
66266789
server: &str,
66276790
version: u32,

crates/openshell-cli/tests/sandbox_name_fallback_integration.rs

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,33 @@ impl OpenShell for TestOpenShell {
127127
let req = request.into_inner();
128128
assert_eq!(
129129
req.sandbox_id, "test-id",
130-
"sandbox_get --policy-only should pass the id from GetSandbox"
130+
"GetSandboxConfig should pass the id from GetSandbox"
131131
);
132132
Ok(Response::new(GetSandboxConfigResponse {
133133
policy: Some(SandboxPolicy {
134-
version: 1,
134+
version: 9,
135+
network_policies: std::iter::once((
136+
"_provider_api".to_string(),
137+
NetworkPolicyRule {
138+
name: "_provider_api".to_string(),
139+
endpoints: vec![NetworkEndpoint {
140+
host: "api.provider.example.com".to_string(),
141+
port: 443,
142+
protocol: "rest".to_string(),
143+
enforcement: "enforce".to_string(),
144+
access: "read-only".to_string(),
145+
..Default::default()
146+
}],
147+
..Default::default()
148+
},
149+
))
150+
.collect(),
135151
..Default::default()
136152
}),
153+
version: 9,
154+
policy_hash: "sha256:effective-policy".to_string(),
155+
config_revision: 42,
156+
policy_source: openshell_core::proto::PolicySource::Sandbox.into(),
137157
..Default::default()
138158
}))
139159
}
@@ -346,7 +366,7 @@ impl OpenShell for TestOpenShell {
346366
) -> Result<Response<GetSandboxPolicyStatusResponse>, Status> {
347367
let req = request.into_inner();
348368
assert_eq!(req.name, "my-sandbox");
349-
assert_eq!(req.version, 0);
369+
assert_eq!(req.version, 3);
350370
assert!(!req.global);
351371

352372
let policy = SandboxPolicy {
@@ -662,6 +682,50 @@ async fn policy_get_full_json_cli_prints_policy_payload() {
662682
String::from_utf8_lossy(&stderr)
663683
);
664684

685+
let json: serde_json::Value =
686+
serde_json::from_slice(&stdout).expect("stdout should be valid JSON");
687+
assert_eq!(json["scope"], "sandbox");
688+
assert_eq!(json["sandbox"], "my-sandbox");
689+
assert_eq!(json["version"], 9);
690+
assert_eq!(json["active_version"], 9);
691+
assert_eq!(json["hash"], "sha256:effective-policy");
692+
assert_eq!(json["status"], "effective");
693+
assert_eq!(json["config_revision"], 42);
694+
assert_eq!(json["policy_source"], "sandbox");
695+
assert_eq!(
696+
json["policy"]["network_policies"]["_provider_api"]["name"],
697+
"_provider_api"
698+
);
699+
assert_eq!(
700+
json["policy"]["network_policies"]["_provider_api"]["endpoints"][0]["host"],
701+
"api.provider.example.com"
702+
);
703+
}
704+
705+
#[tokio::test]
706+
async fn policy_get_explicit_revision_uses_stored_policy_status() {
707+
let ts = run_server().await;
708+
let mut stdout = Vec::new();
709+
let mut stderr = Vec::new();
710+
711+
run::sandbox_policy_get_to_writer(
712+
&ts.endpoint,
713+
"my-sandbox",
714+
3,
715+
true,
716+
"json",
717+
&ts.tls,
718+
(&mut stdout, &mut stderr),
719+
)
720+
.await
721+
.expect("policy get --rev should succeed");
722+
723+
assert!(
724+
stderr.is_empty(),
725+
"policy get --rev should not print stderr: {}",
726+
String::from_utf8_lossy(&stderr)
727+
);
728+
665729
let json: serde_json::Value =
666730
serde_json::from_slice(&stdout).expect("stdout should be valid JSON");
667731
assert_eq!(json["scope"], "sandbox");

crates/openshell-providers/src/profiles.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ use std::sync::OnceLock;
1818

1919
const BUILT_IN_PROFILE_YAMLS: &[&str] = &[
2020
include_str!("../../../providers/claude-code.yaml"),
21+
include_str!("../../../providers/codex.yaml"),
22+
include_str!("../../../providers/copilot.yaml"),
23+
include_str!("../../../providers/cursor.yaml"),
2124
include_str!("../../../providers/github.yaml"),
2225
include_str!("../../../providers/google-vertex-ai.yaml"),
2326
include_str!("../../../providers/nvidia.yaml"),
27+
include_str!("../../../providers/pypi.yaml"),
2428
];
2529

2630
#[derive(Debug, thiserror::Error)]

crates/openshell-server/src/grpc/provider.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1818,7 +1818,16 @@ mod tests {
18181818
.collect::<Vec<_>>();
18191819
assert_eq!(
18201820
ids,
1821-
vec!["claude-code", "github", "google-vertex-ai", "nvidia",]
1821+
vec![
1822+
"claude-code",
1823+
"codex",
1824+
"copilot",
1825+
"cursor",
1826+
"github",
1827+
"google-vertex-ai",
1828+
"nvidia",
1829+
"pypi"
1830+
]
18221831
);
18231832

18241833
let github = response

0 commit comments

Comments
 (0)