Skip to content
Draft
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
12 changes: 8 additions & 4 deletions .agents/skills/openshell-cli/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,11 @@ Create a sandbox, wait for readiness, then connect or execute the trailing comma

### `openshell sandbox get <name>`

Show sandbox details (id, name, namespace, phase, policy).
Show sandbox details (id, name, namespace, phase) and the **active** policy from the gateway (`GetSandboxConfig`), not necessarily the creation-time spec.

| Flag | Default | Description |
|------|---------|-------------|
| `--policy-only` | false | Print only the active policy as YAML (no sandbox header) |

### `openshell sandbox list`

Expand Down Expand Up @@ -282,12 +286,12 @@ Exit codes with `--wait`: 0 = loaded, 1 = failed, 124 = timeout.

### `openshell policy get <name>`

Show current active policy for a sandbox.
Show the effective runtime policy for a sandbox (or a specific sandbox revision with `--rev`), including **Policy source** (`sandbox` vs `global` when a gateway-global override is active).

| Flag | Default | Description |
|------|---------|-------------|
| `--rev <VERSION>` | 0 (latest) | Show a specific revision |
| `--full` | false | Print the full policy as YAML (round-trips with `--policy` input) |
| `--rev <VERSION>` | 0 (latest) | `0` = effective policy (global wins when set). Non-zero = sandbox-scoped revision for audit. |
| `--full` | false | Print the full policy as YAML after the metadata summary |

### `openshell policy list <name>`

Expand Down
13 changes: 11 additions & 2 deletions architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ openshell policy get <sandbox-name> --rev 3
# Print the full policy as YAML (round-trips with --policy input format)
openshell policy get <sandbox-name> --full

# Print only the active policy YAML (from sandbox get; effective runtime policy)
openshell sandbox get <sandbox-name> --policy-only

# Combine: inspect a specific revision's full policy
openshell policy get <sandbox-name> --rev 2 --full

Expand Down Expand Up @@ -265,10 +268,16 @@ See [Gateway Settings Channel](gateway-settings.md#global-policy-lifecycle) for

| Flag | Default | Description |
|------|---------|-------------|
| `--rev N` | `0` (latest) | Retrieve a specific policy revision by version number instead of the latest. Maps to the `version` field of `GetSandboxPolicyStatusRequest` -- version `0` resolves to the latest revision server-side. |
| `--rev N` | `0` (latest) | With `N = 0`, resolves to the **effective** runtime policy: if a gateway-global policy is active, that global revision is returned; otherwise the latest sandbox-scoped revision. With `N > 0`, returns the **sandbox-scoped** revision `N` from history (audit); the CLI prints a note when a global policy is active because that revision is not the effective runtime policy. Maps to the `version` field of `GetSandboxPolicyStatusRequest`. |
| `--full` | off | Print the complete policy as YAML after the metadata summary. The YAML output uses the same schema as the `--policy` input file, so it round-trips: you can save it to a file and pass it back to `nav policy set --policy`. |

When `--full` is specified, the server includes the deserialized `SandboxPolicy` protobuf in the `SandboxPolicyRevision.policy` field (see `crates/openshell-server/src/grpc.rs` -- `policy_record_to_revision()` with `include_policy: true`). The CLI converts this proto back to YAML via `policy_to_yaml()`, which uses a `BTreeMap` for `network_policies` to produce deterministic key ordering. See `crates/openshell-cli/src/run.rs` -- `policy_to_yaml()`, `policy_get()`.
Metadata includes **Policy source** (`sandbox` or `global`) matching `GetSandboxConfig` / runtime behavior, and **Global revision** when the effective policy is global.

For **YAML only** of the active runtime policy (no revision metadata), use `openshell sandbox get <name> --policy-only` (`GetSandboxConfig.policy`).

When `--full` is specified, the server includes the deserialized `SandboxPolicy` protobuf in the `SandboxPolicyRevision.policy` field (see `crates/openshell-server/src/grpc.rs` -- `policy_record_to_revision()` with `include_policy: true`). The CLI converts this proto back to YAML via `serialize_sandbox_policy()`. See `crates/openshell-cli/src/run.rs` -- `sandbox_policy_get()`.

The CLI loads **Policy source** and **Global revision** from `GetSandboxConfig` (same effective policy metadata as `openshell sandbox settings get`), while revision rows and YAML payloads come from `GetSandboxPolicyStatus`.

See `crates/openshell-cli/src/main.rs` -- `PolicyCommands` enum, `crates/openshell-cli/src/run.rs` -- `policy_set()`, `policy_get()`, `policy_list()`.

Expand Down
8 changes: 6 additions & 2 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1206,6 +1206,10 @@ enum SandboxCommands {
/// Sandbox name (defaults to last-used sandbox).
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option<String>,

/// Print only the active policy as YAML (effective runtime policy from the gateway).
#[arg(long)]
policy_only: bool,
},

/// List sandboxes.
Expand Down Expand Up @@ -2380,9 +2384,9 @@ async fn main() -> Result<()> {
| SandboxCommands::Download { .. } => {
unreachable!()
}
SandboxCommands::Get { name } => {
SandboxCommands::Get { name, policy_only } => {
let name = resolve_sandbox_name(name, &ctx.name)?;
run::sandbox_get(endpoint, &name, &tls).await?;
run::sandbox_get(endpoint, &name, policy_only, &tls).await?;
}
SandboxCommands::List {
limit,
Expand Down
180 changes: 124 additions & 56 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2733,7 +2733,16 @@ pub async fn sandbox_sync_command(
}

/// Fetch a sandbox by name.
pub async fn sandbox_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> {
///
/// When `policy_only` is true, prints only the active (effective) policy YAML from
/// [`GetSandboxConfig`]. Otherwise prints sandbox metadata and the active policy
/// from the gateway (not necessarily the creation-time `spec.policy`).
pub async fn sandbox_get(
server: &str,
name: &str,
policy_only: bool,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;

let response = client
Expand All @@ -2747,18 +2756,36 @@ pub async fn sandbox_get(server: &str, name: &str, tls: &TlsOptions) -> Result<(
.sandbox
.ok_or_else(|| miette::miette!("sandbox missing from response"))?;

let config = client
.get_sandbox_config(GetSandboxConfigRequest {
sandbox_id: sandbox.id.clone(),
})
.await
.into_diagnostic()?
.into_inner();

if policy_only {
let Some(ref policy) = config.policy else {
return Err(miette::miette!(
"no active policy configured for this sandbox"
));
};
let yaml_str = openshell_policy::serialize_sandbox_policy(policy)
.wrap_err("failed to serialize policy to YAML")?;
print!("{yaml_str}");
return Ok(());
}

println!("{}", "Sandbox:".cyan().bold());
println!();
println!(" {} {}", "Id:".dimmed(), sandbox.id);
println!(" {} {}", "Name:".dimmed(), sandbox.name);
println!(" {} {}", "Namespace:".dimmed(), sandbox.namespace);
println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase));

if let Some(spec) = &sandbox.spec
&& let Some(policy) = &spec.policy
{
if let Some(policy) = config.policy {
println!();
print_sandbox_policy(policy);
print_sandbox_policy(&policy);
}

Ok(())
Expand Down Expand Up @@ -4633,34 +4660,74 @@ pub async fn sandbox_policy_get(
.into_diagnostic()?;

let inner = status_resp.into_inner();
if let Some(rev) = inner.revision {
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
println!("Version: {}", rev.version);
println!("Hash: {}", rev.policy_hash);
println!("Status: {status:?}");
println!("Active: {}", inner.active_version);
if rev.created_at_ms > 0 {
println!("Created: {} ms", rev.created_at_ms);
}
if rev.loaded_at_ms > 0 {
println!("Loaded: {} ms", rev.loaded_at_ms);
}
if !rev.load_error.is_empty() {
println!("Error: {}", rev.load_error);
}

if full {
if let Some(ref policy) = rev.policy {
println!("---");
let yaml_str = openshell_policy::serialize_sandbox_policy(policy)
.wrap_err("failed to serialize policy to YAML")?;
print!("{yaml_str}");
} else {
eprintln!("Policy payload not available for this version");
}
}
} else {
let Some(rev) = inner.revision else {
eprintln!("No policy history found for sandbox '{name}'");
return Ok(());
};

let sandbox_msg = client
.get_sandbox(GetSandboxRequest {
name: name.to_string(),
})
.await
.into_diagnostic()?
.into_inner()
.sandbox
.ok_or_else(|| miette::miette!("sandbox not found"))?;

let config = client
.get_sandbox_config(GetSandboxConfigRequest {
sandbox_id: sandbox_msg.id,
})
.await
.into_diagnostic()?
.into_inner();

let global_effective =
config.policy_source == openshell_core::proto::PolicySource::Global as i32;

let policy_source_label = if global_effective {
"global"
} else if config.policy_source == openshell_core::proto::PolicySource::Sandbox as i32 {
"sandbox"
} else {
"unspecified"
};
println!("Policy source: {}", policy_source_label);
if global_effective && config.global_policy_version > 0 {
println!("Global revision: {}", config.global_policy_version);
}
if global_effective && version > 0 {
eprintln!(
"Note: a gateway-global policy is active; sandbox revision {} is not the effective runtime policy.",
version
);
}

let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
println!("Version: {}", rev.version);
println!("Hash: {}", rev.policy_hash);
println!("Status: {status:?}");
println!("Active: {}", inner.active_version);
if rev.created_at_ms > 0 {
println!("Created: {} ms", rev.created_at_ms);
}
if rev.loaded_at_ms > 0 {
println!("Loaded: {} ms", rev.loaded_at_ms);
}
if !rev.load_error.is_empty() {
println!("Error: {}", rev.load_error);
}

if full {
if let Some(ref policy) = rev.policy {
println!("---");
let yaml_str = openshell_policy::serialize_sandbox_policy(policy)
.wrap_err("failed to serialize policy to YAML")?;
print!("{yaml_str}");
} else {
eprintln!("Policy payload not available for this version");
}
}

Ok(())
Expand All @@ -4684,31 +4751,32 @@ pub async fn sandbox_policy_get_global(
.into_diagnostic()?;

let inner = status_resp.into_inner();
if let Some(rev) = inner.revision {
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
println!("Scope: global");
println!("Version: {}", rev.version);
println!("Hash: {}", rev.policy_hash);
println!("Status: {status:?}");
if rev.created_at_ms > 0 {
println!("Created: {} ms", rev.created_at_ms);
}
if rev.loaded_at_ms > 0 {
println!("Loaded: {} ms", rev.loaded_at_ms);
}

if full {
if let Some(ref policy) = rev.policy {
println!("---");
let yaml_str = openshell_policy::serialize_sandbox_policy(policy)
.wrap_err("failed to serialize policy to YAML")?;
print!("{yaml_str}");
} else {
eprintln!("Policy payload not available for this version");
}
}
} else {
let Some(rev) = inner.revision else {
eprintln!("No global policy history found");
return Ok(());
};

let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
println!("Policy source: global");
println!("Version: {}", rev.version);
println!("Hash: {}", rev.policy_hash);
println!("Status: {status:?}");
if rev.created_at_ms > 0 {
println!("Created: {} ms", rev.created_at_ms);
}
if rev.loaded_at_ms > 0 {
println!("Loaded: {} ms", rev.loaded_at_ms);
}

if full {
if let Some(ref policy) = rev.policy {
println!("---");
let yaml_str = openshell_policy::serialize_sandbox_policy(policy)
.wrap_err("failed to serialize policy to YAML")?;
print!("{yaml_str}");
} else {
eprintln!("Policy payload not available for this version");
}
}

Ok(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ async fn run_server() -> TestServer {
async fn sandbox_get_sends_correct_name() {
let ts = run_server().await;

run::sandbox_get(&ts.endpoint, "my-sandbox", &ts.tls)
run::sandbox_get(&ts.endpoint, "my-sandbox", false, &ts.tls)
.await
.expect("sandbox_get should succeed");

Expand Down Expand Up @@ -462,7 +462,7 @@ async fn sandbox_get_with_persisted_last_sandbox() {
assert_eq!(resolved, "persisted-sb");

// Call sandbox_get with the resolved name.
run::sandbox_get(&ts.endpoint, &resolved, &ts.tls)
run::sandbox_get(&ts.endpoint, &resolved, false, &ts.tls)
.await
.expect("sandbox_get should succeed");

Expand All @@ -484,7 +484,7 @@ async fn explicit_name_takes_precedence_over_persisted() {
// Persist one name, but supply a different one explicitly.
save_last_sandbox("my-cluster", "old-sandbox").expect("save should succeed");

run::sandbox_get(&ts.endpoint, "explicit-sandbox", &ts.tls)
run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, &ts.tls)
.await
.expect("sandbox_get should succeed");

Expand Down
Loading
Loading