From 88da891c8eeaefe2405a1eef1bfa3ba163722acd Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Wed, 15 Apr 2026 00:54:31 +0100 Subject: [PATCH] feat(policy): add --dry-run flag to policy set command Add validation-only mode for policy updates. When --dry-run is set, the gateway runs all validation checks (safety, static field immutability, global lock) and returns a diff of network rule changes without persisting the revision or notifying the sandbox. - Add dry_run field to UpdateConfigRequest proto - Add dry_run, added_network_rules, removed_network_rules to UpdateConfigResponse proto - Add PolicyDiff utility in openshell-core for comparing network rules between two SandboxPolicy protos - Refactor handle_update_config to skip DB writes and sandbox notification when dry_run=true - Add --dry-run flag to CLI policy set (sandbox and global) - Incompatible with --wait since dry-run does not apply the policy - Update architecture and user-facing docs Signed-off-by: JoeVenner --- CLAUDE.md | 118 ++++++++++++++++- architecture/gateway-settings.md | 16 +++ crates/openshell-cli/src/main.rs | 25 +++- crates/openshell-cli/src/run.rs | 91 +++++++++++-- crates/openshell-core/src/lib.rs | 1 + crates/openshell-core/src/policy_diff.rs | 135 ++++++++++++++++++++ crates/openshell-sandbox/src/grpc_client.rs | 1 + crates/openshell-server/src/grpc/policy.rs | 90 +++++++++++-- crates/openshell-tui/src/lib.rs | 4 + docs/sandboxes/policies.mdx | 8 ++ proto/openshell.proto | 11 ++ 11 files changed, 467 insertions(+), 33 deletions(-) create mode 100644 crates/openshell-core/src/policy_diff.rs diff --git a/CLAUDE.md b/CLAUDE.md index eef4bd20cf..9e1b699307 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,117 @@ -@AGENTS.md \ No newline at end of file +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@AGENTS.md + +## Build & Development Commands + +All tasks run through `mise`. Run `mise tasks` to list all available tasks. + +### Essential commands + +| Command | Purpose | +|---------|---------| +| `mise run pre-commit` | Lint, format, license headers, tests — run before every commit | +| `mise run test` | All unit tests (Rust + Python) | +| `mise run ci` | Full local CI: lint + type checks + tests — run before PRs | +| `mise run cluster` | Bootstrap or incremental deploy of the K3s cluster | +| `mise run sandbox` | Create or reconnect to the dev sandbox | +| `mise run e2e` | End-to-end tests (requires a running cluster) | + +### Rust-specific commands + +```bash +cargo test -p openshell-sandbox # tests for a single crate +cargo test -p openshell-sandbox test_name # single test function +cargo test --workspace --exclude openshell-vm # all tests (vm crate excluded by default) +cargo check --workspace # fast compile check +cargo clippy --workspace --all-targets # lint +cargo fmt --all # format +``` + +### Python-specific commands + +```bash +uv run pytest python/ # Python unit tests +uv run ruff check python/ tasks/scripts/*.py deploy/sbom/*.py # lint +uv run ruff format python/ tasks/scripts/*.py deploy/sbom/*.py # format +uv run ty check python/ tasks/scripts/*.py deploy/sbom/*.py # type check +mise run python:proto # regenerate protobuf stubs +``` + +### Fast iteration + +```bash +mise run cluster:deploy:supervisor # docker cp supervisor binary into cluster (skip rebuild) +mise run cluster:deploy:all # push-mode deploy via local registry +mise run term:dev # TUI with hot-reload on file changes +``` + +## Toolchain & Environment + +- **Rust**: edition 2024, MSRV 1.88, managed by mise +- **Python**: 3.13, managed by mise, always use `uv` (not pip) +- **Node**: 24 (for Fern docs tooling) +- **sccache**: enabled via `RUSTC_WRAPPER` in mise.toml — disk cache at `.cache/sccache` +- **Z3**: required by `openshell-prover`. Install via system package (`brew install z3` / `apt install libz3-dev`) or build with `cargo build -p openshell-prover --features bundled-z3` + +## Lint Configuration + +Clippy runs with `pedantic` + `nursery` lints enabled. Many noisy lints are allowed at the workspace level (see `Cargo.toml` `[workspace.lints.clippy]`). Notable allows: `module_name_repetitions`, `must_use_candidate`, `missing_errors_doc`, `too_many_lines`, `needless_pass_by_value`. Don't fight these — if clippy passes, you're fine. + +## DCO Sign-off + +All commits require a `Signed-off-by` line per the Developer Certificate of Origin: + +```bash +git commit -s -m "feat(scope): description" +``` + +## Running the CLI Locally + +`scripts/bin/openshell` is a shortcut script that auto-builds `openshell-cli` if needed and runs the debug binary. Because mise adds `scripts/bin` to `PATH`, you can run `openshell` directly: + +```bash +openshell --help +openshell sandbox create -- claude +``` + +`scripts/bin/` also contains `kubectl` and `k9s` wrappers that run inside the active gateway's K3s container — works for both local and remote gateways. + +## Architecture at a Glance + +All components run inside a single Docker container hosting a K3s Kubernetes cluster: + +- **CLI/TUI/SDK** → gRPC over mTLS (port 30051 NodePort) → **Gateway** (openshell-server, port 8080) +- **Gateway** manages sandbox lifecycle via K8s CRDs, persists state in SQLite, tunnels SSH via HTTP CONNECT upgrade at `/connect/ssh` +- **Sandbox pods** (one per sandbox): privileged supervisor runs SSH server + HTTP CONNECT proxy + OPA engine + inference router; agent process runs under Landlock + seccomp + network namespace isolation +- All agent outbound traffic is forced through the proxy (10.200.0.1:3128) via veth pair — OPA evaluates every connection, TLS is auto-terminated for credential injection and optional L7 inspection +- **Inference routing** happens inside the sandbox (not through the gateway) — gateway provides route config and credentials via gRPC, sandbox executes HTTP requests directly to backends + +See `architecture/system-architecture.md` for the full diagram and component communication flows. + +## Protobuf / gRPC + +Proto definitions live in `proto/`. The Rust codegen runs via `tonic-build` at compile time. Python stubs are generated by `mise run python:proto` and live in `python/openshell/_proto/`. If you modify a `.proto` file, regenerate the Python stubs and fix the import rewrites (handled automatically by the `python:proto` task). + +## Key Crate Interactions + +- `openshell-core` — shared types, config, error handling. Every other crate depends on this. +- `openshell-server` — the gateway. Depends on `openshell-core`, `openshell-policy`, `openshell-ocsf`. Talks to K8s API and SQLite. +- `openshell-sandbox` — the sandbox supervisor. Depends on `openshell-core`, `openshell-policy`, `openshell-ocsf`, `openshell-router`. Talks to gateway via gRPC (`grpc_client.rs`). +- `openshell-policy` — policy types and validation. Used by both server and sandbox. +- `openshell-router` — privacy-aware LLM routing. Embedded in sandbox. +- `openshell-ocsf` — OCSF v1.7.0 structured logging. Used by sandbox and server for security-relevant events. +- `openshell-prover` — Z3-based policy formal verification (separate from runtime enforcement). +- `openshell-cli` — user-facing CLI binary. Depends on `openshell-core`. Talks to gateway via gRPC. +- `openshell-tui` — ratatui terminal dashboard. Polls gateway via gRPC every 2s. +- `openshell-vm` — experimental MicroVM runtime (libkrun). Excluded from default test runs. + +## Testing Notes + +- E2E tests (`mise run e2e`) require a running cluster — `mise run cluster` first. +- Rust e2e tests live in `e2e/rust/` with their own `Cargo.toml`. Run with `cargo test --manifest-path e2e/rust/Cargo.toml --features e2e`. +- Python e2e tests live in `e2e/python/` and use `pytest-xdist` for parallelism (default 5 workers). +- `gateway_resume_scenarios` e2e tests run in a dedicated CI job — skip locally with `--skip gateway_resume_scenarios`. +- `openshell-vm` is excluded from `cargo test --workspace` — test it separately if modifying. \ No newline at end of file diff --git a/architecture/gateway-settings.md b/architecture/gateway-settings.md index ef9538f5b0..605aa778cd 100644 --- a/architecture/gateway-settings.md +++ b/architecture/gateway-settings.md @@ -403,6 +403,22 @@ openshell policy set --global --policy policy.yaml --yes The `--wait` flag is rejected for global policy updates with: `"--wait is not supported for global policies; global policies are effective immediately"`. See `crates/openshell-cli/src/main.rs`. +### `policy set --dry-run` + +Validate a policy update without applying it. Runs all validation checks (safety, static field immutability, global policy lock) and returns a diff of network rule changes, but does not persist the revision or notify the sandbox. + +```bash +openshell policy set demo --policy policy.yaml --dry-run +openshell policy set --global --policy policy.yaml --dry-run +``` + +The response shows: +- The version number and hash the policy would receive +- Added network rules (rules present in the new policy but not the current one) +- Removed network rules (rules present in the current policy but not the new one) + +The `--wait` flag is incompatible with `--dry-run` (dry-run does not apply the policy, so there is nothing to wait for). + ### `policy delete --global [--yes]` Delete the gateway-global policy, restoring sandbox-level policy control. Removes the `policy` key from the `gateway_settings` blob and supersedes all `__global__` revisions. diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 2929224118..63630bb1ff 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1436,6 +1436,11 @@ enum PolicyCommands { /// Timeout for --wait in seconds. #[arg(long, default_value_t = 60)] timeout: u64, + + /// Validate the policy without applying it. Shows validation results + /// and a diff of network rules that would change. + #[arg(long)] + dry_run: bool, }, /// Show current active policy for a sandbox or the global policy. @@ -1965,6 +1970,7 @@ async fn main() -> Result<()> { yes, wait, timeout, + dry_run, } => { if global { if wait { @@ -1973,19 +1979,26 @@ async fn main() -> Result<()> { global policies are effective immediately" )); } - run::sandbox_policy_set_global( + run::sandbox_policy_set_global(&ctx.endpoint, &policy, yes, dry_run, &tls) + .await?; + } else { + if dry_run && wait { + return Err(miette::miette!( + "--wait is not supported with --dry-run; \ + dry-run does not apply the policy" + )); + } + let name = resolve_sandbox_name(name, &ctx.name)?; + run::sandbox_policy_set( &ctx.endpoint, + &name, &policy, - yes, wait, timeout, + dry_run, &tls, ) .await?; - } else { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_set(&ctx.endpoint, &name, &policy, wait, timeout, &tls) - .await?; } } PolicyCommands::Get { diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index c41b53518e..48e0b508ac 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4148,18 +4148,13 @@ pub async fn sandbox_policy_set_global( server: &str, policy_path: &str, yes: bool, - wait: bool, - _timeout_secs: u64, + dry_run: bool, tls: &TlsOptions, ) -> Result<()> { - if wait { - return Err(miette::miette!( - "--wait is only supported for sandbox-scoped policy updates" - )); + if !dry_run { + confirm_global_setting_takeover("policy", yes)?; } - confirm_global_setting_takeover("policy", yes)?; - let policy = load_sandbox_policy(Some(policy_path))? .ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?; @@ -4172,19 +4167,47 @@ pub async fn sandbox_policy_set_global( setting_value: None, delete_setting: false, global: true, + dry_run, }) .await .into_diagnostic()? .into_inner(); + let hash_display = if response.policy_hash.len() >= 12 { + &response.policy_hash[..12] + } else { + &response.policy_hash + }; + + if response.dry_run { + eprintln!( + "{} Dry-run: global policy would be configured (hash: {}, settings revision: {})", + "✓".green().bold(), + hash_display, + response.settings_revision, + ); + if !response.added_network_rules.is_empty() { + eprintln!(" Network rules to add:"); + for rule in &response.added_network_rules { + eprintln!(" + {rule}"); + } + } + if !response.removed_network_rules.is_empty() { + eprintln!(" Network rules to remove:"); + for rule in &response.removed_network_rules { + eprintln!(" - {rule}"); + } + } + if response.added_network_rules.is_empty() && response.removed_network_rules.is_empty() { + eprintln!(" No network rule changes"); + } + return Ok(()); + } + eprintln!( "{} Global policy configured (hash: {}, settings revision: {})", "✓".green().bold(), - if response.policy_hash.len() >= 12 { - &response.policy_hash[..12] - } else { - &response.policy_hash - }, + hash_display, response.settings_revision, ); Ok(()) @@ -4371,6 +4394,7 @@ pub async fn gateway_setting_set( setting_value: Some(setting_value), delete_setting: false, global: true, + dry_run: false, }) .await .into_diagnostic()? @@ -4404,6 +4428,7 @@ pub async fn sandbox_setting_set( setting_value: Some(setting_value), delete_setting: false, global: false, + dry_run: false, }) .await .into_diagnostic()? @@ -4437,6 +4462,7 @@ pub async fn gateway_setting_delete( setting_value: None, delete_setting: true, global: true, + dry_run: false, }) .await .into_diagnostic()? @@ -4470,6 +4496,7 @@ pub async fn sandbox_setting_delete( setting_value: None, delete_setting: true, global: false, + dry_run: false, }) .await .into_diagnostic()? @@ -4500,6 +4527,7 @@ pub async fn sandbox_policy_set( policy_path: &str, wait: bool, timeout_secs: u64, + dry_run: bool, tls: &TlsOptions, ) -> Result<()> { let policy = load_sandbox_policy(Some(policy_path))? @@ -4527,12 +4555,49 @@ pub async fn sandbox_policy_set( setting_value: None, delete_setting: false, global: false, + dry_run, }) .await .into_diagnostic()?; let resp = response.into_inner(); + if resp.dry_run { + if resp.version == current_version { + eprintln!( + "{} Dry-run: policy unchanged (version {}, hash: {})", + "·".dimmed(), + resp.version, + &resp.policy_hash[..12] + ); + return Ok(()); + } + + eprintln!( + "{} Dry-run: policy would become version {} (hash: {})", + "✓".green().bold(), + resp.version, + &resp.policy_hash[..12] + ); + + if !resp.added_network_rules.is_empty() { + eprintln!(" Network rules to add:"); + for rule in &resp.added_network_rules { + eprintln!(" + {rule}"); + } + } + if !resp.removed_network_rules.is_empty() { + eprintln!(" Network rules to remove:"); + for rule in &resp.removed_network_rules { + eprintln!(" - {rule}"); + } + } + if resp.added_network_rules.is_empty() && resp.removed_network_rules.is_empty() { + eprintln!(" No network rule changes"); + } + return Ok(()); + } + if resp.version == current_version { eprintln!( "{} Policy unchanged (version {}, hash: {})", diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index c0b08f1a57..b52055f8ef 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod image; pub mod inference; pub mod net; pub mod paths; +pub mod policy_diff; pub mod proto; pub mod settings; diff --git a/crates/openshell-core/src/policy_diff.rs b/crates/openshell-core/src/policy_diff.rs new file mode 100644 index 0000000000..6550071a2a --- /dev/null +++ b/crates/openshell-core/src/policy_diff.rs @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Policy diff utility for comparing two `SandboxPolicy` protos. +//! +//! Produces a structured diff of network policy rules — the only field +//! that changes at runtime via `openshell policy set`. + +use crate::proto::SandboxPolicy; + +/// Result of comparing two policies. +pub struct PolicyDiff { + /// Network rule names present in the new policy but not in the current policy. + pub added_network_rules: Vec, + /// Network rule names present in the current policy but not in the new policy. + pub removed_network_rules: Vec, +} + +impl PolicyDiff { + /// Compare `current` and `proposed` sandbox policies. + /// + /// Only network policy rules are diffed since filesystem, landlock, and + /// process fields are immutable after sandbox creation and validated + /// separately by `validate_static_fields_unchanged`. + pub fn diff(current: &SandboxPolicy, proposed: &SandboxPolicy) -> Self { + let mut added: Vec = proposed + .network_policies + .keys() + .filter(|k| !current.network_policies.contains_key(*k)) + .cloned() + .collect(); + added.sort(); + + let mut removed: Vec = current + .network_policies + .keys() + .filter(|k| !proposed.network_policies.contains_key(*k)) + .cloned() + .collect(); + removed.sort(); + + Self { + added_network_rules: added, + removed_network_rules: removed, + } + } + + /// Returns true if there are no differences. + pub fn is_empty(&self) -> bool { + self.added_network_rules.is_empty() && self.removed_network_rules.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + + fn make_policy(rule_names: &[&str]) -> SandboxPolicy { + let mut policy = SandboxPolicy::default(); + for name in rule_names { + policy.network_policies.insert( + name.to_string(), + NetworkPolicyRule { + name: name.to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }, + ); + } + policy + } + + #[test] + fn diff_detects_added_rules() { + let current = make_policy(&["rule_a"]); + let proposed = make_policy(&["rule_a", "rule_b", "rule_c"]); + let diff = PolicyDiff::diff(¤t, &proposed); + assert_eq!(diff.added_network_rules, vec!["rule_b", "rule_c"]); + assert!(diff.removed_network_rules.is_empty()); + assert!(!diff.is_empty()); + } + + #[test] + fn diff_detects_removed_rules() { + let current = make_policy(&["rule_a", "rule_b", "rule_c"]); + let proposed = make_policy(&["rule_a"]); + let diff = PolicyDiff::diff(¤t, &proposed); + assert!(diff.added_network_rules.is_empty()); + assert_eq!(diff.removed_network_rules, vec!["rule_b", "rule_c"]); + assert!(!diff.is_empty()); + } + + #[test] + fn diff_detects_added_and_removed() { + let current = make_policy(&["rule_a", "rule_b"]); + let proposed = make_policy(&["rule_b", "rule_c"]); + let diff = PolicyDiff::diff(¤t, &proposed); + assert_eq!(diff.added_network_rules, vec!["rule_c"]); + assert_eq!(diff.removed_network_rules, vec!["rule_a"]); + assert!(!diff.is_empty()); + } + + #[test] + fn diff_no_changes() { + let current = make_policy(&["rule_a", "rule_b"]); + let proposed = make_policy(&["rule_a", "rule_b"]); + let diff = PolicyDiff::diff(¤t, &proposed); + assert!(diff.is_empty()); + } + + #[test] + fn diff_empty_policies() { + let current = SandboxPolicy::default(); + let proposed = SandboxPolicy::default(); + let diff = PolicyDiff::diff(¤t, &proposed); + assert!(diff.is_empty()); + } + + #[test] + fn diff_adding_to_empty() { + let current = SandboxPolicy::default(); + let proposed = make_policy(&["rule_a"]); + let diff = PolicyDiff::diff(¤t, &proposed); + assert_eq!(diff.added_network_rules, vec!["rule_a"]); + assert!(diff.removed_network_rules.is_empty()); + } +} diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index 5503637eec..d072635578 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -133,6 +133,7 @@ async fn sync_policy_with_client( setting_value: None, delete_setting: false, global: false, + dry_run: false, }) .await .into_diagnostic() diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 58d0c03cf6..a28c494c2c 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -12,6 +12,7 @@ use crate::ServerState; use crate::persistence::{DraftChunkRecord, PolicyRecord, Store}; +use openshell_core::policy_diff::PolicyDiff; use openshell_core::proto::setting_value; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, @@ -283,25 +284,51 @@ pub(super) async fn handle_update_config( && current.status == "loaded" { let mut global_settings = load_global_settings(state.store.as_ref()).await?; - let stored_value = StoredSettingValue::Bytes(hex::encode(&payload)); - let changed = upsert_setting_value( - &mut global_settings.settings, - POLICY_SETTING_KEY, - stored_value, - ); - if changed { - global_settings.revision = global_settings.revision.wrapping_add(1); - save_global_settings(state.store.as_ref(), &global_settings).await?; + if !req.dry_run { + let stored_value = StoredSettingValue::Bytes(hex::encode(&payload)); + let changed = upsert_setting_value( + &mut global_settings.settings, + POLICY_SETTING_KEY, + stored_value, + ); + if changed { + global_settings.revision = global_settings.revision.wrapping_add(1); + save_global_settings(state.store.as_ref(), &global_settings).await?; + } } return Ok(Response::new(UpdateConfigResponse { version: u32::try_from(current.version).unwrap_or(0), policy_hash: hash, settings_revision: global_settings.revision, deleted: false, + dry_run: req.dry_run, + added_network_rules: Vec::new(), + removed_network_rules: Vec::new(), + })); + } + + let next_version = latest.as_ref().map_or(1, |r| r.version + 1); + + // Compute network rule diff for global policy updates. + let current_policy = latest + .as_ref() + .and_then(|r| ProtoSandboxPolicy::decode(r.policy_payload.as_slice()).ok()) + .unwrap_or_default(); + let diff = PolicyDiff::diff(¤t_policy, &new_policy); + + if req.dry_run { + let global_settings = load_global_settings(state.store.as_ref()).await?; + return Ok(Response::new(UpdateConfigResponse { + version: u32::try_from(next_version).unwrap_or(0), + policy_hash: hash, + settings_revision: global_settings.revision, + deleted: false, + dry_run: true, + added_network_rules: diff.added_network_rules, + removed_network_rules: diff.removed_network_rules, })); } - let next_version = latest.map_or(1, |r| r.version + 1); let policy_id = uuid::Uuid::new_v4().to_string(); state @@ -353,6 +380,9 @@ pub(super) async fn handle_update_config( policy_hash: hash, settings_revision: global_settings.revision, deleted: false, + dry_run: false, + added_network_rules: diff.added_network_rules, + removed_network_rules: diff.removed_network_rules, })); } @@ -401,6 +431,9 @@ pub(super) async fn handle_update_config( policy_hash: String::new(), settings_revision: global_settings.revision, deleted: req.delete_setting && changed, + dry_run: false, + added_network_rules: Vec::new(), + removed_network_rules: Vec::new(), })); } @@ -457,6 +490,9 @@ pub(super) async fn handle_update_config( policy_hash: String::new(), settings_revision: sandbox_settings.revision, deleted: removed, + dry_run: false, + added_network_rules: Vec::new(), + removed_network_rules: Vec::new(), })); } @@ -490,6 +526,9 @@ pub(super) async fn handle_update_config( policy_hash: String::new(), settings_revision: sandbox_settings.revision, deleted: false, + dry_run: false, + added_network_rules: Vec::new(), + removed_network_rules: Vec::new(), })); } @@ -515,7 +554,7 @@ pub(super) async fn handle_update_config( if let Some(baseline_policy) = spec.policy.as_ref() { validate_static_fields_unchanged(baseline_policy, &new_policy)?; validate_policy_safety(&new_policy)?; - } else { + } else if !req.dry_run { let mut sandbox = sandbox; if let Some(ref mut spec) = sandbox.spec { spec.policy = Some(new_policy.clone()); @@ -537,8 +576,8 @@ pub(super) async fn handle_update_config( .await .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; - let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); + let next_version = latest.as_ref().map_or(1, |r| r.version + 1); if let Some(ref current) = latest && current.policy_hash == hash @@ -548,10 +587,32 @@ pub(super) async fn handle_update_config( policy_hash: hash, settings_revision: 0, deleted: false, + dry_run: req.dry_run, + added_network_rules: Vec::new(), + removed_network_rules: Vec::new(), + })); + } + + // Compute network rule diff for both dry-run and normal responses. + let current_policy = latest + .as_ref() + .and_then(|r| ProtoSandboxPolicy::decode(r.policy_payload.as_slice()).ok()) + .unwrap_or_default(); + let diff = PolicyDiff::diff(¤t_policy, &new_policy); + + if req.dry_run { + return Ok(Response::new(UpdateConfigResponse { + version: u32::try_from(next_version).unwrap_or(0), + policy_hash: hash, + settings_revision: 0, + deleted: false, + dry_run: true, + added_network_rules: diff.added_network_rules, + removed_network_rules: diff.removed_network_rules, })); } - let next_version = latest.map_or(1, |r| r.version + 1); + let payload = new_policy.encode_to_vec(); let policy_id = uuid::Uuid::new_v4().to_string(); state @@ -579,6 +640,9 @@ pub(super) async fn handle_update_config( policy_hash: hash, settings_revision: 0, deleted: false, + dry_run: false, + added_network_rules: diff.added_network_rules, + removed_network_rules: diff.removed_network_rules, })) } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index f187f59fb4..d8ef1e31ea 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1960,6 +1960,7 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { setting_value: Some(SettingValue { value: Some(value) }), delete_setting: false, global: true, + dry_run: false, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -1994,6 +1995,7 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { setting_value: None, delete_setting: true, global: true, + dry_run: false, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2062,6 +2064,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { setting_value: Some(SettingValue { value: Some(value) }), delete_setting: false, global: false, + dry_run: false, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2100,6 +2103,7 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { setting_value: None, delete_setting: true, global: false, + dry_run: false, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 8d4831f1b0..e77d51985d 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -135,6 +135,14 @@ The following steps outline the hot-reload policy update workflow. openshell policy set --policy current-policy.yaml --wait ``` +You can preview the effects of a policy update before applying it: + +```shell +openshell policy set --policy current-policy.yaml --dry-run +``` + +`--dry-run` runs validation checks and shows which network rules would be added or removed, but does not apply the policy or notify the sandbox. The `--wait` flag is incompatible with `--dry-run`. + 6. Verify the new revision. If status is `loaded`, repeat from step 2 as needed; if `failed`, fix the policy and repeat from step 4. ```shell diff --git a/proto/openshell.proto b/proto/openshell.proto index 0ee1e89041..2e2e399e8e 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -574,6 +574,11 @@ message UpdateConfigRequest { bool delete_setting = 5; // Apply mutation at gateway-global scope. bool global = 6; + // Validate the update without persisting or notifying the sandbox. + // When true, the gateway runs all validation checks and returns what + // the response would look like, but does not write to the database + // or trigger a sandbox reload. + bool dry_run = 7; } // Update sandbox policy response. @@ -586,6 +591,12 @@ message UpdateConfigResponse { uint64 settings_revision = 3; // True when a setting delete operation removed an existing key. bool deleted = 4; + // True when this was a dry-run (validation only, no side effects). + bool dry_run = 5; + // Network policy rules added relative to the current policy (dry-run only). + repeated string added_network_rules = 6; + // Network policy rules removed relative to the current policy (dry-run only). + repeated string removed_network_rules = 7; } // Get sandbox policy status request.