diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 48f68ab6..16a8447c 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -90,4 +90,7 @@ jobs:
env:
DOCKER_BUILDER: openshell
OPENSHELL_CARGO_VERSION: ${{ steps.version.outputs.cargo_version }}
+ # Enable dev-settings feature for test settings (dummy_bool, dummy_int)
+ # used by e2e tests.
+ EXTRA_CARGO_FEATURES: openshell-core/dev-settings
run: mise run --no-prepare docker:build:${{ inputs.component }}
diff --git a/architecture/README.md b/architecture/README.md
index d65b9b23..1b3b88c2 100644
--- a/architecture/README.md
+++ b/architecture/README.md
@@ -224,9 +224,11 @@ Sandbox behavior is governed by policies written in YAML and evaluated by an emb
Inference routing to `inference.local` is configured separately at the cluster level and does not require network policy entries. The OPA engine evaluates only explicit network policies; `inference.local` connections bypass OPA entirely and are handled by the proxy's dedicated inference interception path.
-Policies are not intended to be hand-edited by end users in normal operation. They are associated with sandboxes at creation time and fetched by the sandbox supervisor at startup via gRPC. For development and testing, policies can also be loaded from local files.
+Policies are not intended to be hand-edited by end users in normal operation. They are associated with sandboxes at creation time and fetched by the sandbox supervisor at startup via gRPC. For development and testing, policies can also be loaded from local files. A gateway-global policy can override all sandbox policies via `openshell policy set --global`.
-For more detail, see [Policy Language](security-policy.md).
+In addition to policy, the gateway delivers runtime **settings** -- typed key-value pairs (e.g., `log_level`) that can be configured per-sandbox or globally. Settings and policy are delivered together through the `GetSandboxSettings` RPC and tracked by a single `config_revision` fingerprint. See [Gateway Settings Channel](gateway-settings.md) for details.
+
+For more detail on the policy language, see [Policy Language](security-policy.md).
### Command-Line Interface
@@ -234,7 +236,7 @@ The CLI is the primary way users interact with the platform. It provides command
- **Gateway management** (`openshell gateway`): Deploy, stop, destroy, and inspect clusters. Supports both local and remote (SSH) targets.
- **Sandbox management** (`openshell sandbox`): Create sandboxes (with optional file upload and provider auto-discovery), connect to sandboxes via SSH, and delete sandboxes.
-- **Top-level commands**: `openshell status` (cluster health), `openshell logs` (sandbox logs), `openshell forward` (port forwarding), `openshell policy` (sandbox policy management).
+- **Top-level commands**: `openshell status` (cluster health), `openshell logs` (sandbox logs), `openshell forward` (port forwarding), `openshell policy` (sandbox policy management), `openshell settings` (effective sandbox settings and global/sandbox key updates).
- **Provider management** (`openshell provider`): Create, update, list, and delete external service credentials.
- **Inference management** (`openshell cluster inference`): Configure cluster-level inference by specifying a provider and model. The gateway resolves endpoint and credential details from the named provider record.
@@ -297,4 +299,5 @@ This opens an interactive SSH session into the sandbox, with all provider creden
| [Policy Language](security-policy.md) | The YAML/Rego policy system that governs sandbox behavior. |
| [Inference Routing](inference-routing.md) | Transparent interception and sandbox-local routing of AI inference API calls to configured backends. |
| [System Architecture](system-architecture.md) | Top-level system architecture diagram with all deployable components and communication flows. |
+| [Gateway Settings Channel](gateway-settings.md) | Runtime settings channel: two-tier key-value configuration, global policy override, settings registry, CLI/TUI commands. |
| [TUI](tui.md) | Terminal user interface for sandbox interaction. |
diff --git a/architecture/gateway-security.md b/architecture/gateway-security.md
index 14543640..f6598626 100644
--- a/architecture/gateway-security.md
+++ b/architecture/gateway-security.md
@@ -229,7 +229,7 @@ These are used to build a `tonic::transport::ClientTlsConfig` with:
- `identity()` -- presents the shared client certificate for mTLS.
The sandbox calls two RPCs over this authenticated channel:
-- `GetSandboxPolicy` -- fetches the YAML policy that governs the sandbox's behavior.
+- `GetSandboxSettings` -- fetches the YAML policy that governs the sandbox's behavior.
- `GetSandboxProviderEnvironment` -- fetches provider credentials as environment variables.
## SSH Tunnel Authentication
diff --git a/architecture/gateway-settings.md b/architecture/gateway-settings.md
new file mode 100644
index 00000000..ef9538f5
--- /dev/null
+++ b/architecture/gateway-settings.md
@@ -0,0 +1,561 @@
+# Gateway Settings Channel
+
+## Overview
+
+The settings channel provides a two-tier key-value configuration system that the gateway delivers to sandboxes alongside policy. Settings are runtime-mutable name-value pairs (e.g., `log_level`, feature flags) that flow from the gateway to sandboxes through the existing `GetSandboxSettings` poll loop. The system supports two scopes -- sandbox-level and global -- with a deterministic merge strategy and per-key mutual exclusion to prevent conflicting ownership.
+
+## Architecture
+
+```mermaid
+graph TD
+ CLI["CLI / TUI"]
+ GW["Gateway
(openshell-server)"]
+ OBJ["Store: objects table
(gateway_settings,
sandbox_settings blobs)"]
+ POL["Store: sandbox_policies table
(revisions for sandbox-scoped
and __global__ policies)"]
+ SB["Sandbox
(poll loop)"]
+
+ CLI -- "UpdateSettings
(policy / setting_key + value)" --> GW
+ CLI -- "GetSandboxSettings
GetGatewaySettings
ListSandboxPolicies
GetSandboxPolicyStatus" --> GW
+ GW -- "load/save settings blobs
(delivery mechanism)" --> OBJ
+ GW -- "put/list/update
policy revisions
(audit + versioning)" --> POL
+ GW -- "GetSandboxSettingsResponse
(policy + settings +
config_revision +
global_policy_version)" --> SB
+ SB -- "diff settings
reload OPA on policy change" --> SB
+```
+
+## Settings Registry
+
+**File:** `crates/openshell-core/src/settings.rs`
+
+The `REGISTERED_SETTINGS` static array defines the allowed setting keys and their value types. The registry is the source of truth for both client-side validation (CLI, TUI) and server-side enforcement.
+
+```rust
+pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[
+ RegisteredSetting { key: "log_level", kind: SettingValueKind::String },
+ RegisteredSetting { key: "dummy_int", kind: SettingValueKind::Int },
+ RegisteredSetting { key: "dummy_bool", kind: SettingValueKind::Bool },
+];
+```
+
+| Type | Proto variant | Description |
+|------|---------------|-------------|
+| `String` | `SettingValue.string_value` | Arbitrary UTF-8 string |
+| `Int` | `SettingValue.int_value` | 64-bit signed integer |
+| `Bool` | `SettingValue.bool_value` | Boolean; CLI accepts `true/false/yes/no/1/0/on/off` via `parse_bool_like()` |
+
+The reserved key `policy` is excluded from the registry. It is handled by dedicated policy commands and stored as a hex-encoded protobuf `SandboxPolicy` in the global settings' `Bytes` variant. Attempts to set or delete the `policy` key through settings commands are rejected.
+
+Helper functions:
+- `setting_for_key(key)` -- look up a `RegisteredSetting` by name, returns `None` for unknown keys
+- `registered_keys_csv()` -- comma-separated list of valid keys for error messages
+- `parse_bool_like(raw)` -- flexible bool parsing from CLI string input
+
+## Proto Layer
+
+**File:** `proto/sandbox.proto`
+
+### New Message Types
+
+| Message | Fields | Purpose |
+|---------|--------|---------|
+| `SettingValue` | `oneof value { string_value, bool_value, int_value, bytes_value }` | Type-aware setting value |
+| `EffectiveSetting` | `SettingValue value`, `SettingScope scope` | A resolved setting with its controlling scope |
+| `SettingScope` enum | `UNSPECIFIED`, `SANDBOX`, `GLOBAL` | Which tier controls the current value |
+| `PolicySource` enum | `UNSPECIFIED`, `SANDBOX`, `GLOBAL` | Origin of the policy in a settings response |
+
+### New RPCs
+
+**File:** `proto/openshell.proto`
+
+| RPC | Request | Response | Called by |
+|-----|---------|----------|-----------|
+| `GetSandboxSettings` | `GetSandboxSettingsRequest { sandbox_id }` | `GetSandboxSettingsResponse { policy, version, policy_hash, settings, config_revision, policy_source, global_policy_version }` | Sandbox poll loop, CLI `settings get` |
+| `GetGatewaySettings` | `GetGatewaySettingsRequest {}` | `GetGatewaySettingsResponse { settings, settings_revision }` | CLI `settings get --global`, TUI dashboard |
+
+### `UpdateSettingsRequest`
+
+The `UpdateSettings` RPC multiplexes policy and setting mutations through a single request message:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `setting_key` | `string` | Key to mutate (mutually exclusive with `policy` payload) |
+| `setting_value` | `SettingValue` | Value to set (for upsert operations) |
+| `delete_setting` | `bool` | Delete the key from the specified scope |
+| `global` | `bool` | Target gateway-global scope instead of sandbox scope |
+
+Validation rules:
+- `policy` and `setting_key` cannot both be present
+- At least one of `policy` or `setting_key` must be present
+- `delete_setting` cannot be combined with a `policy` payload
+- The reserved `policy` key requires the `policy` field (not `setting_key`) for set operations
+- `name` is required for sandbox-scoped updates but not for global updates
+
+## Server Implementation
+
+**File:** `crates/openshell-server/src/grpc.rs`
+
+### Storage Model
+
+The settings channel uses two storage mechanisms: the `objects` table for settings blobs (fast delivery) and the `sandbox_policies` table for versioned policy revisions (audit/history).
+
+#### Settings blobs (`objects` table)
+
+Settings are persisted using the existing generic `objects` table with two object types:
+
+| Object type string | Record ID | Record name | Purpose |
+|--------------------|-----------|-------------|---------|
+| `gateway_settings` | `"global"` | `"global"` | Singleton global settings (includes reserved `policy` key for delivery) |
+| `sandbox_settings` | `"settings:{sandbox_uuid}"` | sandbox name | Per-sandbox settings |
+
+The sandbox settings ID is prefixed with `settings:` to avoid a primary key collision with the sandbox's own record in the `objects` table. The `sandbox_settings_id()` function computes this key.
+
+The payload is a JSON-encoded `StoredSettings` struct:
+
+```rust
+struct StoredSettings {
+ revision: u64, // Monotonically increasing
+ settings: BTreeMap, // Sorted for determinism
+}
+
+enum StoredSettingValue {
+ String(String),
+ Bool(bool),
+ Int(i64),
+ Bytes(String), // Hex-encoded binary (used for global policy)
+}
+```
+
+#### Policy revisions (`sandbox_policies` table)
+
+Global policy revisions are stored in the `sandbox_policies` table using the sentinel `sandbox_id = "__global__"` (`GLOBAL_POLICY_SANDBOX_ID` constant). This reuses the same schema as sandbox-scoped policy revisions:
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | `TEXT` | UUID primary key |
+| `sandbox_id` | `TEXT` | `"__global__"` for global revisions, sandbox UUID for sandbox-scoped |
+| `version` | `INTEGER` | Monotonically increasing per `sandbox_id` |
+| `policy_payload` | `BLOB` | Protobuf-encoded `SandboxPolicy` |
+| `policy_hash` | `TEXT` | Deterministic SHA-256 hash of the policy |
+| `status` | `TEXT` | `pending`, `loaded`, `failed`, or `superseded` |
+| `load_error` | `TEXT` | Error message (populated on `failed` status) |
+| `created_at_ms` | `INTEGER` | Epoch milliseconds when the revision was created |
+| `loaded_at_ms` | `INTEGER` | Epoch milliseconds when the revision was marked loaded |
+
+The `sandbox_policies` table provides history and audit trail (queried by `policy list --global` and `policy get --global`). The `gateway_settings` blob's `policy` key is the authoritative source that `GetSandboxSettings` reads for fast poll resolution. Both are written on `policy set --global` -- this dual-write is intentional.
+
+### Two-Tier Resolution (`merge_effective_settings`)
+
+The `GetSandboxSettings` handler resolves the effective settings map by merging sandbox and global tiers:
+
+1. **Seed registered keys**: All keys from `REGISTERED_SETTINGS` are inserted with `scope: UNSPECIFIED` and `value: None`. This ensures registered keys always appear in the response even when unset.
+2. **Apply sandbox values**: Sandbox-scoped settings overlay the registered defaults. Scope becomes `SANDBOX`.
+3. **Apply global values**: Global settings override sandbox values. Scope becomes `GLOBAL`.
+4. **Exclude reserved keys**: The `policy` key is excluded from the merged settings map (it is delivered as the top-level `policy` field in the response).
+
+```mermaid
+flowchart LR
+ REG["REGISTERED_SETTINGS
(seed: scope=UNSPECIFIED)"]
+ SB["Sandbox settings
(scope=SANDBOX)"]
+ GL["Global settings
(scope=GLOBAL)"]
+ OUT["Effective settings map"]
+
+ REG --> OUT
+ SB -->|"overlay"| OUT
+ GL -->|"override"| OUT
+```
+
+### Global Policy as a Setting
+
+The reserved `policy` key in global settings stores a hex-encoded protobuf `SandboxPolicy`. When present, `GetSandboxSettings` uses the global policy instead of the sandbox's own policy:
+
+1. `decode_policy_from_global_settings()` checks for the `policy` key in global settings
+2. If present, the global policy replaces the sandbox policy in the response
+3. `policy_source` is set to `GLOBAL`
+4. The sandbox policy version counter is preserved for status APIs
+5. The `global_policy_version` field is populated from the latest `__global__` revision in the `sandbox_policies` table
+
+This allows operators to push a single policy that applies to all sandboxes via `openshell policy set --global --policy FILE`.
+
+### Global Policy Lifecycle
+
+Global policies are versioned through a full revision lifecycle stored alongside sandbox policies. The sentinel `sandbox_id = "__global__"` (constant `GLOBAL_POLICY_SANDBOX_ID`) distinguishes global revisions from sandbox-scoped revisions in the same `sandbox_policies` table.
+
+#### State Machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> NoGlobalPolicy
+
+ NoGlobalPolicy --> v1_Loaded : policy set --global
(creates v1, marks loaded)
+
+ v1_Loaded --> v1_Loaded : policy set --global
(same hash, dedup no-op)
+ v1_Loaded --> v2_Loaded : policy set --global
(different hash)
+ v1_Loaded --> AllSuperseded : policy delete --global
+
+ v2_Loaded --> v2_Loaded : policy set --global
(same hash, dedup no-op)
+ v2_Loaded --> v3_Loaded : policy set --global
(different hash)
+ v2_Loaded --> AllSuperseded : policy delete --global
+
+ v3_Loaded --> v3_Loaded : policy set --global
(same hash, dedup no-op)
+ v3_Loaded --> AllSuperseded : policy delete --global
+
+ AllSuperseded --> NewVersion_Loaded : policy set --global
(any hash, no dedup)
+
+ state "No Global Policy" as NoGlobalPolicy
+ state "v1: Loaded" as v1_Loaded
+ state "v2: Loaded, v1: Superseded" as v2_Loaded
+ state "v3: Loaded, v1-v2: Superseded" as v3_Loaded
+ state "All Revisions Superseded
(no active global policy)" as AllSuperseded
+ state "vN: Loaded, older: Superseded" as NewVersion_Loaded
+```
+
+#### Key behaviors
+
+- **Dedup on set**: When the latest global revision has status `loaded` and its hash matches the submitted policy, no new revision is created. The settings blob is still ensured to have the `policy` key (reconciliation against potential data loss from a pod restart while the `sandbox_policies` table retained the revision). See `crates/openshell-server/src/grpc.rs` -- `update_settings()`, lines around the `current.policy_hash == hash && current.status == "loaded"` check.
+
+- **No dedup against superseded**: If the latest revision has status `superseded` (e.g., after a `policy delete --global`), the same hash creates a new revision. This supports the toggle pattern: delete the global policy, then re-set the same policy. The dedup check explicitly requires `status == "loaded"`.
+
+- **Immediate load**: Global policy revisions are marked `loaded` immediately upon creation (no sandbox confirmation needed). The gateway calls `update_policy_status(GLOBAL_POLICY_SANDBOX_ID, next_version, "loaded", ...)` right after `put_policy_revision()`. Sandboxes pick up changes via the 10-second poll loop.
+
+- **Supersede on set**: When a new global revision is created, `supersede_older_policies(GLOBAL_POLICY_SANDBOX_ID, next_version)` marks all older revisions with `pending` or `loaded` status as `superseded`.
+
+- **Delete supersedes all**: `policy delete --global` removes the `policy` key from the `gateway_settings` blob and calls `supersede_older_policies()` with `latest.version + 1` to mark ALL `__global__` revisions as `superseded`. This restores sandbox-level policy control.
+
+- **Dual-write**: `policy set --global` writes to BOTH the `sandbox_policies` revision table (for audit/listing via `policy list --global`) AND the `gateway_settings` blob (for fast delivery via `GetSandboxSettings`). The revision table provides history; the settings blob is the authoritative source that sandboxes poll.
+
+- **Concurrency**: All global mutations acquire `ServerState.settings_mutex` (a `tokio::sync::Mutex<()>`) for the duration of the read-modify-write cycle. This prevents races between concurrent global policy set/delete operations and global setting mutations.
+
+#### Global policy effects on sandboxes
+
+When a global policy is active (the `policy` key exists in `gateway_settings`):
+
+| Operation | Effect |
+|-----------|--------|
+| `GetSandboxSettings` | Returns the global policy payload instead of the sandbox's own policy. `policy_source = GLOBAL`. `global_policy_version` set to the active revision's version number. |
+| `policy set ` | Rejected with `FailedPrecondition: "policy is managed globally; delete global policy before sandbox policy update"` |
+| `rule approve ` | Rejected with `FailedPrecondition: "cannot approve rules while a global policy is active; delete the global policy to manage per-sandbox rules"` |
+| `rule approve-all` | Rejected with same `FailedPrecondition` as `rule approve` |
+| Revoking an approved chunk (via `rule reject` on an `approved` chunk) | Rejected with same `FailedPrecondition` -- revoking would modify the sandbox policy which is not in use |
+| Rejecting a `pending` chunk | Allowed -- rejection does not modify the sandbox policy |
+| `settings set/delete` at sandbox scope | Allowed -- settings and policy are independent channels |
+| Draft chunk collection | Continues normally -- sandbox proxy still generates proposals. Chunks are visible but cannot be approved. |
+
+The blocking logic is implemented by `require_no_global_policy()` in `crates/openshell-server/src/grpc.rs`, which checks for the `policy` key in global settings and returns `FailedPrecondition` if present.
+
+### `config_revision` and `global_policy_version`
+
+**`config_revision`** (`u64`): Content hash of the merged effective config. Computed by `compute_config_revision()` from three inputs: `policy_source` (as 4 LE bytes), the deterministic policy hash (if policy present), and sorted settings entries (key bytes + scope as 4 LE bytes + type tag byte + value bytes). The SHA-256 digest is truncated to 8 bytes and interpreted as `u64` (little-endian). Changes when the global policy, sandbox policy, settings, or policy source changes. Used by the sandbox poll loop for change detection.
+
+**`global_policy_version`** (`u32`): The version number of the active global policy revision. Populated in `GetSandboxSettingsResponse` when `policy_source == GLOBAL` by looking up the latest revision for `GLOBAL_POLICY_SANDBOX_ID`. Zero when no global policy is active or when `policy_source == SANDBOX`. Displayed in the TUI dashboard and sandbox metadata pane, and logged by the sandbox on reload.
+
+### Per-Key Mutual Exclusion
+
+Global and sandbox scopes cannot both control the same key simultaneously:
+
+| Operation | Global key exists | Behavior |
+|-----------|-------------------|----------|
+| Sandbox set | Yes | `FailedPrecondition`: "setting '{key}' is managed globally; delete the global setting before sandbox update" |
+| Sandbox delete | Yes | `FailedPrecondition`: "setting '{key}' is managed globally; delete the global setting first" |
+| Sandbox set | No | Allowed |
+| Sandbox delete | No | Allowed |
+| Global set | (any) | Always allowed (global overrides) |
+| Global delete | (any) | Allowed; unlocks sandbox control for the key |
+
+This prevents conflicting values at different scopes. An operator must delete a global key before a sandbox-level value can be set for the same key.
+
+### Sandbox-Scoped Policy Update Interaction
+
+When a global policy is set, sandbox-scoped policy updates via `UpdateSettings` are rejected with `FailedPrecondition`:
+
+```
+policy is managed globally; delete global policy before sandbox policy update
+```
+
+Deleting the global policy (`openshell policy delete --global`) removes the `policy` key from global settings and restores sandbox-level policy control.
+
+## Sandbox Implementation
+
+### Poll Loop Changes
+
+**File:** `crates/openshell-sandbox/src/lib.rs` (`run_policy_poll_loop`)
+
+The poll loop uses `GetSandboxSettings` (not a policy-specific RPC) and tracks `config_revision` as the change-detection signal:
+
+1. **Fetch initial state**: Call `poll_settings(sandbox_id)` to establish baseline `current_config_revision`, `current_policy_hash`, and `current_settings`.
+2. **On each tick**: Compare `result.config_revision` against `current_config_revision`. If unchanged, skip.
+3. **Determine what changed**:
+ - Compare `result.policy_hash` against `current_policy_hash` to detect policy changes
+ - Call `log_setting_changes()` to diff the settings map and log individual changes
+4. **Conditional OPA reload**: Only call `opa_engine.reload_from_proto()` when `policy_hash` changes. Settings-only changes update the tracked state without touching the OPA engine.
+5. **Status reporting**: Report policy load status only for sandbox-scoped revisions (`policy_source == SANDBOX` and `version > 0`). Global policy overrides trigger a reload but do not write per-sandbox policy status history.
+
+```mermaid
+sequenceDiagram
+ participant PL as Poll Loop
+ participant GW as Gateway
+ participant OPA as OPA Engine
+
+ PL->>GW: GetSandboxSettings(sandbox_id)
+ GW-->>PL: policy + settings + config_revision
+
+ loop Every interval (default 10s)
+ PL->>GW: GetSandboxSettings(sandbox_id)
+ GW-->>PL: response
+
+ alt config_revision unchanged
+ PL->>PL: Skip
+ else config_revision changed
+ PL->>PL: log_setting_changes(old, new)
+ alt policy_hash changed
+ PL->>OPA: reload_from_proto(policy)
+ PL->>GW: ReportPolicyStatus (if sandbox-scoped)
+ else settings-only change
+ PL->>PL: Update tracked state (no OPA reload)
+ end
+ end
+ end
+```
+
+### Per-Setting Diff Logging
+
+**File:** `crates/openshell-sandbox/src/lib.rs` (`log_setting_changes`)
+
+When `config_revision` changes, the sandbox logs each individual setting change:
+
+- **Changed**: `info!(key, old, new, "Setting changed")` -- logs old and new values
+- **Added**: `info!(key, value, "Setting added")` -- new key not in previous snapshot
+- **Removed**: `info!(key, "Setting removed")` -- key in previous snapshot but not in new
+
+Values are formatted by `format_setting_value()`: strings as-is, bools and ints as their string representation, bytes as ``, unset as ``.
+
+### `SettingsPollResult`
+
+**File:** `crates/openshell-sandbox/src/grpc_client.rs`
+
+```rust
+pub struct SettingsPollResult {
+ pub policy: Option,
+ pub version: u32,
+ pub policy_hash: String,
+ pub config_revision: u64,
+ pub policy_source: PolicySource,
+ pub settings: HashMap,
+ pub global_policy_version: u32,
+}
+```
+
+The `poll_settings()` method maps the full `GetSandboxSettingsResponse` into this struct. The `settings` field carries the effective settings map for diff logging. The `global_policy_version` field is propagated from the response and used for logging when the sandbox reloads a global policy.
+
+## CLI Commands
+
+**File:** `crates/openshell-cli/src/main.rs` (`SettingsCommands`), `crates/openshell-cli/src/run.rs`
+
+### `settings get [name] [--global]`
+
+Display effective settings for a sandbox or the gateway-global scope.
+
+```bash
+# Sandbox-scoped effective settings
+openshell settings get my-sandbox
+
+# Gateway-global settings
+openshell settings get --global
+```
+
+Sandbox output includes: sandbox name, config revision, policy source (sandbox/global), policy hash, and a table of settings with key, value, and scope (sandbox/global/unset).
+
+Global output includes: scope label, settings revision, and a table of settings with key and value. Registered keys without a configured value display as ``.
+
+### `settings set [name] --key K --value V [--global] [--yes]`
+
+Set a single setting key at sandbox or global scope.
+
+```bash
+# Sandbox-scoped
+openshell settings set my-sandbox --key log_level --value debug
+
+# Global (requires confirmation)
+openshell settings set --global --key log_level --value warn
+openshell settings set --global --key dummy_bool --value yes
+openshell settings set --global --key dummy_int --value 42
+
+# Skip confirmation
+openshell settings set --global --key log_level --value info --yes
+```
+
+Value parsing is type-aware: bool keys accept `true/false/yes/no/1/0/on/off` via `parse_bool_like()`. Int keys parse as base-10 `i64`. String keys accept any value.
+
+### `settings delete [name] --key K [--global] [--yes]`
+
+Delete a setting key from the specified scope.
+
+```bash
+# Global delete (unlocks sandbox control)
+openshell settings delete --global --key log_level --yes
+```
+
+### `policy set --global --policy FILE [--yes]`
+
+Set a gateway-global policy that overrides all sandbox policies. Creates a versioned revision in the `sandbox_policies` table and writes the policy to the `gateway_settings` blob for delivery.
+
+```bash
+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 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.
+
+```bash
+openshell policy delete --global --yes
+```
+
+Note: `policy delete` without `--global` is not supported (sandbox policies are managed through versioned updates, not deletion). The CLI returns: `"sandbox policy delete is not supported; use --global to remove global policy lock"`.
+
+### `policy list --global [--limit N]`
+
+List global policy revision history. Uses `ListSandboxPolicies` with `global: true`, which routes to the `__global__` sentinel in the `sandbox_policies` table.
+
+```bash
+openshell policy list --global
+openshell policy list --global --limit 10
+```
+
+### `policy get --global [--rev N] [--full]`
+
+Show a specific global policy revision (or the latest). Uses `GetSandboxPolicyStatus` with `global: true`.
+
+```bash
+# Latest global revision
+openshell policy get --global
+
+# Specific version
+openshell policy get --global --rev 3
+
+# Full policy payload as YAML
+openshell policy get --global --full
+```
+
+### HITL Confirmation
+
+All `--global` mutations require human-in-the-loop confirmation via an interactive prompt. The `--yes` flag bypasses the prompt for scripted/CI usage. In non-interactive mode (no TTY), `--yes` is required -- otherwise the command fails with an error.
+
+The confirmation message varies:
+- **Global setting set**: warns that this will override sandbox-level values for the key
+- **Global setting delete**: warns that this re-enables sandbox-level management
+- **Global policy set**: warns that this overrides all sandbox policies
+- **Global policy delete**: warns that this restores sandbox-level control
+
+## TUI Integration
+
+**File:** `crates/openshell-tui/src/`
+
+### Dashboard: Global Policy Indicator
+
+**File:** `crates/openshell-tui/src/ui/dashboard.rs`
+
+The gateway row in the dashboard shows a yellow `Global Policy Active (vN)` indicator when a global policy is active. The TUI detects this by calling `ListSandboxPolicies` with `global: true, limit: 1` on each polling tick and checking if the latest revision has `PolicyStatus::Loaded`. The version number and active flag are tracked in `App.global_policy_active` and `App.global_policy_version`.
+
+### Dashboard: Global Settings Tab
+
+The dashboard's middle pane has a tabbed interface: **Providers** | **Global Settings**. Press `Tab` to switch.
+
+The Global Settings tab displays registered keys with their current values, fetched via `GetGatewaySettings`. Features:
+
+- **Navigate**: `j`/`k` or arrow keys to select a setting
+- **Edit** (`Enter`): Opens a type-aware editor:
+ - Bool keys: toggle between true/false
+ - String/Int keys: text input field
+- **Delete** (`d`): Remove the selected key's value
+- **Confirmation modals**: Both edit and delete operations show a confirmation dialog before applying
+- **Scope indicators**: Each key shows its current value or ``
+
+### Sandbox Metadata Pane: Global Policy Indicator
+
+**File:** `crates/openshell-tui/src/ui/sandbox_detail.rs`
+
+When the sandbox's policy source is `GLOBAL` (detected via `policy_source` in the `GetSandboxSettings` response), the metadata pane shows `Policy: managed globally (vN)` in yellow. The version comes from `global_policy_version` in the response. Tracked in `App.sandbox_policy_is_global` and `App.sandbox_global_policy_version`.
+
+### Network Rules Pane: Global Policy Warning
+
+**File:** `crates/openshell-tui/src/ui/sandbox_draft.rs`
+
+When `sandbox_policy_is_global` is true, the Network Rules pane displays a yellow bottom title: `" Cannot approve rules while global policy is active "`. Draft chunks are still rendered but their status styles are greyed out (`t.muted`). Keyboard actions for approve (`a`), reject/revoke (`x`), and approve-all are intercepted client-side with status messages like `"Cannot approve rules while a global policy is active"` and `"Cannot modify rules while a global policy is active"`. See `crates/openshell-tui/src/app.rs` -- draft key handling.
+
+### Sandbox Screen: Settings Tab
+
+The sandbox detail view's bottom pane has a tabbed interface: **Policy** | **Settings**. Press `l` to switch tabs.
+
+The Settings tab shows effective settings for the selected sandbox, fetched as part of the `GetSandboxSettings` response. Features:
+
+- Same navigation and editing as the global settings tab
+- **Scope indicators**: Each key shows `(sandbox)`, `(global)`, or `(unset)` to indicate the controlling tier
+- Sandbox-scoped edits are blocked for globally-managed keys (server returns `FailedPrecondition`)
+
+### Data Refresh
+
+Settings are refreshed on each 2-second polling tick alongside the sandbox list and health status. The global settings revision is tracked to detect changes. Sandbox settings are refreshed when viewing a specific sandbox. Global policy active status is detected on each tick via `ListSandboxPolicies` with `global: true`.
+
+## Data Flow: Setting a Global Key
+
+End-to-end trace for `openshell settings set --global --key log_level --value debug --yes`:
+
+1. **CLI** (`crates/openshell-cli/src/run.rs` -- `gateway_setting_set()`):
+ - `parse_cli_setting_value("log_level", "debug")` -- looks up `SettingValueKind::String` in the registry, wraps as `SettingValue { string_value: "debug" }`
+ - `confirm_global_setting_takeover()` -- skipped because `--yes`
+ - Sends `UpdateSettingsRequest { setting_key: "log_level", setting_value: Some(...), global: true }`
+
+2. **Gateway** (`crates/openshell-server/src/grpc.rs` -- `update_settings()`):
+ - Acquires `settings_mutex` for the duration of the operation
+ - Detects `global=true`, `has_setting=true`
+ - `validate_registered_setting_key("log_level")` -- passes (key is in registry)
+ - `load_global_settings()` -- reads `gateway_settings` record from store
+ - `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::String("debug")`
+ - `upsert_setting_value()` -- inserts into `BTreeMap`, returns `true` (changed)
+ - Increments `revision`, calls `save_global_settings()`
+ - Returns `UpdateSettingsResponse { settings_revision: N }`
+
+3. **Sandbox** (next poll tick in `run_policy_poll_loop()`):
+ - `poll_settings(sandbox_id)` returns new `config_revision`
+ - `log_setting_changes()` logs: `Setting changed key="log_level" old="" new="debug"`
+ - `policy_hash` unchanged -- no OPA reload
+ - Updates tracked `current_config_revision` and `current_settings`
+
+## Data Flow: Setting a Global Policy
+
+End-to-end trace for `openshell policy set --global --policy policy.yaml --yes`:
+
+1. **CLI** (`crates/openshell-cli/src/main.rs`, `crates/openshell-cli/src/run.rs` -- `sandbox_policy_set_global()`):
+ - Rejects `--wait` flag with `"--wait is not supported for global policies; global policies are effective immediately"`
+ - Loads and parses the YAML policy file into a `SandboxPolicy` protobuf
+ - Sends `UpdateSettingsRequest { policy: Some(sandbox_policy), global: true }`
+
+2. **Gateway** (`crates/openshell-server/src/grpc.rs` -- `update_settings()`):
+ - Acquires `settings_mutex`
+ - Detects `global=true`, `has_policy=true`
+ - `ensure_sandbox_process_identity()` -- ensures process identity defaults to "sandbox"
+ - `validate_policy_safety()` -- rejects unsafe policies (e.g., root process)
+ - `deterministic_policy_hash()` -- computes SHA-256 hash of the policy
+ - **Dedup check**: Fetches `get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)`
+ - If latest exists with `status == "loaded"` and same hash → no-op (ensures settings blob has `policy` key, returns existing version)
+ - If no latest, or latest is `superseded`, or hash differs → create new revision
+ - `put_policy_revision(id, "__global__", next_version, payload, hash)` -- persists revision
+ - `update_policy_status("__global__", next_version, "loaded")` -- marks loaded immediately
+ - `supersede_older_policies("__global__", next_version)` -- marks all older revisions as superseded
+ - Stores hex-encoded payload in `gateway_settings` blob under `policy` key via `upsert_setting_value()`
+ - Returns `UpdateSettingsResponse { version: N, policy_hash: "..." }`
+
+3. **Sandbox** (next poll tick, ~10 seconds):
+ - `poll_settings(sandbox_id)` returns response with `policy_source: GLOBAL`, `global_policy_version: N`
+ - `config_revision` changed → enters change processing
+ - `policy_hash` changed → calls `opa_engine.reload_from_proto(global_policy)`
+ - Logs `"Policy reloaded successfully (global)"` with `global_version=N`
+ - Does NOT call `ReportPolicyStatus` (global policies skip per-sandbox status reporting)
+
+## Cross-References
+
+- [Gateway Architecture](gateway.md) -- Persistence layer, gRPC service, object types
+- [Sandbox Architecture](sandbox.md) -- Poll loop, `CachedOpenShellClient`, OPA reload lifecycle
+- [Policy Language](security-policy.md) -- Live policy updates, global policy CLI commands
+- [TUI](tui.md) -- Settings tabs in dashboard and sandbox views
diff --git a/architecture/gateway.md b/architecture/gateway.md
index ca541c7b..39f97c8c 100644
--- a/architecture/gateway.md
+++ b/architecture/gateway.md
@@ -82,7 +82,7 @@ Proto definitions consumed by the gateway:
| `proto/openshell.proto` | `openshell.v1` | `OpenShell` service, sandbox/provider/SSH/watch messages |
| `proto/inference.proto` | `openshell.inference.v1` | `Inference` service: `SetClusterInference`, `GetClusterInference`, `GetInferenceBundle` |
| `proto/datamodel.proto` | `openshell.datamodel.v1` | `Sandbox`, `SandboxSpec`, `SandboxStatus`, `Provider`, `SandboxPhase` |
-| `proto/sandbox.proto` | `openshell.sandbox.v1` | `SandboxPolicy`, `NetworkPolicyRule` |
+| `proto/sandbox.proto` | `openshell.sandbox.v1` | `SandboxPolicy`, `NetworkPolicyRule`, `SettingValue`, `EffectiveSetting`, `SettingScope`, `PolicySource`, `GetSandboxSettingsRequest/Response`, `GetGatewaySettingsRequest/Response` |
## Startup Sequence
@@ -141,6 +141,9 @@ pub struct ServerState {
pub sandbox_index: SandboxIndex,
pub sandbox_watch_bus: SandboxWatchBus,
pub tracing_log_bus: TracingLogBus,
+ pub ssh_connections_by_token: Mutex>,
+ pub ssh_connections_by_sandbox: Mutex>,
+ pub settings_mutex: tokio::sync::Mutex<()>,
}
```
@@ -149,6 +152,7 @@ pub struct ServerState {
- **`sandbox_index`** -- in-memory bidirectional index mapping sandbox names and agent pod names to sandbox IDs. Used by the event tailer to correlate Kubernetes events.
- **`sandbox_watch_bus`** -- `broadcast`-based notification bus keyed by sandbox ID. Producers call `notify(&id)` when the persisted sandbox record changes; consumers in `WatchSandbox` streams receive `()` signals and re-read the record.
- **`tracing_log_bus`** -- captures `tracing` events that include a `sandbox_id` field and republishes them as `SandboxLogLine` messages. Maintains a per-sandbox tail buffer (default 200 entries). Also contains a nested `PlatformEventBus` for Kubernetes events.
+- **`settings_mutex`** -- serializes settings mutations (global and sandbox) to prevent read-modify-write races. Held for the duration of any setting set/delete or global policy set/delete operation. See [Gateway Settings Channel](gateway-settings.md#global-policy-lifecycle).
## Protocol Multiplexing
@@ -225,13 +229,14 @@ Full CRUD for `Provider` objects, which store typed credentials (e.g., API keys
| `UpdateProvider` | Updates an existing provider by name. Preserves the stored `id` and `name`; replaces `type`, `credentials`, and `config`. |
| `DeleteProvider` | Deletes a provider by name. Returns `deleted: true/false`. |
-#### Policy and Provider Environment Delivery
+#### Policy, Settings, and Provider Environment Delivery
-These RPCs are called by sandbox pods at startup to bootstrap themselves.
+These RPCs are called by sandbox pods at startup and during runtime polling.
| RPC | Description |
|-----|-------------|
-| `GetSandboxPolicy` | Returns the `SandboxPolicy` from a sandbox's spec, looked up by sandbox ID. |
+| `GetSandboxSettings` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata (version, hash, source, `global_policy_version`), merged effective settings, and a `config_revision` fingerprint for change detection. Two-tier resolution: registered keys start unset, sandbox values overlay, global values override. The reserved `policy` key in global settings can override the sandbox's own policy. When a global policy is active, `policy_source` is `GLOBAL` and `global_policy_version` carries the active revision number. See [Gateway Settings Channel](gateway-settings.md). |
+| `GetGatewaySettings` | Returns gateway-global settings only (excluding the reserved `policy` key). Returns registered keys with empty values when unconfigured, and a monotonic `settings_revision`. |
| `GetSandboxProviderEnvironment` | Resolves provider credentials into environment variables for a sandbox. Iterates the sandbox's `spec.providers` list, fetches each `Provider`, and collects credential key-value pairs. First provider wins on duplicate keys. Skips credential keys that do not match `^[A-Za-z_][A-Za-z0-9_]*$`. |
#### Policy Recommendation (Network Rules)
@@ -242,9 +247,9 @@ These RPCs support the sandbox-initiated policy recommendation pipeline. The san
|-----|-------------|
| `SubmitPolicyAnalysis` | Receives pre-formed `PolicyChunk` proposals from a sandbox. Validates each chunk, persists via upsert on `(sandbox_id, host, port, binary)` dedup key, notifies watch bus. |
| `GetDraftPolicy` | Returns all draft chunks for a sandbox with current draft version. |
-| `ApproveDraftChunk` | Approves a pending or rejected chunk. Merges the proposed rule into the active policy (appends binary to existing rule or inserts new rule). |
-| `RejectDraftChunk` | Rejects a pending chunk or revokes an approved chunk. If revoking, removes the binary from the active policy rule. |
-| `ApproveAllDraftChunks` | Bulk approves all pending chunks for a sandbox. |
+| `ApproveDraftChunk` | Approves a pending or rejected chunk. Merges the proposed rule into the active policy (appends binary to existing rule or inserts new rule). **Blocked when a global policy is active** -- returns `FailedPrecondition`. |
+| `RejectDraftChunk` | Rejects a pending chunk or revokes an approved chunk. If revoking, removes the binary from the active policy rule. Rejection of `pending` chunks is always allowed. **Revoking approved chunks is blocked when a global policy is active** -- returns `FailedPrecondition`. |
+| `ApproveAllDraftChunks` | Bulk approves all pending chunks for a sandbox. **Blocked when a global policy is active** -- returns `FailedPrecondition`. |
| `EditDraftChunk` | Updates the proposed rule on a pending chunk. |
| `GetDraftHistory` | Returns all chunks (including rejected) for audit trail. |
@@ -457,12 +462,16 @@ Objects are identified by `(object_type, id)` with a unique constraint on `(obje
### Object Types
-| Object type string | Proto message | Traits implemented |
-|--------------------|---------------|-------------------|
-| `"sandbox"` | `Sandbox` | `ObjectType`, `ObjectId`, `ObjectName` |
-| `"provider"` | `Provider` | `ObjectType`, `ObjectId`, `ObjectName` |
-| `"ssh_session"` | `SshSession` | `ObjectType`, `ObjectId`, `ObjectName` |
-| `"inference_route"` | `InferenceRoute` | `ObjectType`, `ObjectId`, `ObjectName` |
+| Object type string | Proto message / format | Traits implemented | Notes |
+|--------------------|------------------------|-------------------|-------|
+| `"sandbox"` | `Sandbox` | `ObjectType`, `ObjectId`, `ObjectName` | |
+| `"provider"` | `Provider` | `ObjectType`, `ObjectId`, `ObjectName` | |
+| `"ssh_session"` | `SshSession` | `ObjectType`, `ObjectId`, `ObjectName` | |
+| `"inference_route"` | `InferenceRoute` | `ObjectType`, `ObjectId`, `ObjectName` | |
+| `"gateway_settings"` | JSON `StoredSettings` | Generic `put`/`get` | Singleton, id=`"global"`. Contains the reserved `policy` key for global policy delivery. |
+| `"sandbox_settings"` | JSON `StoredSettings` | Generic `put`/`get` | Per-sandbox, id=`"settings:{sandbox_uuid}"` |
+
+The `sandbox_policies` table stores versioned policy revisions for both sandbox-scoped and global policies. Global revisions use the sentinel `sandbox_id = "__global__"`. See [Gateway Settings Channel](gateway-settings.md#storage-model) for schema details.
### Generic Protobuf Codec
@@ -559,6 +568,7 @@ Updated by the sandbox watcher on every Applied event and by gRPC handlers durin
## Cross-References
- [Sandbox Architecture](sandbox.md) -- sandbox-side policy enforcement, proxy, and isolation details
+- [Gateway Settings Channel](gateway-settings.md) -- runtime settings channel, two-tier resolution, CLI/TUI commands
- [Inference Routing](inference-routing.md) -- end-to-end inference interception flow, sandbox-side proxy logic, and route resolution
- [Container Management](build-containers.md) -- how sandbox container images are built and configured
- [Sandbox Connect](sandbox-connect.md) -- client-side SSH connection flow
diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md
index dca36c59..16b7948b 100644
--- a/architecture/sandbox-providers.md
+++ b/architecture/sandbox-providers.md
@@ -241,7 +241,7 @@ variables (injected into the pod spec by the gateway's Kubernetes sandbox creati
In `run_sandbox()` (`crates/openshell-sandbox/src/lib.rs`):
-1. loads the sandbox policy via gRPC (`GetSandboxPolicy`),
+1. loads the sandbox policy via gRPC (`GetSandboxSettings`),
2. fetches provider credentials via gRPC (`GetSandboxProviderEnvironment`),
3. if the fetch fails, continues with an empty map (graceful degradation with a warning).
diff --git a/architecture/sandbox.md b/architecture/sandbox.md
index e5c831a8..a9d80ac8 100644
--- a/architecture/sandbox.md
+++ b/architecture/sandbox.md
@@ -315,31 +315,38 @@ The gateway's `UpdateSandboxPolicy` RPC enforces this boundary: it rejects any u
### Poll loop
+The poll loop tracks `config_revision` (a fingerprint of policy + settings + source) as the primary change-detection signal. It separately tracks `policy_hash` to determine whether an OPA reload is needed -- settings-only changes do not trigger OPA reloads.
+
```mermaid
sequenceDiagram
- participant PL as Policy Poll Loop
+ participant PL as Settings Poll Loop
participant GW as Gateway (gRPC)
participant OPA as OPA Engine (Arc)
- PL->>GW: GetSandboxPolicy(sandbox_id)
- GW-->>PL: policy + version + hash
- PL->>PL: Store initial version
+ PL->>GW: GetSandboxSettings(sandbox_id)
+ GW-->>PL: policy + settings + config_revision
+ PL->>PL: Store initial config_revision, policy_hash, settings
loop Every OPENSHELL_POLICY_POLL_INTERVAL_SECS (default 10)
- PL->>GW: GetSandboxPolicy(sandbox_id)
- GW-->>PL: policy + version + hash
- alt version > current_version
- PL->>OPA: reload_from_proto(policy)
- alt Reload succeeds
- OPA-->>PL: Ok
- PL->>PL: Update current_version
- PL->>GW: ReportPolicyStatus(version, LOADED)
- else Reload fails (validation error)
- OPA-->>PL: Err (old engine untouched)
- PL->>GW: ReportPolicyStatus(version, FAILED, error_msg)
+ PL->>GW: GetSandboxSettings(sandbox_id)
+ GW-->>PL: policy + settings + config_revision
+ alt config_revision unchanged
+ PL->>PL: Skip
+ else config_revision changed
+ PL->>PL: log_setting_changes(old_settings, new_settings)
+ alt policy_hash changed
+ PL->>OPA: reload_from_proto(policy)
+ alt Reload succeeds
+ OPA-->>PL: Ok
+ PL->>PL: Update tracked state
+ PL->>GW: ReportPolicyStatus(version, LOADED)
+ else Reload fails (validation error)
+ OPA-->>PL: Err (old engine untouched)
+ PL->>GW: ReportPolicyStatus(version, FAILED, error_msg)
+ end
+ else settings-only change
+ PL->>PL: Update tracked state (no OPA reload)
end
- else version <= current_version
- PL->>PL: Skip (no update)
end
end
```
@@ -347,11 +354,14 @@ sequenceDiagram
The `run_policy_poll_loop()` function in `crates/openshell-sandbox/src/lib.rs` implements this loop:
1. **Connect once**: Create a `CachedOpenShellClient` that holds a persistent mTLS channel to the gateway. This avoids TLS renegotiation on every poll.
-2. **Fetch initial version**: Call `poll_policy(sandbox_id)` to establish the baseline `current_version`. On failure, log a warning and retry on the next interval.
-3. **Poll loop**: Sleep for the configured interval, then call `poll_policy()` again.
-4. **Version comparison**: If `result.version <= current_version`, skip. The version is a monotonically increasing `u32` per sandbox.
-5. **Reload attempt**: Call `opa_engine.reload_from_proto(&result.policy)`. This runs the full `from_proto()` pipeline on the new policy, then atomically swaps the inner engine.
-6. **Status reporting**: On success, report `PolicyStatus::Loaded` to the gateway via `ReportPolicyStatus` RPC. On failure, report `PolicyStatus::Failed` with the error message. Status report failures are logged but do not affect the poll loop.
+2. **Fetch initial state**: Call `poll_settings(sandbox_id)` to establish baseline `current_config_revision`, `current_policy_hash`, and `current_settings` map. On failure, log a warning and retry on the next interval.
+3. **Poll loop**: Sleep for the configured interval, then call `poll_settings()` again.
+4. **Config comparison**: If `result.config_revision == current_config_revision`, skip.
+5. **Per-setting diff logging**: Call `log_setting_changes()` to diff old and new settings maps. Each individual change is logged with old and new values.
+6. **Conditional OPA reload**: Only call `opa_engine.reload_from_proto(policy)` when `policy_hash` changes. Settings-only changes (e.g., `log_level` updated) update the tracked state without touching the OPA engine.
+7. **Status reporting**: On success/failure, report status only for sandbox-scoped policy revisions (`policy_source = SANDBOX`, `version > 0`). Global policy overrides still trigger OPA reload, but they do not write per-sandbox policy status history.
+8. **Global policy logging**: When `global_policy_version > 0`, the sandbox logs `"Policy reloaded successfully (global)"` with the `global_version` field. This distinguishes global reloads from sandbox-scoped reloads in the log stream.
+9. **Update tracked state**: After processing, update `current_config_revision`, `current_policy_hash`, and `current_settings` regardless of whether OPA was reloaded.
### `CachedOpenShellClient`
@@ -364,29 +374,40 @@ pub struct CachedOpenShellClient {
client: OpenShellClient,
}
-pub struct PolicyPollResult {
- pub policy: ProtoSandboxPolicy,
+pub struct SettingsPollResult {
+ pub policy: Option,
pub version: u32,
pub policy_hash: String,
+ pub config_revision: u64,
+ pub policy_source: PolicySource,
+ pub settings: HashMap,
+ pub global_policy_version: u32,
}
```
Methods:
- **`connect(endpoint)`**: Establish an mTLS channel and return a new client.
-- **`poll_policy(sandbox_id)`**: Call `GetSandboxPolicy` RPC and return a `PolicyPollResult` containing the policy, version, and hash.
+- **`poll_settings(sandbox_id)`**: Call `GetSandboxSettings` RPC and return a `SettingsPollResult` containing policy payload (optional), policy metadata, effective config revision, policy source, global policy version, and the effective settings map (for diff logging).
- **`report_policy_status(sandbox_id, version, loaded, error_msg)`**: Call `ReportPolicyStatus` RPC with the appropriate `PolicyStatus` enum value (`Loaded` or `Failed`).
- **`raw_client()`**: Return a clone of the underlying `OpenShellClient` for direct RPC calls (used by the log push task).
### Server-side policy versioning
-The gateway assigns a monotonically increasing version number to each policy revision per sandbox. The `GetSandboxPolicyResponse` includes `version` and `policy_hash` fields. The `ReportPolicyStatus` RPC records which version the sandbox successfully loaded (or failed to load), enabling operators to query `GetSandboxPolicyStatus` for the current active version and load history.
+The gateway assigns a monotonically increasing version number to each sandbox policy revision. `GetSandboxSettingsResponse` carries the full effective configuration: policy payload, effective settings map (with per-key scope indicators), a `config_revision` fingerprint that changes when any effective input changes (policy, settings, or source), and a `policy_source` field indicating whether the policy came from the sandbox's own history or from a global override.
Proto messages involved:
-- `GetSandboxPolicyResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash`
+- `GetSandboxSettingsResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash`, `settings` (map of `EffectiveSetting`), `config_revision`, `policy_source`, `global_policy_version`
+- `EffectiveSetting` (`proto/sandbox.proto`): `SettingValue value`, `SettingScope scope`
+- `SettingScope` enum: `UNSPECIFIED`, `SANDBOX`, `GLOBAL`
+- `PolicySource` enum: `UNSPECIFIED`, `SANDBOX`, `GLOBAL`
- `ReportPolicyStatusRequest` (`proto/openshell.proto`): `sandbox_id`, `version`, `status` (enum), `load_error`
- `PolicyStatus` enum: `PENDING`, `LOADED`, `FAILED`, `SUPERSEDED`
- `SandboxPolicyRevision` (`proto/openshell.proto`): Full revision metadata including `created_at_ms`, `loaded_at_ms`
+The `global_policy_version` field is zero when no global policy is active or when `policy_source` is `SANDBOX`. When `policy_source` is `GLOBAL`, it carries the version number of the active global revision. The sandbox logs this value on reload (`"Policy reloaded successfully (global)" global_version=N`) and the TUI displays it in the dashboard and sandbox metadata pane.
+
+See [Gateway Settings Channel](gateway-settings.md) for full details on the settings resolution model, storage, and CLI/TUI commands.
+
### Failure modes
| Condition | Behavior |
diff --git a/architecture/security-policy.md b/architecture/security-policy.md
index 4be33589..b63179c4 100644
--- a/architecture/security-policy.md
+++ b/architecture/security-policy.md
@@ -112,9 +112,9 @@ sequenceDiagram
GW-->>CLI: UpdateSandboxPolicyResponse(version=N, hash)
loop Every 30s (configurable)
- SB->>GW: GetSandboxPolicy(sandbox_id)
+ SB->>GW: GetSandboxSettings(sandbox_id)
GW->>DB: get_latest_policy(sandbox_id)
- GW-->>SB: GetSandboxPolicyResponse(policy, version=N, hash)
+ GW-->>SB: GetSandboxSettingsResponse(policy, version=N, hash)
end
Note over SB: Detects version > current_version
@@ -140,7 +140,7 @@ sequenceDiagram
Each sandbox maintains an independent, monotonically increasing version counter for its policy revisions:
-- **Version 1** is the policy from the sandbox's `spec.policy` at creation time. It is backfilled lazily on the first `GetSandboxPolicy` call if no explicit revision exists in the policy history table. See `crates/openshell-server/src/grpc.rs` -- `get_sandbox_policy()`.
+- **Version 1** is the policy from the sandbox's `spec.policy` at creation time. It is backfilled lazily on the first `GetSandboxSettings` call if no explicit revision exists in the policy history table. See `crates/openshell-server/src/grpc.rs` -- `get_sandbox_settings()`.
- Each `UpdateSandboxPolicy` call computes the next version as `latest_version + 1` and persists a new `PolicyRecord` with status `"pending"`.
- When a new version is persisted, all older revisions still in `"pending"` status are marked `"superseded"` via `supersede_pending_policies()`. This handles rapid successive updates where the sandbox has not yet picked up an intermediate version.
- The `Sandbox` protobuf object carries a `current_policy_version` field (see `proto/datamodel.proto`) that is updated when the sandbox reports a successful load.
@@ -182,7 +182,7 @@ In gRPC mode, the sandbox spawns a background task that periodically polls the g
The poll loop:
1. Connects a reusable gRPC client (`CachedOpenShellClient`) to avoid per-poll TLS handshake overhead.
-2. Fetches the current policy via `GetSandboxPolicy`, which returns the latest version, its policy payload, and a SHA-256 hash.
+2. Fetches the current policy via `GetSandboxSettings`, which returns the latest version, its policy payload, and a SHA-256 hash.
3. Compares the returned version against the locally tracked `current_version`. If the server version is not greater, the loop sleeps and retries.
4. On a new version, calls `OpaEngine::reload_from_proto()` which builds a complete new `regorus::Engine` through the same validated pipeline as the initial load (proto-to-JSON conversion, L7 validation, access preset expansion).
5. If the new engine builds successfully, it atomically replaces the inner `Mutex`. If it fails, the previous engine is untouched.
@@ -206,34 +206,61 @@ Failure scenarios that trigger LKG behavior include:
### CLI Commands
-The `nav policy` subcommand group manages live policy updates:
+The `openshell policy` subcommand group manages live policy updates:
```bash
# Push a new policy to a running sandbox
-nav policy set --policy updated-policy.yaml
+openshell policy set --policy updated-policy.yaml
# Push and wait for the sandbox to load it (with 60s timeout)
-nav policy set --policy updated-policy.yaml --wait
+openshell policy set --policy updated-policy.yaml --wait
# Push and wait with a custom timeout
-nav policy set --policy updated-policy.yaml --wait --timeout 120
+openshell policy set --policy updated-policy.yaml --wait --timeout 120
+
+# Set a gateway-global policy (overrides all sandbox policies)
+openshell policy set --global --policy policy.yaml --yes
+
+# Delete the gateway-global policy (restores sandbox-level control)
+openshell policy delete --global --yes
# View the current active policy and its status
-nav policy get
+openshell policy get
# Inspect a specific revision
-nav policy get --rev 3
+openshell policy get --rev 3
# Print the full policy as YAML (round-trips with --policy input format)
-nav policy get --full
+openshell policy get --full
# Combine: inspect a specific revision's full policy
-nav policy get --rev 2 --full
+openshell policy get --rev 2 --full
# List policy revision history
-nav policy list --limit 20
+openshell policy list --limit 20
```
+#### Global Policy
+
+The `--global` flag on `policy set`, `policy delete`, `policy list`, and `policy get` manages a gateway-wide policy override. When a global policy is set, all sandboxes receive it through `GetSandboxSettings` (with `policy_source: GLOBAL`) instead of their own per-sandbox policy. Global policies are versioned through the `sandbox_policies` table using the sentinel `sandbox_id = "__global__"` and delivered to sandboxes via the reserved `policy` key in the `gateway_settings` blob.
+
+| Command | Behavior |
+|---------|----------|
+| `policy set --global --policy FILE` | Creates a versioned revision (marked `loaded` immediately) and stores the policy in the global settings blob. Sandboxes pick it up on their next poll (~10s). Deduplicates against the latest `loaded` revision by hash. |
+| `policy delete --global` | Removes the `policy` key from global settings and supersedes all `__global__` revisions. Sandboxes revert to their per-sandbox policy on the next poll. |
+| `policy list --global [--limit N]` | Lists global policy revision history (version, hash, status, timestamps). |
+| `policy get --global [--rev N] [--full]` | Shows a specific global revision's metadata, or the latest. `--full` includes the full policy as YAML. |
+
+Both `set` and `delete` require interactive confirmation (or `--yes` to bypass). The `--wait` flag is rejected for global policy updates: `"--wait is not supported for global policies; global policies are effective immediately"`.
+
+When a global policy is active, sandbox-scoped policy mutations are blocked:
+- `policy set ` returns `FailedPrecondition: "policy is managed globally"`
+- `rule approve`, `rule approve-all` return `FailedPrecondition: "cannot approve rules while a global policy is active"`
+- Revoking a previously approved draft chunk is blocked (it would modify the sandbox policy)
+- Rejecting pending chunks is allowed (does not modify the sandbox policy)
+
+See [Gateway Settings Channel](gateway-settings.md#global-policy-lifecycle) for the full state machine, storage model, and implementation details.
+
#### `policy get` flags
| Flag | Default | Description |
@@ -1382,6 +1409,7 @@ An empty `sources`/`log_sources` list means no source filtering (all sources pas
- [Sandbox Architecture](sandbox.md) -- Full sandbox lifecycle, enforcement mechanisms, and component interaction
- [Gateway Architecture](gateway.md) -- How the gateway stores and delivers policies via gRPC
+- [Gateway Settings Channel](gateway-settings.md) -- Runtime settings channel, global policy override, CLI/TUI settings commands
- [Inference Routing](inference-routing.md) -- How `inference.local` requests are routed to model backends
- [Overview](README.md) -- System-level context for how policies fit into the platform
- [Plain HTTP Forward Proxy Plan](plans/plain-http-forward-proxy.md) -- Design document for the forward proxy feature
diff --git a/architecture/system-architecture.md b/architecture/system-architecture.md
index f0915c18..290d27c6 100644
--- a/architecture/system-architecture.md
+++ b/architecture/system-architecture.md
@@ -123,7 +123,7 @@ graph TB
%% ============================================================
%% CONNECTIONS: Sandbox --> Gateway (control plane)
%% ============================================================
- Supervisor -- "gRPC (mTLS):
GetSandboxPolicy,
GetProviderEnvironment,
GetInferenceBundle,
PushSandboxLogs" --> Gateway
+ Supervisor -- "gRPC (mTLS):
GetSandboxSettings
(policy + settings),
GetProviderEnvironment,
GetInferenceBundle,
PushSandboxLogs" --> Gateway
%% ============================================================
%% CONNECTIONS: Sandbox --> External (via proxy)
@@ -197,4 +197,4 @@ graph TB
5. **Inference Routing**: Inference requests are handled inside the sandbox by the openshell-router (not through the gateway). The gateway provides route configuration and credentials via gRPC; the sandbox executes HTTP requests directly to inference backends.
-6. **Sandbox to Gateway**: The sandbox supervisor uses gRPC (mTLS) to fetch policies, provider credentials, inference bundles, and to push logs back to the gateway.
+6. **Sandbox to Gateway**: The sandbox supervisor uses gRPC (mTLS) to fetch policies and runtime settings (via `GetSandboxSettings`), provider credentials, inference bundles, and to push logs back to the gateway. The settings channel delivers typed key-value pairs alongside policy through a unified poll loop.
diff --git a/architecture/tui.md b/architecture/tui.md
index f54452c8..1a83e96d 100644
--- a/architecture/tui.md
+++ b/architecture/tui.md
@@ -48,15 +48,35 @@ The TUI divides the terminal into four horizontal regions:
### Dashboard (press `1`)
-The Dashboard is the home screen. It shows your cluster at a glance:
+The Dashboard is the home screen. It shows your cluster at a glance.
-- **Cluster name** and **gateway endpoint** — which cluster you are connected to and how to reach it.
-- **Health status** — a live indicator that polls the cluster every 2 seconds:
+The dashboard is divided into a top info pane and a middle pane with two tabs:
+
+- **Top pane**: Cluster name, gateway endpoint, health status, sandbox count.
+- **Middle pane**: Tabbed view toggled with `Tab`:
+ - **Providers** — provider configurations attached to the cluster.
+ - **Global Settings** — gateway-global runtime settings (fetched via `GetGatewaySettings`).
+
+**Health status** indicators:
- `●` **Healthy** (green) — everything is running normally.
- `◐` **Degraded** (yellow) — the cluster is up but something needs attention.
- `○` **Unhealthy** (red) — the cluster is not operating correctly.
- `…` — still connecting or status unknown.
-- **Sandbox count** — how many sandboxes exist in the cluster.
+
+**Global policy indicator**: When a global policy is active, the gateway row shows `Global Policy Active (vN)` in yellow (the `status_warn` style). The TUI detects this by polling `ListSandboxPolicies` with `global: true, limit: 1` on each tick and checking if the latest revision has `PolicyStatus::Loaded`. See `crates/openshell-tui/src/ui/dashboard.rs`.
+
+#### Global Settings Tab
+
+The Global Settings tab shows all registered setting keys with their current values. Keys without a configured value display as ``.
+
+| Key | Action |
+|-----|--------|
+| `j` / `↓` | Move selection down |
+| `k` / `↑` | Move selection up |
+| `Enter` | Edit the selected setting (type-aware: bool toggle, string/int text input) |
+| `d` | Delete the selected setting's value |
+
+Both edit and delete operations display a confirmation modal before applying. Changes are sent to the gateway via the `UpdateSandboxPolicy` RPC with `global: true`.
### Sandboxes (press `2`)
@@ -82,6 +102,23 @@ Use `j`/`k` or the arrow keys to move through the list. The selected row is high
When there are no sandboxes, the view displays: *"No sandboxes found."*
+When viewing a specific sandbox (by pressing `Enter` on a selected row), the bottom pane shows a tabbed view toggled with `l`:
+
+- **Policy** — the sandbox's current active policy, auto-refreshed on version change.
+- **Settings** — effective runtime settings for the sandbox (fetched via `GetSandboxSettings`).
+
+**Global policy indicator on sandbox detail**: When the sandbox's policy is managed globally (`policy_source == GLOBAL` in the `GetSandboxSettings` response), the metadata pane shows `Policy: managed globally (vN)` in yellow. Draft chunks in the **Network Rules** pane are greyed out and a yellow warning reads `"Cannot approve rules while global policy is active"`. Approve (`a`), reject/revoke (`x`), and approve-all actions are blocked client-side with status messages. See `crates/openshell-tui/src/ui/sandbox_detail.rs` and `crates/openshell-tui/src/ui/sandbox_draft.rs`.
+
+#### Sandbox Settings Tab
+
+The Settings tab shows all registered setting keys with their effective values and scope indicators:
+
+- **(sandbox)** — value is set at sandbox scope
+- **(global)** — value is set at gateway-global scope (overrides sandbox)
+- **(unset)** — no value configured at any scope
+
+Navigation and editing use the same keys as the Global Settings tab (`j`/`k`, `Enter` to edit, `d` to delete). Sandbox-scoped edits to globally-managed keys are rejected by the server with a `FailedPrecondition` error.
+
## Keyboard Controls
The TUI has two input modes: **Normal** (default) and **Command** (activated by pressing `:`).
@@ -112,10 +149,12 @@ Press `Esc` to cancel and return to Normal mode. `Backspace` deletes characters
## Data Refresh
-The TUI automatically polls the cluster every **2 seconds**. Both cluster health and the sandbox list update on each tick, so the display stays current without manual refreshing. This uses the same gRPC calls as the CLI — no additional server-side setup is required.
+The TUI automatically polls the cluster every **2 seconds**. Cluster health, the sandbox list, and global settings all update on each tick, so the display stays current without manual refreshing. This uses the same gRPC calls as the CLI — no additional server-side setup is required.
When viewing a sandbox, the policy pane auto-refreshes when a new policy version is detected. The sandbox list response includes `current_policy_version` for each sandbox; on every tick the TUI compares this against the currently displayed policy version and re-fetches the full policy only when they differ. This avoids extra RPCs during normal operation while ensuring policy updates appear within the polling interval. The user's scroll position is preserved across auto-refreshes.
+Global settings are refreshed via `GetGatewaySettings` and tracked by `settings_revision` to detect changes. Sandbox settings are fetched as part of the `GetSandboxSettings` response when viewing a specific sandbox.
+
## Theme
The TUI uses a dark terminal theme based on the NVIDIA brand palette:
@@ -143,9 +182,9 @@ The forwarding implementation lives in `openshell-core::forward`, shared between
## What is Not Yet Available
-The TUI is in its initial phase. The following features are planned but not yet implemented:
+The TUI is in active development. The following features are planned but not yet implemented:
-- **Inference and provider views** — browsing inference routes and provider configurations.
+- **Inference views** — browsing inference routes and configuration.
- **Help overlay** — the `?` key is shown in the nav bar but does not open a help screen yet.
- **Command bar autocomplete** — the command bar accepts text but does not offer suggestions.
- **Filtering and search** — no `/` search within views yet.
diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml
index 61c20450..ef6d8779 100644
--- a/crates/openshell-cli/Cargo.toml
+++ b/crates/openshell-cli/Cargo.toml
@@ -74,6 +74,9 @@ tracing-subscriber = { workspace = true }
[lints]
workspace = true
+[features]
+dev-settings = ["openshell-core/dev-settings"]
+
[dev-dependencies]
futures = { workspace = true }
rcgen = { version = "0.13", features = ["crypto", "pem"] }
diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs
index 84a323b5..3799b392 100644
--- a/crates/openshell-cli/src/main.rs
+++ b/crates/openshell-cli/src/main.rs
@@ -164,6 +164,7 @@ const HELP_TEMPLATE: &str = "\
forward: Manage port forwarding to a sandbox
logs: View sandbox logs
policy: Manage sandbox policy
+ settings: Manage sandbox and global settings
provider: Manage provider configuration
\x1b[1mGATEWAY COMMANDS\x1b[0m
@@ -249,9 +250,21 @@ const POLICY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
\x1b[1mEXAMPLES\x1b[0m
$ openshell policy get my-sandbox
$ openshell policy set my-sandbox --policy policy.yaml
+ $ openshell policy set --global --policy policy.yaml
+ $ openshell policy delete --global
$ openshell policy list my-sandbox
";
+const SETTINGS_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m
+ $ openshell settings get my-sandbox
+ $ openshell settings get --global
+ $ openshell settings set my-sandbox --key log_level --value debug
+ $ openshell settings set --global --key log_level --value warn
+ $ openshell settings set --global --key dummy_bool --value yes
+ $ openshell settings set --global --key dummy_int --value 42
+ $ openshell settings delete --global --key log_level
+";
+
const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m
$ openshell provider create --name openai --type openai --credential OPENAI_API_KEY
$ openshell provider create --name anthropic --type anthropic --from-existing
@@ -397,6 +410,13 @@ enum Commands {
command: Option,
},
+ /// Manage sandbox and gateway settings.
+ #[command(after_help = SETTINGS_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
+ Settings {
+ #[command(subcommand)]
+ command: Option,
+ },
+
/// Manage network rules for a sandbox.
#[command(visible_alias = "rl", hide = true, help_template = SUBCOMMAND_HELP_TEMPLATE)]
Rule {
@@ -1324,7 +1344,7 @@ enum PolicyCommands {
/// Update policy on a live sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Set {
- /// Sandbox name (defaults to last-used sandbox).
+ /// Sandbox name (defaults to last-used sandbox when not using --global).
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option,
@@ -1332,6 +1352,14 @@ enum PolicyCommands {
#[arg(long, value_hint = ValueHint::FilePath)]
policy: String,
+ /// Apply as a gateway-global policy for all sandboxes.
+ #[arg(long)]
+ global: bool,
+
+ /// Skip the confirmation prompt for global policy updates.
+ #[arg(long)]
+ yes: bool,
+
/// Wait for the sandbox to load the policy.
#[arg(long)]
wait: bool,
@@ -1341,10 +1369,10 @@ enum PolicyCommands {
timeout: u64,
},
- /// Show current active policy for a sandbox.
+ /// Show current active policy for a sandbox or the global policy.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Get {
- /// Sandbox name (defaults to last-used sandbox).
+ /// Sandbox name (defaults to last-used sandbox). Ignored with --global.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option,
@@ -1355,18 +1383,101 @@ enum PolicyCommands {
/// Print the full policy as YAML.
#[arg(long)]
full: bool,
+
+ /// Show the global policy revision.
+ #[arg(long)]
+ global: bool,
},
- /// List policy history for a sandbox.
+ /// List policy history for a sandbox or the global policy.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
List {
- /// Sandbox name (defaults to last-used sandbox).
+ /// Sandbox name (defaults to last-used sandbox). Ignored with --global.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option,
/// Maximum number of revisions to return.
#[arg(long, default_value_t = 20)]
limit: u32,
+
+ /// List global policy revisions.
+ #[arg(long)]
+ global: bool,
+ },
+
+ /// Delete the gateway-global policy lock, restoring sandbox-level policy control.
+ #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
+ Delete {
+ /// Delete the global policy setting.
+ #[arg(long)]
+ global: bool,
+
+ /// Skip the confirmation prompt for global policy delete.
+ #[arg(long)]
+ yes: bool,
+ },
+}
+
+#[derive(Subcommand, Debug)]
+enum SettingsCommands {
+ /// Show effective settings for a sandbox or gateway-global scope.
+ #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
+ Get {
+ /// Sandbox name (defaults to last-used sandbox).
+ #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
+ name: Option,
+
+ /// Show gateway-global settings.
+ #[arg(long)]
+ global: bool,
+
+ /// Output as JSON.
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Set a single setting key.
+ #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
+ Set {
+ /// Sandbox name (defaults to last-used sandbox when not using --global).
+ #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
+ name: Option,
+
+ /// Setting key.
+ #[arg(long)]
+ key: String,
+
+ /// Setting value (string input; bool keys accept true/false/yes/no/1/0).
+ #[arg(long)]
+ value: String,
+
+ /// Apply at gateway-global scope.
+ #[arg(long)]
+ global: bool,
+
+ /// Skip the confirmation prompt for global setting updates.
+ #[arg(long)]
+ yes: bool,
+ },
+
+ /// Delete a setting key (sandbox-scoped or gateway-global).
+ #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
+ Delete {
+ /// Sandbox name (defaults to last-used sandbox when not using --global).
+ #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
+ name: Option,
+
+ /// Setting key.
+ #[arg(long)]
+ key: String,
+
+ /// Delete at gateway-global scope.
+ #[arg(long)]
+ global: bool,
+
+ /// Skip the confirmation prompt for global setting delete.
+ #[arg(long)]
+ yes: bool,
},
}
@@ -1730,20 +1841,119 @@ async fn main() -> Result<()> {
PolicyCommands::Set {
name,
policy,
+ global,
+ yes,
wait,
timeout,
} => {
- let name = resolve_sandbox_name(name, &ctx.name)?;
- run::sandbox_policy_set(&ctx.endpoint, &name, &policy, wait, timeout, &tls)
+ if global {
+ if wait {
+ return Err(miette::miette!(
+ "--wait is not supported for global policies; \
+ global policies are effective immediately"
+ ));
+ }
+ run::sandbox_policy_set_global(
+ &ctx.endpoint,
+ &policy,
+ yes,
+ wait,
+ timeout,
+ &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 { name, rev, full } => {
- let name = resolve_sandbox_name(name, &ctx.name)?;
- run::sandbox_policy_get(&ctx.endpoint, &name, rev, full, &tls).await?;
+ PolicyCommands::Get {
+ name,
+ rev,
+ full,
+ global,
+ } => {
+ if global {
+ run::sandbox_policy_get_global(&ctx.endpoint, rev, full, &tls).await?;
+ } else {
+ let name = resolve_sandbox_name(name, &ctx.name)?;
+ run::sandbox_policy_get(&ctx.endpoint, &name, rev, full, &tls).await?;
+ }
}
- PolicyCommands::List { name, limit } => {
- let name = resolve_sandbox_name(name, &ctx.name)?;
- run::sandbox_policy_list(&ctx.endpoint, &name, limit, &tls).await?;
+ PolicyCommands::List {
+ name,
+ limit,
+ global,
+ } => {
+ if global {
+ run::sandbox_policy_list_global(&ctx.endpoint, limit, &tls).await?;
+ } else {
+ let name = resolve_sandbox_name(name, &ctx.name)?;
+ run::sandbox_policy_list(&ctx.endpoint, &name, limit, &tls).await?;
+ }
+ }
+ PolicyCommands::Delete { global, yes } => {
+ if !global {
+ return Err(miette::miette!(
+ "sandbox policy delete is not supported; use --global to remove global policy lock"
+ ));
+ }
+ run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &tls).await?;
+ }
+ }
+ }
+
+ // -----------------------------------------------------------
+ // Settings commands
+ // -----------------------------------------------------------
+ Some(Commands::Settings {
+ command: Some(settings_cmd),
+ }) => {
+ let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
+ let mut tls = tls.with_gateway_name(&ctx.name);
+ apply_edge_auth(&mut tls, &ctx.name);
+
+ match settings_cmd {
+ SettingsCommands::Get { name, global, json } => {
+ if global {
+ if name.is_some() {
+ return Err(miette::miette!(
+ "settings get --global does not accept a sandbox name"
+ ));
+ }
+ run::gateway_settings_get(&ctx.endpoint, json, &tls).await?;
+ } else {
+ let name = resolve_sandbox_name(name, &ctx.name)?;
+ run::sandbox_settings_get(&ctx.endpoint, &name, json, &tls).await?;
+ }
+ }
+ SettingsCommands::Set {
+ name,
+ key,
+ value,
+ global,
+ yes,
+ } => {
+ if global {
+ run::gateway_setting_set(&ctx.endpoint, &key, &value, yes, &tls).await?;
+ } else {
+ let name = resolve_sandbox_name(name, &ctx.name)?;
+ run::sandbox_setting_set(&ctx.endpoint, &name, &key, &value, &tls).await?;
+ }
+ }
+ SettingsCommands::Delete {
+ name,
+ key,
+ global,
+ yes,
+ } => {
+ if global {
+ run::gateway_setting_delete(&ctx.endpoint, &key, yes, &tls).await?;
+ } else {
+ let name = resolve_sandbox_name(name, &ctx.name)?;
+ run::sandbox_setting_delete(&ctx.endpoint, &name, &key, &tls).await?;
+ }
}
}
}
@@ -2229,6 +2439,13 @@ async fn main() -> Result<()> {
.print_help()
.expect("Failed to print help");
}
+ Some(Commands::Settings { command: None }) => {
+ Cli::command()
+ .find_subcommand_mut("settings")
+ .expect("settings subcommand exists")
+ .print_help()
+ .expect("Failed to print help");
+ }
Some(Commands::Provider { command: None }) => {
Cli::command()
.find_subcommand_mut("provider")
@@ -2803,4 +3020,99 @@ mod tests {
other => panic!("expected SshProxy, got: {other:?}"),
}
}
+
+ #[test]
+ fn settings_set_global_parses_yes_flag() {
+ let cli = Cli::try_parse_from([
+ "openshell",
+ "settings",
+ "set",
+ "--global",
+ "--key",
+ "log_level",
+ "--value",
+ "warn",
+ "--yes",
+ ])
+ .expect("settings set --global should parse");
+
+ match cli.command {
+ Some(Commands::Settings {
+ command:
+ Some(SettingsCommands::Set {
+ global,
+ yes,
+ key,
+ value,
+ ..
+ }),
+ }) => {
+ assert!(global);
+ assert!(yes);
+ assert_eq!(key, "log_level");
+ assert_eq!(value, "warn");
+ }
+ other => panic!("expected settings set command, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn settings_get_global_parses() {
+ let cli = Cli::try_parse_from(["openshell", "settings", "get", "--global"])
+ .expect("settings get --global should parse");
+
+ match cli.command {
+ Some(Commands::Settings {
+ command: Some(SettingsCommands::Get { name, global, .. }),
+ }) => {
+ assert!(global);
+ assert!(name.is_none());
+ }
+ other => panic!("expected settings get command, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn policy_delete_global_parses() {
+ let cli = Cli::try_parse_from(["openshell", "policy", "delete", "--global", "--yes"])
+ .expect("policy delete --global should parse");
+
+ match cli.command {
+ Some(Commands::Policy {
+ command: Some(PolicyCommands::Delete { global, yes }),
+ }) => {
+ assert!(global);
+ assert!(yes);
+ }
+ other => panic!("expected policy delete command, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn settings_delete_global_parses_yes_flag() {
+ let cli = Cli::try_parse_from([
+ "openshell",
+ "settings",
+ "delete",
+ "--global",
+ "--key",
+ "log_level",
+ "--yes",
+ ])
+ .expect("settings delete --global should parse");
+
+ match cli.command {
+ Some(Commands::Settings {
+ command:
+ Some(SettingsCommands::Delete {
+ key, global, yes, ..
+ }),
+ }) => {
+ assert_eq!(key, "log_level");
+ assert!(global);
+ assert!(yes);
+ }
+ other => panic!("expected settings delete command, got: {other:?}"),
+ }
+ }
}
diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs
index 2f9dd2f7..6d6b6b89 100644
--- a/crates/openshell-cli/src/run.rs
+++ b/crates/openshell-cli/src/run.rs
@@ -24,13 +24,15 @@ use openshell_bootstrap::{
use openshell_core::proto::{
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest,
CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest,
- GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetProviderRequest,
- GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest,
- ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider,
+ GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
+ GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest,
+ GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest,
+ ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider,
RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
- SetClusterInferenceRequest, UpdateProviderRequest, UpdateSandboxPolicyRequest,
- WatchSandboxRequest,
+ SetClusterInferenceRequest, SettingScope, SettingValue, UpdateProviderRequest,
+ UpdateSettingsRequest, WatchSandboxRequest, setting_value,
};
+use openshell_core::settings::{self, SettingValueKind};
use openshell_providers::{
ProviderRegistry, detect_provider_from_command, normalize_provider_type,
};
@@ -3783,6 +3785,456 @@ fn parse_duration_to_ms(s: &str) -> Result {
Ok(num * multiplier)
}
+fn confirm_global_setting_takeover(key: &str, yes: bool) -> Result<()> {
+ if yes {
+ return Ok(());
+ }
+
+ if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
+ return Err(miette::miette!(
+ "global setting updates require confirmation; pass --yes in non-interactive mode"
+ ));
+ }
+
+ let proceed = Confirm::with_theme(&ColorfulTheme::default())
+ .with_prompt(format!(
+ "Setting '{key}' globally will disable sandbox-level management for this key. Continue?"
+ ))
+ .default(false)
+ .interact()
+ .into_diagnostic()?;
+
+ if !proceed {
+ return Err(miette::miette!("aborted by user"));
+ }
+
+ Ok(())
+}
+
+fn confirm_global_setting_delete(key: &str, yes: bool) -> Result<()> {
+ if yes {
+ return Ok(());
+ }
+
+ if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
+ return Err(miette::miette!(
+ "global setting deletes require confirmation; pass --yes in non-interactive mode"
+ ));
+ }
+
+ let proceed = Confirm::with_theme(&ColorfulTheme::default())
+ .with_prompt(format!(
+ "Deleting global setting '{key}' re-enables sandbox-level management for this key. Continue?"
+ ))
+ .default(false)
+ .interact()
+ .into_diagnostic()?;
+
+ if !proceed {
+ return Err(miette::miette!("aborted by user"));
+ }
+
+ Ok(())
+}
+
+fn parse_cli_setting_value(key: &str, raw_value: &str) -> Result {
+ let setting = settings::setting_for_key(key).ok_or_else(|| {
+ miette::miette!(
+ "unknown setting key '{}'. Allowed keys: {}",
+ key,
+ settings::registered_keys_csv()
+ )
+ })?;
+
+ let value = match setting.kind {
+ SettingValueKind::String => setting_value::Value::StringValue(raw_value.to_string()),
+ SettingValueKind::Int => {
+ let parsed = raw_value.trim().parse::().map_err(|_| {
+ miette::miette!(
+ "invalid int value '{}' for key '{}'; expected base-10 integer",
+ raw_value,
+ key
+ )
+ })?;
+ setting_value::Value::IntValue(parsed)
+ }
+ SettingValueKind::Bool => {
+ let parsed = settings::parse_bool_like(raw_value).ok_or_else(|| {
+ miette::miette!(
+ "invalid bool value '{}' for key '{}'; expected one of: true,false,yes,no,1,0",
+ raw_value,
+ key
+ )
+ })?;
+ setting_value::Value::BoolValue(parsed)
+ }
+ };
+
+ Ok(SettingValue { value: Some(value) })
+}
+
+fn format_setting_value(value: Option<&SettingValue>) -> String {
+ let Some(value) = value.and_then(|v| v.value.as_ref()) else {
+ return "".to_string();
+ };
+ match value {
+ setting_value::Value::StringValue(v) => v.clone(),
+ setting_value::Value::BoolValue(v) => v.to_string(),
+ setting_value::Value::IntValue(v) => v.to_string(),
+ setting_value::Value::BytesValue(v) => format!("", v.len()),
+ }
+}
+
+pub async fn sandbox_policy_set_global(
+ server: &str,
+ policy_path: &str,
+ yes: bool,
+ wait: bool,
+ _timeout_secs: u64,
+ tls: &TlsOptions,
+) -> Result<()> {
+ if wait {
+ return Err(miette::miette!(
+ "--wait is only supported for sandbox-scoped policy updates"
+ ));
+ }
+
+ 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}"))?;
+
+ let mut client = grpc_client(server, tls).await?;
+ let response = client
+ .update_settings(UpdateSettingsRequest {
+ name: String::new(),
+ policy: Some(policy),
+ setting_key: String::new(),
+ setting_value: None,
+ delete_setting: false,
+ global: true,
+ })
+ .await
+ .into_diagnostic()?
+ .into_inner();
+
+ eprintln!(
+ "{} Global policy configured (hash: {}, settings revision: {})",
+ "✓".green().bold(),
+ if response.policy_hash.len() >= 12 {
+ &response.policy_hash[..12]
+ } else {
+ &response.policy_hash
+ },
+ response.settings_revision,
+ );
+ Ok(())
+}
+
+pub async fn sandbox_settings_get(
+ server: &str,
+ name: &str,
+ json: bool,
+ tls: &TlsOptions,
+) -> Result<()> {
+ 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::miette!("sandbox not found"))?;
+
+ let response = client
+ .get_sandbox_config(GetSandboxConfigRequest {
+ sandbox_id: sandbox.id.clone(),
+ })
+ .await
+ .into_diagnostic()?
+ .into_inner();
+
+ if json {
+ let obj = settings_to_json_sandbox(name, &response);
+ println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?);
+ return Ok(());
+ }
+
+ let policy_source =
+ if response.policy_source == openshell_core::proto::PolicySource::Global as i32 {
+ "global"
+ } else {
+ "sandbox"
+ };
+
+ println!("Sandbox: {}", name);
+ println!("Config Rev: {}", response.config_revision);
+ println!("Policy Source: {}", policy_source);
+ println!("Policy Hash: {}", response.policy_hash);
+
+ if response.settings.is_empty() {
+ println!("Settings: No settings available.");
+ return Ok(());
+ }
+
+ println!("Settings:");
+ let mut keys: Vec<_> = response.settings.keys().cloned().collect();
+ keys.sort();
+ for key in keys {
+ if let Some(setting) = response.settings.get(&key) {
+ let scope = match SettingScope::try_from(setting.scope) {
+ Ok(SettingScope::Global) => "global",
+ Ok(SettingScope::Sandbox) => "sandbox",
+ _ => "unset",
+ };
+ println!(
+ " {} = {} ({})",
+ key,
+ format_setting_value(setting.value.as_ref()),
+ scope
+ );
+ }
+ }
+
+ Ok(())
+}
+
+pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> Result<()> {
+ let mut client = grpc_client(server, tls).await?;
+ let response = client
+ .get_gateway_config(GetGatewayConfigRequest {})
+ .await
+ .into_diagnostic()?
+ .into_inner();
+
+ if json {
+ let obj = settings_to_json_global(&response);
+ println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?);
+ return Ok(());
+ }
+
+ println!("Scope: global");
+ println!("Settings Rev: {}", response.settings_revision);
+
+ if response.settings.is_empty() {
+ println!("Settings: No settings available.");
+ return Ok(());
+ }
+
+ println!("Settings:");
+ let mut keys: Vec<_> = response.settings.keys().cloned().collect();
+ keys.sort();
+ for key in keys {
+ if let Some(setting) = response.settings.get(&key) {
+ println!(" {} = {}", key, format_setting_value(Some(setting)));
+ }
+ }
+ Ok(())
+}
+
+fn settings_to_json_sandbox(
+ name: &str,
+ response: &openshell_core::proto::GetSandboxConfigResponse,
+) -> serde_json::Value {
+ let policy_source =
+ if response.policy_source == openshell_core::proto::PolicySource::Global as i32 {
+ "global"
+ } else {
+ "sandbox"
+ };
+
+ let mut settings = serde_json::Map::new();
+ let mut keys: Vec<_> = response.settings.keys().cloned().collect();
+ keys.sort();
+ for key in keys {
+ if let Some(setting) = response.settings.get(&key) {
+ let scope = match SettingScope::try_from(setting.scope) {
+ Ok(SettingScope::Global) => "global",
+ Ok(SettingScope::Sandbox) => "sandbox",
+ _ => "unset",
+ };
+ settings.insert(
+ key,
+ serde_json::json!({
+ "value": format_setting_value(setting.value.as_ref()),
+ "scope": scope,
+ }),
+ );
+ }
+ }
+
+ serde_json::json!({
+ "sandbox": name,
+ "config_revision": response.config_revision,
+ "policy_source": policy_source,
+ "policy_hash": response.policy_hash,
+ "settings": settings,
+ })
+}
+
+fn settings_to_json_global(
+ response: &openshell_core::proto::GetGatewayConfigResponse,
+) -> serde_json::Value {
+ let mut settings = serde_json::Map::new();
+ let mut keys: Vec<_> = response.settings.keys().cloned().collect();
+ keys.sort();
+ for key in keys {
+ if let Some(setting) = response.settings.get(&key) {
+ settings.insert(key, serde_json::json!(format_setting_value(Some(setting))));
+ }
+ }
+
+ serde_json::json!({
+ "scope": "global",
+ "settings_revision": response.settings_revision,
+ "settings": settings,
+ })
+}
+
+pub async fn gateway_setting_set(
+ server: &str,
+ key: &str,
+ value: &str,
+ yes: bool,
+ tls: &TlsOptions,
+) -> Result<()> {
+ let setting_value = parse_cli_setting_value(key, value)?;
+ confirm_global_setting_takeover(key, yes)?;
+
+ let mut client = grpc_client(server, tls).await?;
+ let response = client
+ .update_settings(UpdateSettingsRequest {
+ name: String::new(),
+ policy: None,
+ setting_key: key.to_string(),
+ setting_value: Some(setting_value),
+ delete_setting: false,
+ global: true,
+ })
+ .await
+ .into_diagnostic()?
+ .into_inner();
+
+ println!(
+ "{} Set global setting {}={} (revision {})",
+ "✓".green().bold(),
+ key,
+ value,
+ response.settings_revision
+ );
+ Ok(())
+}
+
+pub async fn sandbox_setting_set(
+ server: &str,
+ name: &str,
+ key: &str,
+ value: &str,
+ tls: &TlsOptions,
+) -> Result<()> {
+ let setting_value = parse_cli_setting_value(key, value)?;
+
+ let mut client = grpc_client(server, tls).await?;
+ let response = client
+ .update_settings(UpdateSettingsRequest {
+ name: name.to_string(),
+ policy: None,
+ setting_key: key.to_string(),
+ setting_value: Some(setting_value),
+ delete_setting: false,
+ global: false,
+ })
+ .await
+ .into_diagnostic()?
+ .into_inner();
+
+ println!(
+ "{} Set sandbox setting {}={} for {} (revision {})",
+ "✓".green().bold(),
+ key,
+ value,
+ name,
+ response.settings_revision
+ );
+ Ok(())
+}
+
+pub async fn gateway_setting_delete(
+ server: &str,
+ key: &str,
+ yes: bool,
+ tls: &TlsOptions,
+) -> Result<()> {
+ confirm_global_setting_delete(key, yes)?;
+
+ let mut client = grpc_client(server, tls).await?;
+ let response = client
+ .update_settings(UpdateSettingsRequest {
+ name: String::new(),
+ policy: None,
+ setting_key: key.to_string(),
+ setting_value: None,
+ delete_setting: true,
+ global: true,
+ })
+ .await
+ .into_diagnostic()?
+ .into_inner();
+
+ if response.deleted {
+ println!(
+ "{} Deleted global setting {} (revision {})",
+ "✓".green().bold(),
+ key,
+ response.settings_revision
+ );
+ } else {
+ println!("{} Global setting {} not found", "!".yellow(), key,);
+ }
+ Ok(())
+}
+
+pub async fn sandbox_setting_delete(
+ server: &str,
+ name: &str,
+ key: &str,
+ tls: &TlsOptions,
+) -> Result<()> {
+ let mut client = grpc_client(server, tls).await?;
+ let response = client
+ .update_settings(UpdateSettingsRequest {
+ name: name.to_string(),
+ policy: None,
+ setting_key: key.to_string(),
+ setting_value: None,
+ delete_setting: true,
+ global: false,
+ })
+ .await
+ .into_diagnostic()?
+ .into_inner();
+
+ if response.deleted {
+ println!(
+ "{} Deleted sandbox setting {} for {} (revision {})",
+ "✓".green().bold(),
+ key,
+ name,
+ response.settings_revision
+ );
+ } else {
+ println!(
+ "{} Sandbox setting {} not found for {}",
+ "!".yellow(),
+ key,
+ name,
+ );
+ }
+ Ok(())
+}
+
pub async fn sandbox_policy_set(
server: &str,
name: &str,
@@ -3801,6 +4253,7 @@ pub async fn sandbox_policy_set(
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
name: name.to_string(),
version: 0,
+ global: false,
})
.await
.ok()
@@ -3808,9 +4261,13 @@ pub async fn sandbox_policy_set(
.map_or(0, |r| r.version);
let response = client
- .update_sandbox_policy(UpdateSandboxPolicyRequest {
+ .update_settings(UpdateSettingsRequest {
name: name.to_string(),
policy: Some(policy),
+ setting_key: String::new(),
+ setting_value: None,
+ delete_setting: false,
+ global: false,
})
.await
.into_diagnostic()?;
@@ -3856,6 +4313,7 @@ pub async fn sandbox_policy_set(
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
name: name.to_string(),
version: resp.version,
+ global: false,
})
.await
.into_diagnostic()?;
@@ -3910,6 +4368,7 @@ pub async fn sandbox_policy_get(
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
name: name.to_string(),
version,
+ global: false,
})
.await
.into_diagnostic()?;
@@ -3948,6 +4407,54 @@ pub async fn sandbox_policy_get(
Ok(())
}
+pub async fn sandbox_policy_get_global(
+ server: &str,
+ version: u32,
+ full: bool,
+ tls: &TlsOptions,
+) -> Result<()> {
+ let mut client = grpc_client(server, tls).await?;
+
+ let status_resp = client
+ .get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
+ name: String::new(),
+ version,
+ global: true,
+ })
+ .await
+ .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 {
+ eprintln!("No global policy history found");
+ }
+
+ Ok(())
+}
+
pub async fn sandbox_policy_list(
server: &str,
name: &str,
@@ -3961,6 +4468,7 @@ pub async fn sandbox_policy_list(
name: name.to_string(),
limit,
offset: 0,
+ global: false,
})
.await
.into_diagnostic()?;
@@ -3971,11 +4479,39 @@ pub async fn sandbox_policy_list(
return Ok(());
}
+ print_policy_revision_table(&revisions);
+ Ok(())
+}
+
+pub async fn sandbox_policy_list_global(server: &str, limit: u32, tls: &TlsOptions) -> Result<()> {
+ let mut client = grpc_client(server, tls).await?;
+
+ let resp = client
+ .list_sandbox_policies(ListSandboxPoliciesRequest {
+ name: String::new(),
+ limit,
+ offset: 0,
+ global: true,
+ })
+ .await
+ .into_diagnostic()?;
+
+ let revisions = resp.into_inner().revisions;
+ if revisions.is_empty() {
+ eprintln!("No global policy history found");
+ return Ok(());
+ }
+
+ print_policy_revision_table(&revisions);
+ Ok(())
+}
+
+fn print_policy_revision_table(revisions: &[openshell_core::proto::SandboxPolicyRevision]) {
println!(
"{:<8} {:<14} {:<12} {:<24} ERROR",
"VERSION", "HASH", "STATUS", "CREATED"
);
- for rev in &revisions {
+ for rev in revisions {
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
let hash_short = if rev.policy_hash.len() >= 12 {
&rev.policy_hash[..12]
@@ -3996,8 +4532,6 @@ pub async fn sandbox_policy_list(
error_short,
);
}
-
- Ok(())
}
// ---------------------------------------------------------------------------
@@ -4406,8 +4940,9 @@ mod tests {
GatewayControlTarget, TlsOptions, format_gateway_select_header,
format_gateway_select_items, gateway_auth_label, gateway_select_with, gateway_type_label,
git_sync_files, http_health_check, image_requests_gpu, inferred_provider_type,
- parse_credential_pairs, provisioning_timeout_message, ready_false_condition_message,
- resolve_gateway_control_target_from, sandbox_should_persist, source_requests_gpu,
+ parse_cli_setting_value, parse_credential_pairs, provisioning_timeout_message,
+ ready_false_condition_message, resolve_gateway_control_target_from, sandbox_should_persist,
+ source_requests_gpu,
};
use crate::TEST_ENV_LOCK;
use hyper::StatusCode;
@@ -4527,6 +5062,49 @@ mod tests {
));
}
+ #[cfg(feature = "dev-settings")]
+ #[test]
+ fn parse_cli_setting_value_parses_bool_aliases() {
+ let yes_value = parse_cli_setting_value("dummy_bool", "yes").expect("parse yes");
+ assert_eq!(
+ yes_value.value,
+ Some(openshell_core::proto::setting_value::Value::BoolValue(true))
+ );
+
+ let zero_value = parse_cli_setting_value("dummy_bool", "0").expect("parse 0");
+ assert_eq!(
+ zero_value.value,
+ Some(openshell_core::proto::setting_value::Value::BoolValue(
+ false
+ ))
+ );
+ }
+
+ #[cfg(feature = "dev-settings")]
+ #[test]
+ fn parse_cli_setting_value_parses_int_key() {
+ let int_value = parse_cli_setting_value("dummy_int", "42").expect("parse int");
+ assert_eq!(
+ int_value.value,
+ Some(openshell_core::proto::setting_value::Value::IntValue(42))
+ );
+ }
+
+ #[cfg(feature = "dev-settings")]
+ #[test]
+ fn parse_cli_setting_value_rejects_invalid_bool() {
+ let err =
+ parse_cli_setting_value("dummy_bool", "maybe").expect_err("invalid bool should fail");
+ assert!(err.to_string().contains("invalid bool value"));
+ }
+
+ #[test]
+ fn parse_cli_setting_value_rejects_unknown_key() {
+ let err =
+ parse_cli_setting_value("unknown_key", "value").expect_err("unknown key should fail");
+ assert!(err.to_string().contains("unknown setting key"));
+ }
+
#[test]
fn inferred_provider_type_returns_type_for_known_command() {
let result = inferred_provider_type(&["claude".to_string(), "--help".to_string()]);
diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs
index 659edffd..8f86766e 100644
--- a/crates/openshell-cli/tests/ensure_providers_integration.rs
+++ b/crates/openshell-cli/tests/ensure_providers_integration.rs
@@ -11,12 +11,13 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
use openshell_core::proto::{
CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse,
DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse,
- ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest,
- GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest,
- GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse,
- ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse,
- Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse,
- SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest,
+ ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse,
+ GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse,
+ GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest,
+ HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse,
+ ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse,
+ RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent,
+ ServiceStatus, UpdateProviderRequest, WatchSandboxRequest,
};
use rcgen::{
BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair,
@@ -153,11 +154,18 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(DeleteSandboxResponse { deleted: true }))
}
- async fn get_sandbox_policy(
+ async fn get_sandbox_config(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
- Ok(Response::new(GetSandboxPolicyResponse::default()))
+ _request: tonic::Request,
+ ) -> Result, Status> {
+ Ok(Response::new(GetSandboxConfigResponse::default()))
+ }
+
+ async fn get_gateway_config(
+ &self,
+ _request: tonic::Request,
+ ) -> Result, Status> {
+ Ok(Response::new(GetGatewayConfigResponse::default()))
}
async fn get_sandbox_provider_environment(
@@ -311,10 +319,10 @@ impl OpenShell for TestOpenShell {
)))
}
- async fn update_sandbox_policy(
+ async fn update_settings(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
+ _request: tonic::Request,
+ ) -> Result, Status> {
Err(Status::unimplemented("not implemented in test"))
}
diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs
index 8b238da9..4f2eed8b 100644
--- a/crates/openshell-cli/tests/mtls_integration.rs
+++ b/crates/openshell-cli/tests/mtls_integration.rs
@@ -108,12 +108,21 @@ impl OpenShell for TestOpenShell {
))
}
- async fn get_sandbox_policy(
+ async fn get_sandbox_config(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
+ _request: tonic::Request,
+ ) -> Result, Status> {
Ok(Response::new(
- openshell_core::proto::GetSandboxPolicyResponse::default(),
+ openshell_core::proto::GetSandboxConfigResponse::default(),
+ ))
+ }
+
+ async fn get_gateway_config(
+ &self,
+ _request: tonic::Request,
+ ) -> Result, Status> {
+ Ok(Response::new(
+ openshell_core::proto::GetGatewayConfigResponse::default(),
))
}
@@ -212,10 +221,10 @@ impl OpenShell for TestOpenShell {
)))
}
- async fn update_sandbox_policy(
+ async fn update_settings(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
+ _request: tonic::Request,
+ ) -> Result, Status> {
Err(Status::unimplemented("not implemented in test"))
}
diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs
index af7e80a3..b8abb1e6 100644
--- a/crates/openshell-cli/tests/provider_commands_integration.rs
+++ b/crates/openshell-cli/tests/provider_commands_integration.rs
@@ -7,12 +7,13 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
use openshell_core::proto::{
CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse,
DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse,
- ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest,
- GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest,
- GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse,
- ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse,
- Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse,
- SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest,
+ ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse,
+ GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse,
+ GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest,
+ HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse,
+ ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse,
+ RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent,
+ ServiceStatus, UpdateProviderRequest, WatchSandboxRequest,
};
use rcgen::{
BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair,
@@ -107,11 +108,18 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(DeleteSandboxResponse { deleted: true }))
}
- async fn get_sandbox_policy(
+ async fn get_sandbox_config(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
- Ok(Response::new(GetSandboxPolicyResponse::default()))
+ _request: tonic::Request,
+ ) -> Result, Status> {
+ Ok(Response::new(GetSandboxConfigResponse::default()))
+ }
+
+ async fn get_gateway_config(
+ &self,
+ _request: tonic::Request,
+ ) -> Result, Status> {
+ Ok(Response::new(GetGatewayConfigResponse::default()))
}
async fn get_sandbox_provider_environment(
@@ -265,10 +273,10 @@ impl OpenShell for TestOpenShell {
)))
}
- async fn update_sandbox_policy(
+ async fn update_settings(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
+ _request: tonic::Request,
+ ) -> Result, Status> {
Err(Status::unimplemented("not implemented in test"))
}
diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs
index 9fcfeced..66cd5b9e 100644
--- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs
+++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs
@@ -8,13 +8,14 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
use openshell_core::proto::{
CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse,
DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse,
- ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest,
- GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest,
- GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse,
- ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse,
- PlatformEvent, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox,
- SandboxPhase, SandboxResponse, SandboxStreamEvent, ServiceStatus, UpdateProviderRequest,
- WatchSandboxRequest, sandbox_stream_event,
+ ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse,
+ GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse,
+ GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest,
+ HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse,
+ ListSandboxesRequest, ListSandboxesResponse, PlatformEvent, ProviderResponse,
+ RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox, SandboxPhase, SandboxResponse,
+ SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest,
+ sandbox_stream_event,
};
use rcgen::{
BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair,
@@ -156,11 +157,18 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(DeleteSandboxResponse { deleted: true }))
}
- async fn get_sandbox_policy(
+ async fn get_sandbox_config(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
- Ok(Response::new(GetSandboxPolicyResponse::default()))
+ _request: tonic::Request,
+ ) -> Result, Status> {
+ Ok(Response::new(GetSandboxConfigResponse::default()))
+ }
+
+ async fn get_gateway_config(
+ &self,
+ _request: tonic::Request,
+ ) -> Result, Status> {
+ Ok(Response::new(GetGatewayConfigResponse::default()))
}
async fn get_sandbox_provider_environment(
@@ -291,10 +299,10 @@ impl OpenShell for TestOpenShell {
)))
}
- async fn update_sandbox_policy(
+ async fn update_settings(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
+ _request: tonic::Request,
+ ) -> Result, Status> {
Err(Status::unimplemented("not implemented in test"))
}
diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs
index 3fce5d8d..612516a9 100644
--- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs
+++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs
@@ -8,12 +8,12 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
use openshell_core::proto::{
CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse,
DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse,
- ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest,
- GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest,
- GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse,
- ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse,
- ProviderResponse, Sandbox, SandboxResponse, SandboxStreamEvent, ServiceStatus,
- UpdateProviderRequest, WatchSandboxRequest,
+ ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse,
+ GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse,
+ GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest,
+ HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse,
+ ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, Sandbox, SandboxResponse,
+ SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest,
};
use rcgen::{
BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair,
@@ -132,11 +132,18 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(DeleteSandboxResponse { deleted: true }))
}
- async fn get_sandbox_policy(
+ async fn get_sandbox_config(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
- Ok(Response::new(GetSandboxPolicyResponse::default()))
+ _request: tonic::Request,
+ ) -> Result, Status> {
+ Ok(Response::new(GetSandboxConfigResponse::default()))
+ }
+
+ async fn get_gateway_config(
+ &self,
+ _request: tonic::Request,
+ ) -> Result, Status> {
+ Ok(Response::new(GetGatewayConfigResponse::default()))
}
async fn get_sandbox_provider_environment(
@@ -224,10 +231,10 @@ impl OpenShell for TestOpenShell {
)))
}
- async fn update_sandbox_policy(
+ async fn update_settings(
&self,
- _request: tonic::Request,
- ) -> Result, Status> {
+ _request: tonic::Request,
+ ) -> Result, Status> {
Err(Status::unimplemented("not implemented in test"))
}
diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml
index eeedd11a..8bccef54 100644
--- a/crates/openshell-core/Cargo.toml
+++ b/crates/openshell-core/Cargo.toml
@@ -20,6 +20,12 @@ serde = { workspace = true }
serde_json = { workspace = true }
url = { workspace = true }
+[features]
+## Include test-only settings (dummy_bool, dummy_int) in the registry.
+## Off by default so production builds have an empty registry.
+## Enabled by e2e tests and during development.
+dev-settings = []
+
[build-dependencies]
tonic-build = { workspace = true }
protobuf-src = { workspace = true }
diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs
index 9cf0d620..c785b074 100644
--- a/crates/openshell-core/src/lib.rs
+++ b/crates/openshell-core/src/lib.rs
@@ -15,6 +15,7 @@ pub mod forward;
pub mod inference;
pub mod paths;
pub mod proto;
+pub mod settings;
pub use config::{Config, TlsConfig};
pub use error::{Error, Result};
diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs
new file mode 100644
index 00000000..b94c08fc
--- /dev/null
+++ b/crates/openshell-core/src/settings.rs
@@ -0,0 +1,245 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//! Registry for sandbox runtime settings keys and value kinds.
+
+/// Supported value kinds for registered sandbox settings.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SettingValueKind {
+ String,
+ Int,
+ Bool,
+}
+
+impl SettingValueKind {
+ /// Human-readable value kind used in error messages.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::String => "string",
+ Self::Int => "int",
+ Self::Bool => "bool",
+ }
+ }
+}
+
+/// Static descriptor for one registered sandbox setting key.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RegisteredSetting {
+ pub key: &'static str,
+ pub kind: SettingValueKind,
+}
+
+/// Static registry of currently-supported runtime settings.
+///
+/// `policy` is intentionally excluded because it is a reserved key handled by
+/// dedicated policy commands and payloads.
+///
+/// # Adding a new setting
+///
+/// 1. Add a [`RegisteredSetting`] entry to this array with the key name and
+/// [`SettingValueKind`].
+/// 2. Recompile `openshell-server` (gateway) and `openshell-sandbox`
+/// (supervisor). No database migration is needed -- new keys are stored in
+/// the existing settings JSON blob.
+/// 3. Add sandbox-side consumption in `openshell-sandbox` to read and act on
+/// the new key from the poll loop's `SettingsPollResult::settings` map.
+/// 4. The key will automatically appear in `settings get` (CLI/TUI) and be
+/// settable via `settings set`. The server validates that only registered
+/// keys are accepted.
+/// 5. Add a unit test in this module's `tests` section to cover the new key.
+pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[
+ // Production settings go here. Add entries following the steps above.
+ //
+ // Test-only keys live behind the `dev-settings` feature flag so they
+ // don't appear in production builds.
+ #[cfg(feature = "dev-settings")]
+ RegisteredSetting {
+ key: "dummy_int",
+ kind: SettingValueKind::Int,
+ },
+ #[cfg(feature = "dev-settings")]
+ RegisteredSetting {
+ key: "dummy_bool",
+ kind: SettingValueKind::Bool,
+ },
+];
+
+/// Resolve a setting descriptor from the registry by key.
+#[must_use]
+pub fn setting_for_key(key: &str) -> Option<&'static RegisteredSetting> {
+ REGISTERED_SETTINGS.iter().find(|entry| entry.key == key)
+}
+
+/// Return comma-separated registered keys for CLI/API diagnostics.
+#[must_use]
+pub fn registered_keys_csv() -> String {
+ REGISTERED_SETTINGS
+ .iter()
+ .map(|entry| entry.key)
+ .collect::>()
+ .join(", ")
+}
+
+/// Parse common bool-like string values.
+#[must_use]
+pub fn parse_bool_like(raw: &str) -> Option {
+ match raw.trim().to_ascii_lowercase().as_str() {
+ "1" | "true" | "yes" | "y" | "on" => Some(true),
+ "0" | "false" | "no" | "n" | "off" => Some(false),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like,
+ registered_keys_csv, setting_for_key,
+ };
+
+ #[cfg(feature = "dev-settings")]
+ #[test]
+ fn setting_for_key_returns_dev_entries() {
+ let setting = setting_for_key("dummy_bool").expect("dummy_bool should be registered");
+ assert_eq!(setting.kind, SettingValueKind::Bool);
+ let setting = setting_for_key("dummy_int").expect("dummy_int should be registered");
+ assert_eq!(setting.kind, SettingValueKind::Int);
+ }
+
+ #[test]
+ fn setting_for_key_returns_none_for_unknown() {
+ assert!(setting_for_key("nonexistent_key").is_none());
+ }
+
+ #[test]
+ fn setting_for_key_returns_none_for_reserved_policy() {
+ // "policy" is intentionally excluded from the registry.
+ assert!(setting_for_key("policy").is_none());
+ }
+
+ // ---- parse_bool_like ----
+
+ #[test]
+ fn parse_bool_like_accepts_expected_spellings() {
+ for raw in ["1", "true", "yes", "on", "Y"] {
+ assert_eq!(parse_bool_like(raw), Some(true), "expected true for {raw}");
+ }
+ for raw in ["0", "false", "no", "off", "N"] {
+ assert_eq!(
+ parse_bool_like(raw),
+ Some(false),
+ "expected false for {raw}"
+ );
+ }
+ }
+
+ #[test]
+ fn parse_bool_like_case_insensitive() {
+ assert_eq!(parse_bool_like("TRUE"), Some(true));
+ assert_eq!(parse_bool_like("True"), Some(true));
+ assert_eq!(parse_bool_like("FALSE"), Some(false));
+ assert_eq!(parse_bool_like("False"), Some(false));
+ assert_eq!(parse_bool_like("YES"), Some(true));
+ assert_eq!(parse_bool_like("NO"), Some(false));
+ assert_eq!(parse_bool_like("On"), Some(true));
+ assert_eq!(parse_bool_like("Off"), Some(false));
+ }
+
+ #[test]
+ fn parse_bool_like_trims_whitespace() {
+ assert_eq!(parse_bool_like(" true "), Some(true));
+ assert_eq!(parse_bool_like("\tfalse\t"), Some(false));
+ assert_eq!(parse_bool_like(" 1 "), Some(true));
+ assert_eq!(parse_bool_like(" 0 "), Some(false));
+ }
+
+ #[test]
+ fn parse_bool_like_rejects_unrecognized_values() {
+ assert_eq!(parse_bool_like("maybe"), None);
+ assert_eq!(parse_bool_like(""), None);
+ assert_eq!(parse_bool_like("2"), None);
+ assert_eq!(parse_bool_like("nope"), None);
+ assert_eq!(parse_bool_like("yep"), None);
+ assert_eq!(parse_bool_like("enabled"), None);
+ assert_eq!(parse_bool_like("disabled"), None);
+ }
+
+ // ---- REGISTERED_SETTINGS entries ----
+
+ #[test]
+ fn registered_settings_have_valid_kinds() {
+ let valid_kinds = [
+ SettingValueKind::String,
+ SettingValueKind::Int,
+ SettingValueKind::Bool,
+ ];
+ for entry in REGISTERED_SETTINGS {
+ assert!(
+ valid_kinds.contains(&entry.kind),
+ "registered setting '{}' has unexpected kind {:?}",
+ entry.key,
+ entry.kind,
+ );
+ }
+ }
+
+ #[test]
+ fn registered_settings_keys_are_nonempty_and_unique() {
+ let mut seen = std::collections::HashSet::new();
+ for entry in REGISTERED_SETTINGS {
+ assert!(
+ !entry.key.is_empty(),
+ "registered setting key must not be empty"
+ );
+ assert!(
+ seen.insert(entry.key),
+ "duplicate registered setting key '{}'",
+ entry.key,
+ );
+ }
+ }
+
+ #[test]
+ fn registered_settings_excludes_policy() {
+ assert!(
+ !REGISTERED_SETTINGS.iter().any(|e| e.key == "policy"),
+ "policy must not appear in REGISTERED_SETTINGS"
+ );
+ }
+
+ #[test]
+ fn registered_keys_csv_contains_all_keys() {
+ let csv = registered_keys_csv();
+ for entry in REGISTERED_SETTINGS {
+ assert!(
+ csv.contains(entry.key),
+ "registered_keys_csv() missing '{}'",
+ entry.key,
+ );
+ }
+ }
+
+ // ---- SettingValueKind::as_str ----
+
+ #[test]
+ fn setting_value_kind_as_str_returns_expected_labels() {
+ assert_eq!(SettingValueKind::String.as_str(), "string");
+ assert_eq!(SettingValueKind::Int.as_str(), "int");
+ assert_eq!(SettingValueKind::Bool.as_str(), "bool");
+ }
+
+ // ---- RegisteredSetting structural ----
+
+ #[test]
+ fn registered_setting_derives_debug_clone_eq() {
+ let a = RegisteredSetting {
+ key: "test",
+ kind: SettingValueKind::Bool,
+ };
+ let b = a;
+ assert_eq!(a, b);
+ // Debug is exercised implicitly by format!
+ let _ = format!("{a:?}");
+ }
+}
diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs
index a1a0f75b..649bfc08 100644
--- a/crates/openshell-sandbox/src/grpc_client.rs
+++ b/crates/openshell-sandbox/src/grpc_client.rs
@@ -9,9 +9,9 @@ use std::time::Duration;
use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::proto::{
- DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxPolicyRequest,
- GetSandboxProviderEnvironmentRequest, PolicyStatus, ReportPolicyStatusRequest,
- SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, UpdateSandboxPolicyRequest,
+ DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxConfigRequest,
+ GetSandboxProviderEnvironmentRequest, PolicySource, PolicyStatus, ReportPolicyStatusRequest,
+ SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, UpdateSettingsRequest,
inference_client::InferenceClient, open_shell_client::OpenShellClient,
};
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
@@ -101,7 +101,7 @@ async fn fetch_policy_with_client(
sandbox_id: &str,
) -> Result