diff --git a/Cargo.lock b/Cargo.lock index cba681774..1588558c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3581,6 +3581,7 @@ dependencies = [ "miette", "openshell-core", "serde", + "serde_json", "serde_yml", ] diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index ca242be32..1ebf032db 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -663,6 +663,21 @@ impl ProviderProfileOutput { } } +#[derive(Clone, Debug, ValueEnum)] +enum PolicyGetOutput { + Table, + Json, +} + +impl PolicyGetOutput { + fn as_str(&self) -> &'static str { + match self { + Self::Table => "table", + Self::Json => "json", + } + } +} + #[derive(Clone, Debug, ValueEnum)] enum CliEditor { Vscode, @@ -1491,6 +1506,10 @@ enum PolicyCommands { #[arg(long)] full: bool, + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = PolicyGetOutput::Table)] + output: PolicyGetOutput, + /// Show the global policy revision. #[arg(long)] global: bool, @@ -2167,13 +2186,29 @@ async fn main() -> Result<()> { name, rev, full, + output, global, } => { if global { - run::sandbox_policy_get_global(&ctx.endpoint, rev, full, &tls).await?; + run::sandbox_policy_get_global( + &ctx.endpoint, + rev, + full, + output.as_str(), + &tls, + ) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_get(&ctx.endpoint, &name, rev, full, &tls).await?; + run::sandbox_policy_get( + &ctx.endpoint, + &name, + rev, + full, + output.as_str(), + &tls, + ) + .await?; } } PolicyCommands::List { @@ -3557,6 +3592,34 @@ mod tests { } } + #[test] + fn policy_get_json_output_parses() { + let cli = Cli::try_parse_from([ + "openshell", + "policy", + "get", + "my-sandbox", + "--full", + "-o", + "json", + ]) + .expect("policy get -o json should parse"); + + match cli.command { + Some(Commands::Policy { + command: + Some(PolicyCommands::Get { + name, full, output, .. + }), + }) => { + assert_eq!(name.as_deref(), Some("my-sandbox")); + assert!(full); + assert!(matches!(output, PolicyGetOutput::Json)); + } + other => panic!("expected policy get command, got: {other:?}"), + } + } + #[test] fn policy_delete_global_parses() { let cli = Cli::try_parse_from(["openshell", "policy", "delete", "--global", "--yes"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 198cb4b0a..1695521b4 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5653,6 +5653,7 @@ pub async fn sandbox_policy_get( name: &str, version: u32, full: bool, + output: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5669,6 +5670,23 @@ pub async fn sandbox_policy_get( let inner = status_resp.into_inner(); if let Some(rev) = inner.revision { let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified); + match output { + "json" => { + let obj = policy_revision_to_json( + "sandbox", + Some(name), + Some(inner.active_version), + &rev, + status, + full, + )?; + println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?); + return Ok(()); + } + "table" => {} + _ => return Err(miette!("unsupported output format: {output}")), + } + println!("Version: {}", rev.version); println!("Hash: {}", rev.policy_hash); println!("Status: {status:?}"); @@ -5704,6 +5722,7 @@ pub async fn sandbox_policy_get_global( server: &str, version: u32, full: bool, + output: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5720,6 +5739,16 @@ pub async fn sandbox_policy_get_global( let inner = status_resp.into_inner(); if let Some(rev) = inner.revision { let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified); + match output { + "json" => { + let obj = policy_revision_to_json("global", None, None, &rev, status, full)?; + println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?); + return Ok(()); + } + "table" => {} + _ => return Err(miette!("unsupported output format: {output}")), + } + println!("Scope: global"); println!("Version: {}", rev.version); println!("Hash: {}", rev.policy_hash); @@ -5748,6 +5777,66 @@ pub async fn sandbox_policy_get_global( Ok(()) } +fn policy_status_json_name(status: PolicyStatus) -> &'static str { + match status { + PolicyStatus::Unspecified => "unspecified", + PolicyStatus::Pending => "pending", + PolicyStatus::Loaded => "loaded", + PolicyStatus::Failed => "failed", + PolicyStatus::Superseded => "superseded", + } +} + +fn policy_revision_to_json( + scope: &str, + sandbox: Option<&str>, + active_version: Option, + rev: &openshell_core::proto::SandboxPolicyRevision, + status: PolicyStatus, + full: bool, +) -> Result { + let mut obj = serde_json::Map::new(); + obj.insert("scope".to_string(), serde_json::json!(scope)); + if let Some(sandbox) = sandbox { + obj.insert("sandbox".to_string(), serde_json::json!(sandbox)); + } + obj.insert("version".to_string(), serde_json::json!(rev.version)); + obj.insert("hash".to_string(), serde_json::json!(rev.policy_hash)); + obj.insert( + "status".to_string(), + serde_json::json!(policy_status_json_name(status)), + ); + if let Some(active_version) = active_version { + obj.insert( + "active_version".to_string(), + serde_json::json!(active_version), + ); + } + if rev.created_at_ms > 0 { + obj.insert( + "created_at_ms".to_string(), + serde_json::json!(rev.created_at_ms), + ); + } + if rev.loaded_at_ms > 0 { + obj.insert( + "loaded_at_ms".to_string(), + serde_json::json!(rev.loaded_at_ms), + ); + } + if !rev.load_error.is_empty() { + obj.insert("load_error".to_string(), serde_json::json!(rev.load_error)); + } + if full { + let policy = match rev.policy.as_ref() { + Some(policy) => openshell_policy::sandbox_policy_to_json_value(policy)?, + None => serde_json::Value::Null, + }; + obj.insert("policy".to_string(), policy); + } + Ok(serde_json::Value::Object(obj)) +} + pub async fn sandbox_policy_list( server: &str, name: &str, diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index f26136c6b..8936b85be 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -13,6 +13,7 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core" } serde = { workspace = true } +serde_json = { workspace = true } serde_yml = { workspace = true } miette = { workspace = true } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 632170c0b..8dbaf077c 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -558,6 +558,25 @@ pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result { .wrap_err("failed to serialize policy to YAML") } +/// Convert a proto sandbox policy into the canonical policy JSON representation. +/// +/// The shape mirrors the YAML schema used by [`serialize_sandbox_policy`], so +/// automation can use the same documented field names in either format. +pub fn sandbox_policy_to_json_value(policy: &SandboxPolicy) -> Result { + let json_repr = from_proto(policy); + serde_json::to_value(&json_repr) + .into_diagnostic() + .wrap_err("failed to serialize policy to JSON") +} + +/// Serialize a proto sandbox policy to a pretty-printed JSON string. +pub fn serialize_sandbox_policy_json(policy: &SandboxPolicy) -> Result { + let json_repr = sandbox_policy_to_json_value(policy)?; + serde_json::to_string_pretty(&json_repr) + .into_diagnostic() + .wrap_err("failed to serialize policy to JSON") +} + /// Load a sandbox policy from an explicit source. /// /// Resolution order: @@ -881,6 +900,30 @@ mod tests { ); } + /// Verify that JSON serialization uses the same canonical schema keys as YAML. + #[test] + fn serialized_json_uses_policy_schema_keys() { + let proto = parse_sandbox_policy( + r" +version: 1 +network_policies: + github: + endpoints: + - host: api.github.com + port: 443 + protocol: https + binaries: + - path: /usr/bin/curl +", + ) + .expect("parse failed"); + let json = sandbox_policy_to_json_value(&proto).expect("serialize failed"); + + assert_eq!(json["version"], serde_json::json!(1)); + assert!(json.get("filesystem").is_none()); + assert!(json.get("network_policies").is_some()); + } + /// Verify that `allowed_ips` survives the round-trip. #[test] fn round_trip_preserves_allowed_ips() {