diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 4c92bc3857..0ed09d9d4b 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -96,7 +96,7 @@ jobs: cli) binary_component=cli binary_name=openshell - features="bundled-z3" + features="" has_image=false ;; *) diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 20d4f0a0c8..c30e879fa2 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -292,7 +292,7 @@ jobs: sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - name: Build ${{ matrix.target }} - run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli --features bundled-z3 + run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli - name: sccache stats if: always() diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index ec95ab5ea1..d27c0ab7fa 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -324,7 +324,7 @@ jobs: sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - name: Build ${{ matrix.target }} - run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli --features bundled-z3 + run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli - name: sccache stats if: always() diff --git a/.github/workflows/rust-native-build.yml b/.github/workflows/rust-native-build.yml index 89993ba12d..cbcafd2018 100644 --- a/.github/workflows/rust-native-build.yml +++ b/.github/workflows/rust-native-build.yml @@ -223,12 +223,6 @@ jobs: OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }} run: | set -euo pipefail - # z3 built with zig c++ uses libc++ symbols (std::__1::*). - # Override z3-sys default (stdc++) so Rust links the matching runtime. - if [[ "${{ inputs.component }}" == "cli" ]]; then - echo "CXXSTDLIB=c++" >> "$GITHUB_ENV" - fi - mise x -- rustup target add "${{ steps.target.outputs.target }}" cargo_cmd=(cargo build) diff --git a/Cargo.lock b/Cargo.lock index 13b670f558..0f0c8ba76d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2429,25 +2429,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -3597,7 +3578,6 @@ dependencies = [ "openshell-bootstrap", "openshell-core", "openshell-policy", - "openshell-prover", "openshell-providers", "openshell-tui", "owo-colors", @@ -3780,10 +3760,7 @@ dependencies = [ name = "openshell-prover" version = "0.0.0" dependencies = [ - "glob", - "include_dir", "miette", - "owo-colors", "serde", "serde_yml", "z3", diff --git a/architecture/security-policy.md b/architecture/security-policy.md index b7f5262e61..4ae7350e83 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -96,6 +96,33 @@ before the child process starts. Gateway-global policy can override sandbox-scoped policy. Use it sparingly because it changes the effective access model for every sandbox on the gateway. +## Managed Maximum + +A gateway can own one optional managed maximum policy. The maximum is a ceiling, +not an active sandbox policy: each sandbox starts with a narrower base policy, +and the gateway proves the fully composed base and provider-derived policy before +creation or any authority-changing update. With no maximum configured, policy +creation, updates, and providers keep their existing unmanaged behavior; accepted +agent proposals remain pending for human review. + +The maximum extends the normal policy YAML with an ID, version, allowed/default +`ask` or `auto` modes, and optional `review.required` annotations. The same +admission operation protects sandbox creation, provider attachment, direct policy +updates, and proposal merges. The initial implementation checks filesystem paths +plus L4 and REST network authority. Explicit denies take precedence; other +protocols and unsupported authority fields fail closed. Providers without a +resolvable policy profile also fail closed because their credential-bearing +reach cannot be included in the proof. Managed maximums initially require +single-replica SQLite storage; PostgreSQL/HA needs database-backed coordination +before it can preserve the proof-to-commit boundary across gateway replicas. + +The sandbox's selected permission mode is fixed at creation. `ask` holds new +agent-proposed authority for review. `auto` applies only the requested delta that +fits the maximum's auto-eligible region; review-marked authority remains pending. +Authenticated direct edits are approval of that exact edit but remain bounded. +Every merge recomputes the candidate from live policy and provider state before +persistence, so a stored proposal or approval is evidence rather than authority. + ## Policy Advisor The policy advisor pipeline turns observed denials into draft policy @@ -107,53 +134,44 @@ through the proposal loop instead of treating the denial as terminal. 1. **Submit.** Both proposers POST through the same `SubmitPolicyAnalysis` path. Each chunk is persisted with its `analysis_mode` for audit provenance. -2. **Validate.** The gateway runs the prover (`openshell-prover`) on every - chunk regardless of mode. The prover builds a Z3 model from the merged - policy plus the sandbox's attached-provider credential set, then computes - the delta of findings between the current baseline and the merged policy. -3. **Auto-approval gate (proposer-agnostic, opt-in).** Auto-approval fires - when *both* (a) the prover delta is empty (`prover: no new findings`) AND - (b) the `proposal_approval_mode` setting resolves to `"auto"` — gateway - scope wins, sandbox scope is the per-sandbox override, default is - `"manual"`. When both hold, the gateway internally invokes the approve - path with actor identity `system:auto`. The audit event uses - `CONFIG:APPROVED` and carries `auto=true`, `source=`, - `prover_delta=empty`, and `resolved_from=` as unmapped - fields, with message text `"auto-approved: no new prover findings"` — - never `safe`. The opt-in gate preserves OpenShell's default-deny - posture: with no setting at either scope, every proposal lands in - `pending` for human review, even when the prover sees no findings. +2. **Validate.** The gateway rejects always-blocked targets and merges each + chunk into the live base policy. If a managed maximum exists, the same + managed-admission operation used by creation, provider attachment, and + direct policy updates returns `apply`, `ask`, or `reject`. +3. **Decide.** Without a managed maximum, every accepted proposal remains + pending. Managed `ask` also holds all in-boundary proposals. Managed `auto` + applies only the requested delta inside the maximum's auto-eligible view; + review-marked authority remains pending and outside or unsupported authority + is rejected. Automatic application emits `CONFIG:APPROVED` with + `auto=true`, `source=`, and `approval_basis=managed_maximum`. 4. **Implicit supersede.** On any successful submission, the gateway scans the sandbox's pending chunks for matches on `(host, port, binary)` and auto-rejects the older ones with reason `"superseded by chunk X"`. This gives the agent a refinement path (broad mechanistic L4 → narrow agent L7) without an explicit `supersedes_chunk_id` field. -5. **Escalation.** Anything else lands in `pending` for human review. - -## What the prover decides +5. **Revalidate and merge.** Automatic and human-approved proposals are + checked again against live policy and provider state before persistence. + Stored decisions are audit evidence, not reusable authority. -The prover answers four formal questions about each proposed policy -change. Each "yes" answer becomes its own categorical finding — there is -no severity grade. Any finding (of any category) blocks auto-approval. -The categories are intended to be (mostly) mutually exclusive per -underlying change: the gateway suppresses `capability_expansion` paths -whose `(binary, host, port)` is also in the `credential_reach_expansion` -delta, so a brand-new credentialed reach surfaces as one finding rather -than one reach + N method findings. +## Credentialed Raw-L4 Advisory -| Category | The prover detects… | -|---|---| -| `link_local_reach` | The proposal grants reach to a host in `169.254.0.0/16`, `fe80::/10`, or a known metadata hostname such as `metadata.google.internal`. Unconditional — cloud-metadata endpoints serve credentials regardless of sandbox state. | -| `l7_bypass_credentialed` | The proposal lets a binary using a non-HTTP wire protocol (`git-remote-https`, `ssh`, `nc`) reach a host where a sandbox credential is in scope. The L7 proxy cannot inspect the wire protocol; the reviewer decides whether to trust the binary with the credential. | -| `credential_reach_expansion` | A binary gained credentialed reach to a (host, port) it could not reach before. New authenticated reach is a stated intent change; the reviewer confirms the binary should authenticate to the host at all. | -| `capability_expansion` | On a (binary, host, port) that already had credentialed reach, the policy adds a new HTTP method. The reviewer sees exactly which method was added (e.g., PUT) and decides if it's part of the agent's task. | - -"Credential in scope" is sandbox-coarse, not binary-fine: a credential is -considered in scope if the sandbox has a provider attached whose -`target_hosts` include the proposed endpoint's host, including runtime-like +For each proposal, the gateway checks whether a raw L4 endpoint overlaps a +host targeted by an attached provider credential. Host overlap includes first-label wildcard coverage such as `*.github.com` covering -`api.github.com`. v1 does not model credential scopes (read-only vs write); -presence is enough. +`api.github.com`. A match adds a deterministic advisory containing the binary, +host, and port. The warning states that raw stream authority cannot be bounded +by an HTTP method or path. + +The advisory is deliberately non-gating. It does not return `apply`, `ask`, or +`reject`, and it cannot override managed admission. This keeps the security +contract singular: the managed maximum decides authority, while the advisory +helps the policy author prefer L7 or consciously accept opaque L4 access. + +Loopback, link-local, unspecified, and known metadata targets are enforced as +always-blocked network invariants and are rejected during proposal validation. +Capability and credential-reach expansion are represented by the candidate +policy and therefore bounded by managed-maximum containment rather than a +second proposal-specific finding model. Proposals intentionally omit `allowed_ips`. If a proposed rule targets a host that resolves to a private IP, the proxy's runtime SSRF classification blocks @@ -161,9 +179,8 @@ the connection. The operator must then add an explicit `allowed_ips` entry to permit it — a two-step flow that keeps SSRF protection on by default. The advisor proposes narrow additions and preserves explicit-deny behavior. -Auto-approval is gated on prover determinism, not human judgment; an LLM-based -contextual reviewer is a deliberate future addition layered on top of the -deterministic prover gate. +Managed containment remains deterministic; contextual review can be added as +an advisory layer without becoming another authority boundary. ## Security Logging diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index 8e4cb3fb25..1cbfd7d474 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -19,7 +19,6 @@ openshell-bootstrap = { path = "../openshell-bootstrap" } openshell-core = { path = "../openshell-core", default-features = false } openshell-policy = { path = "../openshell-policy" } openshell-providers = { path = "../openshell-providers" } -openshell-prover = { path = "../openshell-prover" } openshell-tui = { path = "../openshell-tui" } serde = { workspace = true } serde_json = { workspace = true } @@ -84,9 +83,6 @@ tracing-subscriber = { workspace = true } [lints] workspace = true -[features] -bundled-z3 = ["openshell-prover/bundled-z3"] - [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 e02c7c9cd5..d948facf42 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1289,6 +1289,13 @@ enum SandboxCommands { #[arg(long, value_hint = ValueHint::FilePath)] policy: Option, + /// Managed permission mode for this sandbox. + /// + /// When a managed maximum is configured, omit this flag to use the + /// gateway default or choose `ask` or `auto` explicitly. + #[arg(long, value_parser = ["ask", "auto"])] + permission_mode: Option, + /// Forward a local port to the sandbox before the initial command or shell starts. /// Accepts [`bind_address`:]port (e.g. 8080, 0.0.0.0:8080). Keeps the sandbox alive. #[arg(long, conflicts_with = "no_keep")] @@ -1324,18 +1331,6 @@ enum SandboxCommands { #[arg(long = "env", value_name = "KEY=VALUE")] envs: Vec, - /// Approval mode for agent-authored policy proposals. - /// - /// `manual` (default): every proposal lands in the draft inbox for - /// human review, regardless of the prover verdict. - /// - /// `auto`: proposals whose prover delta is empty are approved - /// automatically; proposals with findings still require human - /// approval. Auto mode is an explicit opt-in — `OpenShell`'s - /// default-deny posture is preserved unless you choose otherwise. - #[arg(long, value_parser = ["manual", "auto"], default_value = "manual")] - approval_mode: String, - /// Command to run after "--" (defaults to an interactive shell). #[arg(last = true, allow_hyphen_values = true)] command: Vec, @@ -1606,6 +1601,10 @@ enum DraftCommands { #[derive(Subcommand, Debug)] enum PolicyCommands { + /// Manage the gateway-owned maximum authority boundary. + #[command(subcommand)] + Maximum(MaximumPolicyCommands), + /// Update policy on a live sandbox. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Set { @@ -1737,29 +1736,36 @@ enum PolicyCommands { #[arg(long)] yes: bool, }, +} - /// Prove properties of a sandbox policy — or find counterexamples. +#[derive(Subcommand, Debug)] +enum MaximumPolicyCommands { + /// Set or replace the managed maximum policy. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] - Prove { - /// Path to `OpenShell` sandbox policy YAML. + Set { + /// Path to the managed maximum policy YAML file. #[arg(long, value_hint = ValueHint::FilePath)] policy: String, - /// Path to credential descriptor YAML. - #[arg(long, value_hint = ValueHint::FilePath)] - credentials: String, - - /// Path to capability registry directory (default: bundled). - #[arg(long, value_hint = ValueHint::DirPath)] - registry: Option, + /// Skip the confirmation prompt. + #[arg(long)] + yes: bool, + }, - /// Path to accepted risks YAML. - #[arg(long, value_hint = ValueHint::FilePath)] - accepted_risks: Option, + /// Show the configured managed maximum policy. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Get { + /// Include the complete YAML policy document. + #[arg(long)] + full: bool, + }, - /// One-line-per-finding output (for demos and CI). + /// Delete the managed maximum policy. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Skip the confirmation prompt. #[arg(long)] - compact: bool, + yes: bool, }, } @@ -2288,28 +2294,6 @@ async fn main() -> Result<()> { // ----------------------------------------------------------- // Top-level policy (was `sandbox policy`) // ----------------------------------------------------------- - Some(Commands::Policy { - command: - Some(PolicyCommands::Prove { - policy, - credentials, - registry, - accepted_risks, - compact, - }), - }) => { - // Prove runs locally — no gateway needed. - let exit_code = openshell_prover::prove( - &policy, - &credentials, - registry.as_deref(), - accepted_risks.as_deref(), - compact, - )?; - if exit_code != 0 { - std::process::exit(exit_code); - } - } Some(Commands::Policy { command: Some(policy_cmd), }) => { @@ -2317,6 +2301,17 @@ async fn main() -> Result<()> { let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); match policy_cmd { + PolicyCommands::Maximum(command) => match command { + MaximumPolicyCommands::Set { policy, yes } => { + run::managed_maximum_set(&ctx.endpoint, &policy, yes, &tls).await?; + } + MaximumPolicyCommands::Get { full } => { + run::managed_maximum_get(&ctx.endpoint, full, &tls).await?; + } + MaximumPolicyCommands::Delete { yes } => { + run::managed_maximum_delete(&ctx.endpoint, yes, &tls).await?; + } + }, PolicyCommands::Set { name, policy, @@ -2429,7 +2424,6 @@ async fn main() -> Result<()> { } run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &tls).await?; } - PolicyCommands::Prove { .. } => unreachable!(), } } @@ -2610,6 +2604,7 @@ async fn main() -> Result<()> { driver_config_json, providers, policy, + permission_mode, forward, tty, no_tty, @@ -2617,7 +2612,6 @@ async fn main() -> Result<()> { no_auto_providers, labels, envs, - approval_mode, command, } => { // Resolve --tty / --no-tty into an Option override. @@ -2697,13 +2691,13 @@ async fn main() -> Result<()> { editor, providers: &providers, policy: policy.as_deref(), + permission_mode: permission_mode.as_deref(), forward, command: &command, tty_override, auto_providers_override, labels: labels_map, environment: env_map, - approval_mode: &approval_mode, }, &tls, )) @@ -4504,57 +4498,49 @@ mod tests { } } - /// `sandbox create` defaults `--approval-mode` to `"manual"`. The CLI - /// always sends an explicit value so the wire form is human-readable - /// (the gateway treats `""` as `"manual"` too, but the CLI's job is to - /// be unambiguous). #[test] - fn sandbox_create_approval_mode_defaults_to_manual() { - let cli = Cli::try_parse_from(["openshell", "sandbox", "create"]) - .expect("sandbox create with no flags should parse"); - match cli.command { + fn sandbox_create_permission_mode_is_optional_and_accepts_managed_modes() { + let default = Cli::try_parse_from(["openshell", "sandbox", "create"]) + .expect("sandbox create should parse"); + assert!(matches!( + default.command, Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { approval_mode, .. }), - .. - }) => { - assert_eq!(approval_mode, "manual"); - } - other => panic!("expected SandboxCommands::Create, got: {other:?}"), - } - } + command: Some(SandboxCommands::Create { + permission_mode: None, + .. + }), + }) + )); - /// `--approval-mode auto` parses through. - #[test] - fn sandbox_create_approval_mode_accepts_auto() { - let cli = - Cli::try_parse_from(["openshell", "sandbox", "create", "--approval-mode", "auto"]) - .expect("--approval-mode auto should parse"); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { approval_mode, .. }), - .. - }) => { - assert_eq!(approval_mode, "auto"); - } - other => panic!("expected SandboxCommands::Create, got: {other:?}"), + for mode in ["ask", "auto"] { + let cli = + Cli::try_parse_from(["openshell", "sandbox", "create", "--permission-mode", mode]) + .expect("managed permission mode should parse"); + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Create { + permission_mode: Some(ref parsed), + .. + }), + }) if parsed == mode + )); } - } - /// `--approval-mode ` is rejected by clap's value parser, so the - /// CLI can't smuggle through a future-mode value that the gateway - /// doesn't yet know about. - #[test] - fn sandbox_create_approval_mode_rejects_unknown_value() { - let result = Cli::try_parse_from([ - "openshell", - "sandbox", - "create", - "--approval-mode", - "auto_on_low_risk", - ]); assert!( - result.is_err(), - "--approval-mode auto_on_low_risk should be rejected until added to the value parser" + Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--permission-mode", + "manual", + ]) + .is_err() + ); + assert!( + Cli::try_parse_from(["openshell", "sandbox", "create", "--approval-mode", "auto",]) + .is_err(), + "the removed unmanaged approval mode must not remain in the CLI" ); } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index f56bb71512..eab83b6382 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -36,24 +36,25 @@ use openshell_core::proto::ProviderProfileCategory; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest, ClearDraftChunksRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, - CreateSandboxRequest, CreateSshSessionRequest, DeleteProviderProfileRequest, - DeleteProviderRefreshRequest, DeleteProviderRequest, DeleteSandboxRequest, - DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest, - GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - GetGatewayConfigRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, - GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, - GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, - HealthRequest, ImportProviderProfilesRequest, LintProviderProfilesRequest, - ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, - ListSandboxProvidersRequest, ListSandboxesRequest, ListServicesRequest, PlatformEvent, - PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, - ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, - RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetClusterInferenceRequest, - SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, - UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, - exec_sandbox_event, setting_value, tcp_forward_init, + CreateSandboxRequest, CreateSshSessionRequest, DeleteManagedMaximumPolicyRequest, + DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, + DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, + ExposeServiceRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, + GetDraftPolicyRequest, GetGatewayConfigRequest, GetManagedMaximumPolicyRequest, + GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRequest, + GetSandboxConfigRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, + GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, HealthRequest, + ImportProviderProfilesRequest, LintProviderProfilesRequest, ListProviderProfilesRequest, + ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxProvidersRequest, + ListSandboxesRequest, ListServicesRequest, PlatformEvent, PolicySource, PolicyStatus, Provider, + ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderProfile, + ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, + ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, + SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse, + SetClusterInferenceRequest, SetManagedMaximumPolicyRequest, SettingScope, SettingValue, + TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, + UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, + setting_value, tcp_forward_init, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_core::{ObjectId, ObjectName}; @@ -502,15 +503,22 @@ fn is_provisioning_progress_event(event: &PlatformEvent) -> bool { } fn print_sandbox_header(sandbox: &Sandbox, display: Option<&ProvisioningDisplay>) { - let lines = [ + let mut lines = vec![ String::new(), format!( "{} {}", "Created sandbox:".cyan().bold(), sandbox.object_name().bold() ), - String::new(), ]; + if let Some(mode) = sandbox.metadata.as_ref().and_then(|metadata| { + metadata + .labels + .get(openshell_core::driver_utils::LABEL_PERMISSION_MODE) + }) { + lines.push(format!("{} {mode} (managed)", "Permission mode:".dimmed())); + } + lines.push(String::new()); match display { Some(d) => { for line in lines { @@ -1769,7 +1777,7 @@ async fn finalize_sandbox_create_session( /// Infrastructure parameters (`server`, `gateway_name`, `tls`) remain positional /// on the function signature, following the `provider_refresh_config(server, input, tls)` /// precedent. This struct captures sandbox-specific options. -#[derive(Debug)] +#[derive(Debug, Default)] pub struct SandboxCreateConfig<'a> { pub name: Option<&'a str>, pub from: Option<&'a str>, @@ -1782,38 +1790,13 @@ pub struct SandboxCreateConfig<'a> { pub editor: Option, pub providers: &'a [String], pub policy: Option<&'a str>, + pub permission_mode: Option<&'a str>, pub forward: Option, pub command: &'a [String], pub tty_override: Option, pub auto_providers_override: Option, pub labels: HashMap, pub environment: HashMap, - pub approval_mode: &'a str, -} - -impl Default for SandboxCreateConfig<'_> { - fn default() -> Self { - Self { - name: None, - from: None, - uploads: &[], - keep: false, - gpu_requirements: None, - cpu: None, - memory: None, - driver_config_json: None, - editor: None, - providers: &[], - policy: None, - forward: None, - command: &[], - tty_override: None, - auto_providers_override: None, - labels: HashMap::new(), - environment: HashMap::new(), - approval_mode: "manual", - } - } } /// Create a sandbox with default settings. @@ -1835,13 +1818,13 @@ pub async fn sandbox_create( editor, providers, policy, + permission_mode, forward, command, tty_override, auto_providers_override, labels, environment, - approval_mode, } = config; if editor.is_some() && !command.is_empty() { @@ -1928,6 +1911,7 @@ pub async fn sandbox_create( }), name: name.unwrap_or_default().to_string(), labels, + permission_mode: permission_mode.unwrap_or_default().to_string(), }; let response = match client.create_sandbox(request).await { @@ -1959,38 +1943,6 @@ pub async fn sandbox_create( let _ = save_last_sandbox(gateway, &sandbox_name); } - // Persist `--approval-mode` as a sandbox-scoped setting now that the - // sandbox exists. `manual` is the implicit default (no setting needed); - // any other value is written so it survives sandbox restarts and can be - // flipped later via `openshell settings set proposal_approval_mode`. - // If the write fails the sandbox still runs in default `manual` — surface - // the recovery command so the user can retry. - if approval_mode != "manual" { - let setting = parse_cli_setting_value(settings::PROPOSAL_APPROVAL_MODE_KEY, approval_mode)?; - match client - .update_config(UpdateConfigRequest { - name: sandbox_name.clone(), - policy: None, - setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), - setting_value: Some(setting), - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, - }) - .await - { - Ok(_) => {} - Err(status) => { - eprintln!( - "{} failed to set approval mode '{approval_mode}' on sandbox '{sandbox_name}': {}\n retry with: openshell settings set {sandbox_name} proposal_approval_mode {approval_mode}", - "warning:".yellow().bold(), - status.message(), - ); - } - } - } - // Set up display — interactive terminals get a step-based checklist with // spinners; non-interactive (pipes / CI) get timestamped lines. let mut display = if interactive { @@ -6219,10 +6171,6 @@ fn parse_cli_setting_value(key: &str, raw_value: &str) -> Result { let value = match setting.kind { SettingValueKind::String => { - // Reject typos client-side so `openshell settings set ... - // proposal_approval_mode autom` errors immediately instead of - // round-tripping through the server. The server enforces the - // same check independently for non-CLI callers. setting .validate_string_value(raw_value) .map_err(|allowed| { @@ -6599,6 +6547,112 @@ pub async fn gateway_setting_delete( Ok(()) } +pub async fn managed_maximum_set( + server: &str, + policy_path: &str, + yes: bool, + tls: &TlsOptions, +) -> Result<()> { + confirm_managed_maximum_change("set", yes)?; + let policy_yaml = std::fs::read(policy_path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read managed maximum policy '{policy_path}'"))?; + let mut client = grpc_client(server, tls).await?; + let response = client + .set_managed_maximum_policy(SetManagedMaximumPolicyRequest { policy_yaml }) + .await + .into_diagnostic()? + .into_inner(); + println!( + "{} Set managed maximum {}@{} (hash {}, revision {})", + "✓".green().bold(), + response.policy_id, + response.version, + short_hash(&response.policy_hash), + response.settings_revision + ); + println!( + " modes: {} (default: {})", + response.allowed_modes.join(", "), + response.default_mode + ); + Ok(()) +} + +pub async fn managed_maximum_get(server: &str, full: bool, tls: &TlsOptions) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_managed_maximum_policy(GetManagedMaximumPolicyRequest {}) + .await + .into_diagnostic()? + .into_inner(); + if !response.configured { + println!("No managed maximum policy configured"); + return Ok(()); + } + println!("Policy: {}@{}", response.policy_id, response.version); + println!("Hash: {}", response.policy_hash); + println!("Modes: {}", response.allowed_modes.join(", ")); + println!("Default mode: {}", response.default_mode); + if !response.audit_label.is_empty() { + println!("Audit label: {}", response.audit_label); + } + println!("Config rev: {}", response.settings_revision); + if full { + println!("---"); + let yaml = std::str::from_utf8(&response.policy_yaml) + .map_err(|_| miette!("gateway returned non-UTF-8 managed maximum policy"))?; + print!("{yaml}"); + if !yaml.ends_with('\n') { + println!(); + } + } + Ok(()) +} + +pub async fn managed_maximum_delete(server: &str, yes: bool, tls: &TlsOptions) -> Result<()> { + confirm_managed_maximum_change("delete", yes)?; + let mut client = grpc_client(server, tls).await?; + let response = client + .delete_managed_maximum_policy(DeleteManagedMaximumPolicyRequest {}) + .await + .into_diagnostic()? + .into_inner(); + if response.deleted { + println!( + "{} Deleted managed maximum policy (revision {})", + "✓".green().bold(), + response.settings_revision + ); + } else { + println!("{} Managed maximum policy not found", "!".yellow()); + } + Ok(()) +} + +fn confirm_managed_maximum_change(action: &str, yes: bool) -> Result<()> { + if yes { + return Ok(()); + } + if !std::io::stdin().is_terminal() { + return Err(miette!( + "refusing to {action} managed maximum policy without confirmation; pass --yes" + )); + } + let proceed = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(format!( + "{action} the gateway managed maximum policy? Existing sandboxes must not be present" + )) + .default(false) + .interact() + .into_diagnostic()?; + if proceed { + Ok(()) + } else { + Err(miette!("managed maximum policy change cancelled")) + } +} + pub async fn sandbox_setting_delete( server: &str, name: &str, diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7bf8612b4a..2afd6d37af 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -75,6 +75,27 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn set_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn get_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn delete_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + async fn health( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 28a4e6c9c3..16de78cb4a 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -33,6 +33,27 @@ struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn set_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn get_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn delete_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + async fn health( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 5a6e53eb15..58b3ff9e25 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -96,6 +96,27 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn set_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn get_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn delete_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + async fn health( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index ec8bd53743..a4e0a1634e 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -57,6 +57,27 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn set_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn get_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn delete_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + async fn health( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 8e799f821e..611439bc54 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -48,6 +48,27 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn set_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn get_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn delete_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + async fn health( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 9e4411b2a1..7b6b436388 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -27,6 +27,9 @@ pub const LABEL_SANDBOX_NAME: &str = "openshell.ai/sandbox-name"; /// Container/pod label carrying the sandbox namespace. pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.ai/sandbox-namespace"; +/// Sandbox metadata label carrying its immutable managed permission mode. +pub const LABEL_PERMISSION_MODE: &str = "openshell.ai/permission-mode"; + // --------------------------------------------------------------------------- /// Path to the sandbox supervisor binary inside the container image. diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 156e4c3845..0307d3a501 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -85,28 +85,6 @@ pub const PROVIDERS_V2_ENABLED_KEY: &str = "providers_v2_enabled"; /// still applies when this flag is on. pub const AGENT_POLICY_PROPOSALS_ENABLED_KEY: &str = "agent_policy_proposals_enabled"; -/// Approval mode for agent-authored policy proposals. -/// -/// `"manual"` (the default when unset): every proposal lands in the draft -/// inbox for human review, regardless of the prover verdict. `"auto"`: -/// proposals whose prover delta is empty are approved automatically; -/// proposals with findings still require human approval. Any other value -/// (typos, future-reserved modes like `"auto_on_low_risk"`) falls back to -/// manual — auto mode is an explicit, exact opt-in. -/// -/// Resolution precedence (matches the rest of the settings model): gateway -/// scope wins over sandbox scope. A reviewer can pin manual mode for a -/// fleet by setting it globally; per-sandbox overrides only apply when no -/// global is set. -pub const PROPOSAL_APPROVAL_MODE_KEY: &str = "proposal_approval_mode"; - -/// Allowed values for [`PROPOSAL_APPROVAL_MODE_KEY`]. -/// -/// Any other string is rejected at configure time (so operators get immediate -/// feedback on typos like `"autom"`) while the runtime resolver still -/// fail-closes on unknown persisted values for defense in depth. -pub const PROPOSAL_APPROVAL_MODE_VALUES: &[&str] = &["manual", "auto"]; - pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ // Gateway-level opt-in for provider profile policy composition. Defaults // to false when unset. @@ -130,13 +108,6 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ kind: SettingValueKind::Bool, allowed_string_values: None, }, - // Approval mode for agent-authored proposals. See - // PROPOSAL_APPROVAL_MODE_KEY for details. Defaults to manual. - RegisteredSetting { - key: PROPOSAL_APPROVAL_MODE_KEY, - kind: SettingValueKind::String, - allowed_string_values: Some(PROPOSAL_APPROVAL_MODE_VALUES), - }, ]; /// Resolve a setting descriptor from the registry by key. @@ -168,9 +139,8 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, PROVIDERS_V2_ENABLED_KEY, - REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, - registered_keys_csv, setting_for_key, + PROVIDERS_V2_ENABLED_KEY, REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, + parse_bool_like, registered_keys_csv, setting_for_key, }; #[test] @@ -203,40 +173,6 @@ mod tests { assert!(setting.validate_string_value("").is_ok()); } - #[test] - fn proposal_approval_mode_accepts_manual_and_auto() { - let setting = setting_for_key(PROPOSAL_APPROVAL_MODE_KEY) - .expect("proposal_approval_mode should be registered"); - assert_eq!(setting.kind, SettingValueKind::String); - assert_eq!( - setting.allowed_string_values, - Some(PROPOSAL_APPROVAL_MODE_VALUES) - ); - assert!(setting.validate_string_value("manual").is_ok()); - assert!(setting.validate_string_value("auto").is_ok()); - } - - #[test] - fn proposal_approval_mode_rejects_typos_and_future_modes() { - let setting = setting_for_key(PROPOSAL_APPROVAL_MODE_KEY) - .expect("proposal_approval_mode should be registered"); - for bad in [ - "autom", - "AUTO", - "Manual", - "", - " auto", - "auto_on_low_risk", - "yes", - ] { - let err = setting - .validate_string_value(bad) - .expect_err(&format!("expected '{bad}' to be rejected")); - // Caller gets the allowed slice back for diagnostics. - assert_eq!(err, PROPOSAL_APPROVAL_MODE_VALUES); - } - } - // ---- parse_bool_like ---- #[test] diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 3143b6d68e..2627a1bf27 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -316,10 +316,10 @@ impl OcsfEvent { ); let what = e.base.message.as_deref().unwrap_or("config"); // Bracketed suffix carries the structured provenance fields a - // reviewer needs to scan a CONFIG audit line. Auto-approval - // emits `auto`/`source`/`prover_delta`; every config change - // also carries `policy_version` and `policy_hash`. Order is - // stable so logs are greppable. + // reviewer needs to scan a CONFIG audit line. Managed + // automatic application emits `auto`/`source`/`approval_basis`; + // every config change also carries `policy_version` and + // `policy_hash`. Order is stable so logs are greppable. let suffix = e .base .unmapped @@ -333,6 +333,7 @@ impl OcsfEvent { }; push("auto"); push("source"); + push("approval_basis"); push("prover_delta"); push("resolved_from"); if let Some(ver) = u.get("policy_version").and_then(|v| v.as_str()) { @@ -916,17 +917,15 @@ mod tests { ); } - /// Auto-approval audit events carry `auto`, `source`, `prover_delta`, and - /// `resolved_from` as unmapped fields. Lock the suffix order so operators - /// (and the demo's grep) can rely on it. + /// Managed automatic application audit events carry the approval basis as + /// an unmapped field. Lock the suffix order so operators can rely on it. #[test] - fn test_config_state_change_shorthand_includes_auto_approve_fields() { + fn test_config_state_change_shorthand_includes_managed_apply_fields() { let mut b = base(5019, "Device Config State Change", 5, "Discovery", 1, "Log"); - b.set_message("auto-approved: no new prover findings (source=agent_authored)"); + b.set_message("managed maximum auto-applied proposal (source=agent_authored)"); b.add_unmapped("auto", serde_json::json!("true")); b.add_unmapped("source", serde_json::json!("agent_authored")); - b.add_unmapped("prover_delta", serde_json::json!("empty")); - b.add_unmapped("resolved_from", serde_json::json!("sandbox")); + b.add_unmapped("approval_basis", serde_json::json!("managed_maximum")); b.add_unmapped("policy_version", serde_json::json!("v4")); b.add_unmapped("policy_hash", serde_json::json!("sha256:cafe")); @@ -941,8 +940,8 @@ mod tests { let shorthand = event.format_shorthand(); assert_eq!( shorthand, - "CONFIG:APPROVED [INFO] auto-approved: no new prover findings (source=agent_authored) \ - [auto:true source:agent_authored prover_delta:empty resolved_from:sandbox \ + "CONFIG:APPROVED [INFO] managed maximum auto-applied proposal (source=agent_authored) \ + [auto:true source:agent_authored approval_basis:managed_maximum \ version:v4 hash:sha256:cafe]" ); } diff --git a/crates/openshell-prover/Cargo.toml b/crates/openshell-prover/Cargo.toml index ee815f3a3f..ff53ed72e7 100644 --- a/crates/openshell-prover/Cargo.toml +++ b/crates/openshell-prover/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "openshell-prover" -description = "Formal policy verification for OpenShell sandboxes" +description = "Managed maximum policy containment for OpenShell sandboxes" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -18,9 +18,6 @@ z3 = { workspace = true } serde = { workspace = true } serde_yml = { workspace = true } miette = { workspace = true } -owo-colors = { workspace = true } -include_dir = { workspace = true } -glob = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-prover/README.md b/crates/openshell-prover/README.md index f8b45eca61..859e93fbb9 100644 --- a/crates/openshell-prover/README.md +++ b/crates/openshell-prover/README.md @@ -3,134 +3,75 @@ # openshell-prover -Formal verifier for OpenShell sandbox policies. Encodes a policy + its -attached credential set + a binary capability registry as a Z3 SMT -model, then runs reachability queries to detect credentialed-reach and -capability changes a reviewer should be aware of. - -Used by the gateway to gate auto-approval of agent-authored policy -proposals: any finding blocks auto-approval, an empty delta lets the -chunk pass through (when the reviewer opts in via the -`proposal_approval_mode` setting at either gateway or sandbox scope). +Formal containment verifier for OpenShell managed maximum policies. It encodes +a candidate policy and the gateway-managed maximum as a Z3 model and looks for +one concrete action the candidate allows outside the maximum. ## What it decides -The prover answers four formal questions. Each "yes" answer is its own -categorical finding — there is no severity grade. The categories live -in [`finding::category`](src/finding.rs). - -| Category | Question the prover decides | -|---|---| -| `link_local_reach` | Does this policy grant reach to a host in `169.254.0.0/16` or `fe80::/10`? | -| `l7_bypass_credentialed` | Does it let a binary using a non-HTTP wire protocol (per the binary registry's `bypasses_l7` flag) reach a host where a credential is in scope? | -| `credential_reach_expansion` | Does it let a binary reach a (host, port) with a credential in scope, where the binary couldn't reach that endpoint before? | -| `capability_expansion` | On a (binary, host, port) the binary already reaches with credentials, does it add a new HTTP method? | - -The first two are unconditional risks. The latter two are *delta* -properties — the gateway runs the prover on both the baseline policy -and the merged policy and surfaces only the new paths. - -## Evidence shape - -Each finding carries one or more [`FindingPath::Exfil`](src/finding.rs) -entries: +The public API exposes two checks: ```rust -pub struct ExfilPath { - pub binary: String, - pub endpoint_host: String, - pub endpoint_port: u16, - pub mechanism: String, // human-readable description - pub policy_name: String, // rule the path traverses - pub category: String, // one of the category constants - pub method: String, // populated for capability_expansion; empty otherwise -} +check_within_maximum(maximum, candidate) +check_within_auto_eligible_maximum(maximum, requested_delta) ``` -The gateway's `finding_delta` keys paths by `(category, binary, -host:port, category, method)` so that adding a new method on an -already-reached host surfaces as exactly one new path (not the whole -re-emission of the existing method set). - -### Category suppression at the delta layer +The first check proves the whole composed candidate fits inside the maximum. +The second proves a proposal's requested delta fits the maximum after removing +authority marked `review.required`. Managed admission uses the result to +return `apply`, `ask`, or `reject`; there is no separate proposal-risk gate. -`capability_expansion` paths whose `(binary, host, port)` tuple is also -in the `credential_reach_expansion` delta are suppressed by the -gateway. A brand-new credentialed reach is described by the -reach-expansion finding alone, not also by N per-method findings. +The initial model covers: -## Adding a new category +- filesystem read-only and read-write paths; +- raw L4 authority over binary, host, and port; +- enforced REST authority over binary, host, port, method, and path; +- explicit REST deny rules; and +- endpoint- or allow-level `review.required` annotations in the maximum. -1. Add a constant to `src/finding.rs::category`. -2. In `src/queries.rs::check_credential_safety`, add the branch that - detects the new category and emits one `ExfilPath` per evidence - row. Set `path.category` to the new constant. -3. In `src/report.rs::format_path_line`, add a `match` arm rendering - the per-path display string the reviewer sees. -4. (Gateway) If the new category should be suppressed by another, add - the suppression rule to `crates/openshell-server/src/grpc/policy.rs::finding_delta`. -5. Add a unit test in `src/queries.rs` and an integration test in - `crates/openshell-server/src/grpc/policy.rs::tests`. - -The four v1 categories cover the formal properties the OpenShell -auto-approval gate cares about today. Additional categories (e.g., -"destructive method introduced," "new outbound TLS without SNI") would -be additive — they don't displace existing categories. +Unsupported protocols or fields return `Unsupported` so managed admission +fails closed instead of silently ignoring authority. ## What the prover does *not* decide -- **Semantic risk of an action.** The prover models *can the binary do - this?*, not *is this destructive?*. `PUT /repos/.../contents/file.md` - and `GET /repos/.../contents/file.md` are both authenticated actions; - the reviewer (or a downstream layer like an LLM contextual reviewer - or an intent file) decides if the action is desired. -- **Cross-sandbox or cross-binary intent.** The model is per-sandbox. - If two sandboxes share a credential through external policy, the - prover reasons about each independently. -- **Runtime behavior.** The prover analyzes the policy as written; it - doesn't observe the proxy's actual decisions. The proxy is the - enforcement layer; the prover is the change-review layer. +- **Whether an action is desirable.** The maximum declares delegated + authority; reviewers still own intent inside review-required regions. +- **Runtime network decisions.** The sandbox policy engine and proxy enforce + the active policy. This crate proves containment before the gateway commits + an authority-changing operation. +- **Credential secrecy or protocol inspection.** Provider policy is composed + into the candidate by the gateway. A separate non-gating advisory warns when + a proposal grants raw L4 access to a provider credential target. +- **Multiple simultaneous maximums.** The gateway currently owns one maximum. ## Inputs -- **Policy** — a `SandboxPolicy` proto, parsed via - `openshell-policy::parse_sandbox_policy`. -- **Credential set** — built from the sandbox's attached providers in - `crates/openshell-server/src/grpc/policy.rs::build_credential_set_for_sandbox`. - v1 captures presence only (host-coarse); no scope modeling. -- **Binary registry** — YAML descriptors at - `crates/openshell-prover/registry/binaries/*.yaml`. Each describes - the binary's protocols, `bypasses_l7` flag, and `can_exfiltrate` - capability. +- **Maximum** — managed policy YAML parsed into [`PolicyModel`](src/policy.rs), + including metadata and review annotations. +- **Candidate** — the fully composed sandbox and provider policy for whole + policy containment. +- **Requested delta** — the proposal-specific authority used for automatic + eligibility checks. ## Outputs -- A list of `Finding` values, one per fired category. Each finding's - `query` field holds the category name. -- The CLI renderer (`report::render_compact` / `render_report`) prints - human-readable output for the `openshell-prover` binary. -- The gateway calls `report::finding_shorthand` to build the - `validation_result` string persisted on each draft chunk. +- `WithinMax` when no candidate action exists outside the selected maximum + view. +- `ExceedsMax` with a concrete filesystem or network counterexample. +- `Unsupported` with the field or protocol that prevented a sound proof. ## Z3 model layout -See `src/model.rs`. Briefly: - -- Bool sorts per `(binary, endpoint)` pair encode policy reachability, - filtered by binary capability flags (`can_exfiltrate`, - `bypasses_l7`). -- Bool sorts per `(binary, host)` encode credential-in-scope (one - credential set per sandbox). -- The reachability formula composes these into the SAT query the - `queries::check_credential_safety` loop iterates over. +[`envelope.rs`](src/envelope.rs) creates one symbolic action over binary, +host, port, layer, method, and path. The solver asks whether the candidate +allows that action while the selected maximum view does not. A satisfiable +model becomes the counterexample returned to managed admission. Filesystem +containment is checked directly over normalized path patterns. ## Tests -- Unit tests in each module (`src/queries.rs`, `src/report.rs`, - `src/policy.rs`) cover individual primitives and category emission. -- Integration tests in `src/lib.rs::tests` exercise the full - parse → build_model → run_all_queries pipeline against testdata - policies in `testdata/`. -- Gateway-level acceptance tests in - `crates/openshell-server/src/grpc/policy.rs::tests` lock in the - end-to-end `validation_result` shape and the auto-approval gate. +- [`policy.rs`](src/policy.rs) tests strict parsing and managed metadata. +- [`envelope.rs`](src/envelope.rs) tests containment, review filtering, + explicit denies, filesystem access, and fail-closed unsupported fields. +- Gateway tests cover proposal, provider, creation, and update admission using + the same managed maximum. diff --git a/crates/openshell-prover/registry/apis/github.yaml b/crates/openshell-prover/registry/apis/github.yaml deleted file mode 100644 index 78fa15c936..0000000000 --- a/crates/openshell-prover/registry/apis/github.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -api: github -host: api.github.com -port: 443 -credential_type: github-pat - -scope_capabilities: - repo: - - { method: GET, path: "/repos/**", action: read_repo } - - { method: GET, path: "/user/repos", action: list_repos } - - { method: POST, path: "/repos/*/issues", action: create_issue } - - { method: POST, path: "/repos/*/issues/*/comments", action: comment_issue } - - { method: POST, path: "/repos/*/pulls", action: create_pr } - - { method: PUT, path: "/repos/*/contents/**", action: write_file } - - { method: DELETE, path: "/repos/*/contents/**", action: delete_file } - - { method: DELETE, path: "/repos/*/*", action: delete_repo } - - { method: POST, path: "/repos/*/git/**", action: write_git_data } - - { method: POST, path: "/repos/*/forks", action: fork_repo } - - { method: PATCH, path: "/repos/*/*", action: update_repo } - - { method: POST, path: "/repos/*/releases", action: create_release } - - { method: POST, path: "/repos/*/dispatches", action: trigger_workflow } - "read:org": - - { method: GET, path: "/orgs/**", action: read_org } - - { method: GET, path: "/user/orgs", action: list_orgs } - "write:org": - - { method: GET, path: "/orgs/**", action: read_org } - - { method: POST, path: "/orgs/*/repos", action: create_org_repo } - - { method: PUT, path: "/orgs/*/memberships/**", action: manage_members } - gist: - - { method: GET, path: "/gists/**", action: read_gists } - - { method: POST, path: "/gists", action: create_gist } - - { method: PATCH, path: "/gists/*", action: update_gist } - - { method: DELETE, path: "/gists/*", action: delete_gist } - "admin:repo_hook": - - { method: GET, path: "/repos/*/hooks/**", action: read_hooks } - - { method: POST, path: "/repos/*/hooks", action: create_hook } - - { method: DELETE, path: "/repos/*/hooks/*", action: delete_hook } - -action_risk: - read_repo: low - list_repos: low - create_issue: medium - comment_issue: medium - create_pr: medium - write_file: high - delete_file: high - delete_repo: critical - write_git_data: high - fork_repo: medium - update_repo: high - create_release: high - trigger_workflow: critical - read_org: low - list_orgs: low - create_org_repo: high - manage_members: critical - read_gists: low - create_gist: medium - update_gist: medium - delete_gist: medium - read_hooks: low - create_hook: critical - delete_hook: high diff --git a/crates/openshell-prover/registry/binaries/claude.yaml b/crates/openshell-prover/registry/binaries/claude.yaml deleted file mode 100644 index e9d1b1d316..0000000000 --- a/crates/openshell-prover/registry/binaries/claude.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/local/bin/claude -description: "Claude Code CLI — AI coding agent" -protocols: - - name: anthropic-api - transport: https - description: "Anthropic API calls for inference" - bypasses_l7: false - actions: - - { name: inference, type: write, description: "Send prompts and receive completions" } - - name: http-via-tools - transport: https - description: "Claude can make HTTP requests via tool use (Bash, WebFetch)" - bypasses_l7: false - actions: - - { name: get, type: read } - - { name: post, type: write } - - { name: put, type: write } -spawns: - - /usr/local/bin/node - - /usr/local/bin/python3.13 - - /usr/bin/git - - /usr/bin/curl - - /usr/bin/ssh - - /usr/bin/nc -can_exfiltrate: true -exfil_mechanism: "Can read files and send contents via tool use, API calls, or spawned subprocesses" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/curl.yaml b/crates/openshell-prover/registry/binaries/curl.yaml deleted file mode 100644 index 4cfadb8aca..0000000000 --- a/crates/openshell-prover/registry/binaries/curl.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/curl -description: "Command-line HTTP client" -protocols: - - name: http - transport: https - description: "HTTP/HTTPS requests — can construct arbitrary method, path, headers, body" - bypasses_l7: false - actions: - - { name: get, type: read, description: "HTTP GET request" } - - { name: post, type: write, description: "HTTP POST with arbitrary body" } - - { name: put, type: write, description: "HTTP PUT with arbitrary body" } - - { name: delete, type: destructive, description: "HTTP DELETE request" } - - { name: upload, type: write, description: "File upload via multipart POST" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "POST/PUT file contents to any reachable endpoint, or encode in URL query parameters" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/gh.yaml b/crates/openshell-prover/registry/binaries/gh.yaml deleted file mode 100644 index 8ad7cb3923..0000000000 --- a/crates/openshell-prover/registry/binaries/gh.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/gh -description: "GitHub CLI — REST API client for GitHub" -protocols: - - name: github-rest - transport: https - description: "GitHub REST API via gh cli — uses standard HTTP, subject to L7 inspection" - bypasses_l7: false - actions: - - { name: api_read, type: read, description: "GET requests to GitHub API" } - - { name: api_write, type: write, description: "POST/PUT/PATCH requests (create issues, PRs, etc.)" } - - { name: api_delete, type: destructive, description: "DELETE requests (delete repos, branches, etc.)" } -spawns: - - /usr/bin/git -can_exfiltrate: true -exfil_mechanism: "Create gists, issues, or PRs with file contents" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/git.yaml b/crates/openshell-prover/registry/binaries/git.yaml deleted file mode 100644 index 2c3480cbe6..0000000000 --- a/crates/openshell-prover/registry/binaries/git.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/git -description: "Distributed version control system" -protocols: - - name: git-smart-http - transport: https - description: "Git smart HTTP protocol over HTTPS CONNECT tunnel — not REST, bypasses L7 HTTP inspection" - bypasses_l7: true - actions: - - { name: clone, type: read, description: "Clone/fetch repository" } - - { name: push, type: write, description: "Push commits to remote" } - - { name: force_push, type: destructive, description: "Force push, rewriting remote history" } - - name: git-ssh - transport: ssh - description: "Git over SSH" - bypasses_l7: true - actions: - - { name: clone, type: read } - - { name: push, type: write } - - { name: force_push, type: destructive } -spawns: - - /usr/lib/git-core/git-remote-https - - /usr/bin/ssh -can_exfiltrate: true -exfil_mechanism: "Commit data to repo and push, or encode in branch/tag names" -can_construct_http: false diff --git a/crates/openshell-prover/registry/binaries/nc.yaml b/crates/openshell-prover/registry/binaries/nc.yaml deleted file mode 100644 index 8f98d76399..0000000000 --- a/crates/openshell-prover/registry/binaries/nc.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/nc -description: "Netcat — arbitrary TCP/UDP connections" -protocols: - - name: raw-tcp - transport: tcp - description: "Raw TCP connection — can send/receive arbitrary data" - bypasses_l7: true - actions: - - { name: connect, type: read, description: "Establish TCP connection" } - - { name: send, type: write, description: "Send arbitrary data over TCP" } - - { name: listen, type: read, description: "Listen for incoming connections" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "Stream arbitrary data over raw TCP connection" -can_construct_http: false diff --git a/crates/openshell-prover/registry/binaries/node.yaml b/crates/openshell-prover/registry/binaries/node.yaml deleted file mode 100644 index cfa83f2beb..0000000000 --- a/crates/openshell-prover/registry/binaries/node.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/local/bin/node -description: "Node.js runtime — general-purpose JavaScript runtime" -protocols: - - name: http-programmatic - transport: https - description: "Node can construct arbitrary HTTP requests via fetch, axios, http module" - bypasses_l7: false - actions: - - { name: get, type: read } - - { name: post, type: write } - - { name: put, type: write } - - { name: delete, type: destructive } -spawns: - - /usr/bin/curl - - /usr/bin/git -can_exfiltrate: true -exfil_mechanism: "Construct arbitrary HTTP requests, spawn subprocesses" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/python3.yaml b/crates/openshell-prover/registry/binaries/python3.yaml deleted file mode 100644 index 3cdf9cd3b6..0000000000 --- a/crates/openshell-prover/registry/binaries/python3.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/local/bin/python3.13 -description: "Python interpreter — general-purpose runtime" -protocols: - - name: http-programmatic - transport: https - description: "Python can construct arbitrary HTTP requests via urllib, httpx, requests" - bypasses_l7: false - actions: - - { name: get, type: read } - - { name: post, type: write } - - { name: put, type: write } - - { name: delete, type: destructive } -spawns: - - /usr/bin/curl - - /usr/bin/git - - /usr/bin/ssh - - /usr/bin/nc - - /usr/bin/wget -can_exfiltrate: true -exfil_mechanism: "Construct arbitrary HTTP requests, write to files, spawn subprocesses" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/ssh.yaml b/crates/openshell-prover/registry/binaries/ssh.yaml deleted file mode 100644 index b53065d884..0000000000 --- a/crates/openshell-prover/registry/binaries/ssh.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/ssh -description: "OpenSSH client" -protocols: - - name: ssh - transport: ssh - description: "SSH protocol — encrypted tunnel, can forward ports and transfer files" - bypasses_l7: true - actions: - - { name: connect, type: read, description: "SSH shell connection" } - - { name: tunnel, type: write, description: "Port forwarding / tunnel" } - - { name: scp, type: write, description: "File transfer via SCP" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "SCP files or pipe data through SSH tunnel" -can_construct_http: false diff --git a/crates/openshell-prover/registry/binaries/wget.yaml b/crates/openshell-prover/registry/binaries/wget.yaml deleted file mode 100644 index 0741998b75..0000000000 --- a/crates/openshell-prover/registry/binaries/wget.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/wget -description: "Non-interactive network downloader" -protocols: - - name: http - transport: https - description: "HTTP/HTTPS downloads — can also POST with --post-data" - bypasses_l7: false - actions: - - { name: download, type: read } - - { name: post, type: write, description: "POST via --post-data or --post-file" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "POST file contents via --post-file to any reachable endpoint" -can_construct_http: true diff --git a/crates/openshell-prover/src/accepted_risks.rs b/crates/openshell-prover/src/accepted_risks.rs deleted file mode 100644 index 8c28a44188..0000000000 --- a/crates/openshell-prover/src/accepted_risks.rs +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Load and match accepted risk annotations against findings. - -use std::path::Path; - -use miette::{IntoDiagnostic, Result, WrapErr}; -use serde::Deserialize; - -use crate::finding::{Finding, FindingPath}; - -// --------------------------------------------------------------------------- -// Serde types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct AcceptedRisksFile { - #[serde(default)] - accepted_risks: Vec, -} - -#[derive(Debug, Deserialize)] -struct AcceptedRiskDef { - #[serde(default)] - query: String, - #[serde(default)] - reason: String, - #[serde(default)] - accepted_by: String, - #[serde(default)] - binary: String, - #[serde(default)] - endpoint: String, -} - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/// An explicitly accepted risk annotation. -#[derive(Debug, Clone)] -pub struct AcceptedRisk { - pub query: String, - pub reason: String, - pub accepted_by: String, - pub binary: String, - pub endpoint: String, -} - -// --------------------------------------------------------------------------- -// Loading -// --------------------------------------------------------------------------- - -/// Load accepted risks from a YAML file. -pub fn load_accepted_risks(path: &Path) -> Result> { - let contents = std::fs::read_to_string(path) - .into_diagnostic() - .wrap_err_with(|| format!("reading accepted risks {}", path.display()))?; - let raw: AcceptedRisksFile = serde_yml::from_str(&contents) - .into_diagnostic() - .wrap_err("parsing accepted risks YAML")?; - - Ok(raw - .accepted_risks - .into_iter() - .map(|r| AcceptedRisk { - query: r.query, - reason: r.reason, - accepted_by: r.accepted_by, - binary: r.binary, - endpoint: r.endpoint, - }) - .collect()) -} - -// --------------------------------------------------------------------------- -// Matching -// --------------------------------------------------------------------------- - -/// Check if a single finding path matches an accepted risk. -fn path_matches_risk(path: &FindingPath, risk: &AcceptedRisk) -> bool { - let FindingPath::Exfil(p) = path; - if !risk.binary.is_empty() && p.binary != risk.binary { - return false; - } - if !risk.endpoint.is_empty() && p.endpoint_host != risk.endpoint { - return false; - } - true -} - -/// Mark findings as accepted where they match accepted risk annotations. -/// -/// A finding is accepted if **all** of its paths match at least one accepted -/// risk entry for that query. If only some paths match, the finding stays -/// active with the unmatched paths. -pub fn apply_accepted_risks(findings: Vec, accepted: &[AcceptedRisk]) -> Vec { - if accepted.is_empty() { - return findings; - } - - let mut result = Vec::new(); - for finding in findings { - let matching_risks: Vec<&AcceptedRisk> = accepted - .iter() - .filter(|r| r.query == finding.query) - .collect(); - - if matching_risks.is_empty() { - result.push(finding); - continue; - } - - if finding.paths.is_empty() { - // Pathless finding — accept if query matches. - result.push(Finding { - accepted: true, - accepted_reason: matching_risks[0].reason.clone(), - ..finding - }); - continue; - } - - let mut unmatched_paths = Vec::new(); - let mut matched_reason = String::new(); - for path in &finding.paths { - let mut path_accepted = false; - for risk in &matching_risks { - if path_matches_risk(path, risk) { - path_accepted = true; - matched_reason.clone_from(&risk.reason); - break; - } - } - if !path_accepted { - unmatched_paths.push(path.clone()); - } - } - - if unmatched_paths.is_empty() { - result.push(Finding { - accepted: true, - accepted_reason: matched_reason, - ..finding - }); - } else if unmatched_paths.len() < finding.paths.len() { - result.push(Finding { - paths: unmatched_paths, - ..finding - }); - } else { - result.push(finding); - } - } - result -} diff --git a/crates/openshell-prover/src/credentials.rs b/crates/openshell-prover/src/credentials.rs deleted file mode 100644 index 586d0fbbf9..0000000000 --- a/crates/openshell-prover/src/credentials.rs +++ /dev/null @@ -1,460 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Credential descriptors and API capability registries. - -use std::collections::HashMap; -use std::path::Path; - -use miette::{IntoDiagnostic, Result, WrapErr}; -use serde::Deserialize; - -// --------------------------------------------------------------------------- -// Serde types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct CredentialsFile { - #[serde(default)] - credentials: Vec, -} - -#[derive(Debug, Deserialize)] -struct CredentialDef { - #[serde(default)] - name: String, - #[serde(default, rename = "type")] - cred_type: String, - #[serde(default)] - scopes: Vec, - #[serde(default)] - injected_via: String, - #[serde(default)] - target_hosts: Vec, -} - -#[derive(Debug, Deserialize)] -struct ApiRegistryDef { - #[serde(default)] - api: String, - #[serde(default)] - host: String, - #[serde(default)] - port: u32, - #[serde(default)] - credential_type: String, - #[serde(default)] - scope_capabilities: HashMap>, - #[serde(default)] - action_risk: HashMap, -} - -#[derive(Debug, Deserialize)] -struct ApiActionDef { - #[serde(default)] - method: String, - #[serde(default)] - path: String, - #[serde(default)] - action: String, -} - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/// A credential injected into the sandbox. -#[derive(Debug, Clone)] -pub struct Credential { - pub name: String, - pub cred_type: String, - pub scopes: Vec, - pub injected_via: String, - pub target_hosts: Vec, -} - -/// A single API action (HTTP method + path + semantic name). -#[derive(Debug, Clone)] -pub struct ApiAction { - pub method: String, - pub path: String, - pub action: String, -} - -/// Capability registry for an API (e.g., GitHub REST API). -#[derive(Debug, Clone)] -pub struct ApiCapability { - pub api: String, - pub host: String, - pub port: u32, - pub credential_type: String, - pub scope_capabilities: HashMap>, - pub action_risk: HashMap, -} - -impl ApiCapability { - /// All actions enabled by the given scopes. - pub fn actions_for_scopes(&self, scopes: &[String]) -> Vec<&ApiAction> { - let mut result = Vec::new(); - for scope in scopes { - if let Some(actions) = self.scope_capabilities.get(scope) { - result.extend(actions.iter()); - } - } - result - } - - /// Write actions (POST, PUT, PATCH, DELETE) enabled by the given scopes. - pub fn write_actions_for_scopes(&self, scopes: &[String]) -> Vec<&ApiAction> { - self.actions_for_scopes(scopes) - .into_iter() - .filter(|a| { - let m = a.method.to_uppercase(); - m == "POST" || m == "PUT" || m == "PATCH" || m == "DELETE" - }) - .collect() - } - - /// Destructive actions (high or critical risk) enabled by the given scopes. - pub fn destructive_actions_for_scopes(&self, scopes: &[String]) -> Vec<&ApiAction> { - self.actions_for_scopes(scopes) - .into_iter() - .filter(|a| { - let risk = self.action_risk.get(&a.action).map(String::as_str); - matches!(risk, Some("high" | "critical")) - }) - .collect() - } -} - -/// Combined set of credentials and API registries. -#[derive(Debug, Clone, Default)] -pub struct CredentialSet { - pub credentials: Vec, - pub api_registries: HashMap, -} - -impl CredentialSet { - /// Credentials that target a given host. Matching mirrors runtime host - /// policy semantics for exact names and first-label wildcards, so a - /// proposal for `*.github.com` is treated as credentialed when the - /// attached credential targets `api.github.com`. - pub fn credentials_for_host(&self, host: &str) -> Vec<&Credential> { - self.credentials - .iter() - .filter(|c| { - c.target_hosts - .iter() - .any(|target| host_patterns_overlap(host, target)) - }) - .collect() - } - - /// API capability registry for a given host. Exact matches win, then - /// wildcard host overlap is used so credentialed wildcard proposals can be - /// evaluated against concrete API capability registries. - pub fn api_for_host(&self, host: &str) -> Option<&ApiCapability> { - let needle = normalize_host(host); - self.api_registries - .values() - .find(|api| normalize_host(&api.host) == needle) - .or_else(|| { - self.api_registries - .values() - .find(|api| host_patterns_overlap(host, &api.host)) - }) - } -} - -fn normalize_host(host: &str) -> String { - host.trim().trim_end_matches('.').to_ascii_lowercase() -} - -fn host_patterns_overlap(left: &str, right: &str) -> bool { - let left = normalize_host(left); - let right = normalize_host(right); - if left.is_empty() || right.is_empty() { - return false; - } - left == right || host_pattern_covers(&left, &right) || host_pattern_covers(&right, &left) -} - -fn host_pattern_covers(pattern: &str, host: &str) -> bool { - let pattern_labels: Vec<&str> = pattern.split('.').collect(); - let host_labels: Vec<&str> = host.split('.').collect(); - let Some(first_pattern_label) = pattern_labels.first().copied() else { - return false; - }; - - if first_pattern_label == "**" { - let suffix = &pattern_labels[1..]; - let host_suffix = host_labels - .len() - .checked_sub(suffix.len()) - .map(|start| &host_labels[start..]); - return !suffix.is_empty() - && host_labels.len() > suffix.len() - && matches!(host_suffix, Some(host_suffix) if host_suffix == suffix); - } - - if !first_pattern_label.contains('*') { - return false; - } - - // Runtime host wildcards only apply in the first DNS label. Wildcards in - // later labels are not treated as policy globs here. - pattern_labels.len() == host_labels.len() - && pattern_labels[1..] == host_labels[1..] - && wildcard_label_matches(first_pattern_label, host_labels[0]) -} - -fn wildcard_label_matches(pattern: &str, label: &str) -> bool { - if pattern == "*" { - return !label.is_empty(); - } - if label.is_empty() || !pattern.contains('*') { - return false; - } - - let parts: Vec<&str> = pattern.split('*').collect(); - let mut remaining = label; - - if let Some(prefix) = parts.first().copied().filter(|part| !part.is_empty()) { - let Some(stripped) = remaining.strip_prefix(prefix) else { - return false; - }; - remaining = stripped; - } - - if parts.len() > 2 { - for part in parts[1..parts.len() - 1] - .iter() - .copied() - .filter(|part| !part.is_empty()) - { - let Some(offset) = remaining.find(part) else { - return false; - }; - remaining = &remaining[offset + part.len()..]; - } - } - - parts - .last() - .copied() - .filter(|suffix| !suffix.is_empty()) - .is_none_or(|suffix| remaining.ends_with(suffix)) -} - -// --------------------------------------------------------------------------- -// Loading -// --------------------------------------------------------------------------- - -/// Load credential descriptors from a YAML file. -pub fn load_credentials(path: &Path) -> Result> { - let contents = std::fs::read_to_string(path) - .into_diagnostic() - .wrap_err_with(|| format!("reading credentials file {}", path.display()))?; - let raw: CredentialsFile = serde_yml::from_str(&contents) - .into_diagnostic() - .wrap_err("parsing credentials YAML")?; - - Ok(raw - .credentials - .into_iter() - .map(|c| Credential { - name: c.name, - cred_type: c.cred_type, - scopes: c.scopes, - injected_via: c.injected_via, - target_hosts: c.target_hosts, - }) - .collect()) -} - -fn parse_api_registry(contents: &str, source: &str) -> Result { - let raw: ApiRegistryDef = serde_yml::from_str(contents) - .into_diagnostic() - .wrap_err_with(|| format!("parsing API registry {source}"))?; - - let scope_capabilities = raw - .scope_capabilities - .into_iter() - .map(|(scope, actions)| { - let actions = actions - .into_iter() - .map(|a| ApiAction { - method: a.method, - path: a.path, - action: a.action, - }) - .collect(); - (scope, actions) - }) - .collect(); - - Ok(ApiCapability { - api: raw.api, - host: raw.host, - port: raw.port, - credential_type: raw.credential_type, - scope_capabilities, - action_risk: raw.action_risk, - }) -} - -fn load_embedded_api_registries() -> Result> { - let registry = crate::registry::embedded_registry(); - let mut api_registries = HashMap::new(); - if let Some(dir) = registry.get_dir("apis") { - for file in dir.files() { - if file.path().extension().is_some_and(|ext| ext == "yaml") { - let contents = file.contents_utf8().ok_or_else(|| { - miette::miette!("non-UTF8 API registry file: {}", file.path().display()) - })?; - let api = parse_api_registry(contents, &file.path().display().to_string())?; - api_registries.insert(api.api.clone(), api); - } - } - } - Ok(api_registries) -} - -fn load_api_registries_from_dir(registry_dir: &Path) -> Result> { - let mut api_registries = HashMap::new(); - let apis_dir = registry_dir.join("apis"); - if apis_dir.is_dir() { - let entries = std::fs::read_dir(&apis_dir) - .into_diagnostic() - .wrap_err_with(|| format!("reading directory {}", apis_dir.display()))?; - for entry in entries { - let entry = entry.into_diagnostic()?; - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let contents = std::fs::read_to_string(&path) - .into_diagnostic() - .wrap_err_with(|| format!("reading {}", path.display()))?; - let api = parse_api_registry(&contents, &path.display().to_string())?; - api_registries.insert(api.api.clone(), api); - } - } - } - Ok(api_registries) -} - -/// Load credentials with API registries from the embedded registry. -pub fn load_credential_set_embedded(credentials_path: &Path) -> Result { - let creds = load_credentials(credentials_path)?; - let api_registries = load_embedded_api_registries()?; - Ok(CredentialSet { - credentials: creds, - api_registries, - }) -} - -/// Load credentials with API registries from a filesystem directory override. -pub fn load_credential_set_from_dir( - credentials_path: &Path, - registry_dir: &Path, -) -> Result { - let creds = load_credentials(credentials_path)?; - let api_registries = load_api_registries_from_dir(registry_dir)?; - Ok(CredentialSet { - credentials: creds, - api_registries, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn github_credential() -> Credential { - Credential { - name: "github-pat".to_string(), - cred_type: "github-pat".to_string(), - scopes: vec!["repo".to_string()], - injected_via: "GITHUB_TOKEN".to_string(), - target_hosts: vec!["api.github.com".to_string()], - } - } - - fn github_api() -> ApiCapability { - ApiCapability { - api: "github".to_string(), - host: "api.github.com".to_string(), - port: 443, - credential_type: "github-pat".to_string(), - scope_capabilities: HashMap::new(), - action_risk: HashMap::new(), - } - } - - #[test] - fn host_patterns_overlap_matches_exact_case_and_trailing_dot() { - assert!(host_patterns_overlap("API.GITHUB.COM.", "api.github.com")); - assert!(!host_patterns_overlap( - "api.github.com", - "uploads.github.com" - )); - } - - #[test] - fn host_patterns_overlap_matches_first_label_wildcard_only() { - assert!(host_patterns_overlap("*.github.com", "api.github.com")); - assert!(!host_patterns_overlap("*.github.com", "github.com")); - assert!(!host_patterns_overlap( - "*.github.com", - "deep.api.github.com" - )); - } - - #[test] - fn host_patterns_overlap_matches_intra_label_first_label_wildcard() { - assert!(host_patterns_overlap( - "api-*.github.com", - "api-v3.github.com" - )); - assert!(!host_patterns_overlap( - "api-*.github.com", - "uploads.github.com" - )); - assert!(!host_patterns_overlap( - "api.*.github.com", - "api.v3.github.com" - )); - } - - #[test] - fn host_patterns_overlap_matches_recursive_first_label_wildcard() { - assert!(host_patterns_overlap("**.github.com", "api.github.com")); - assert!(host_patterns_overlap( - "**.github.com", - "deep.api.github.com" - )); - assert!(!host_patterns_overlap("**.github.com", "github.com")); - } - - #[test] - fn wildcard_policy_host_finds_credentialed_concrete_target() { - let set = CredentialSet { - credentials: vec![github_credential()], - api_registries: HashMap::new(), - }; - - let creds = set.credentials_for_host("*.github.com"); - assert_eq!(creds.len(), 1); - assert_eq!(creds[0].name, "github-pat"); - } - - #[test] - fn wildcard_policy_host_finds_concrete_api_registry() { - let set = CredentialSet { - credentials: Vec::new(), - api_registries: HashMap::from([("github".to_string(), github_api())]), - }; - - let api = set.api_for_host("*.github.com").expect("github API"); - assert_eq!(api.host, "api.github.com"); - } -} diff --git a/crates/openshell-prover/src/envelope.rs b/crates/openshell-prover/src/envelope.rs new file mode 100644 index 0000000000..9fd80d4643 --- /dev/null +++ b/crates/openshell-prover/src/envelope.rs @@ -0,0 +1,804 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Semantic containment for the initial managed-maximum surface. +//! +//! The first implementation models filesystem paths, L4 host/port/binary +//! authority, and enforced REST method/path authority. Other network +//! protocols fail closed so the admission contract can grow without changing +//! its callers. + +use std::str::FromStr; + +use z3::ast::{Bool, Int, Regexp, String as Z3String}; +use z3::{Context, SatResult, Solver}; + +use crate::policy::{Endpoint, L7DenyRule, L7Rule, NetworkPolicyRule, PolicyModel}; + +const READ_ONLY_METHODS: &[&str] = &["GET", "HEAD", "OPTIONS"]; +const READ_WRITE_METHODS: &[&str] = &["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"]; +const LAYER_L4: &str = "l4"; +const LAYER_REST: &str = "rest"; + +/// Result of proving a candidate policy against a managed maximum. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MaximumPolicyCheck { + WithinMax, + ExceedsMax { + counterexample: Box, + }, + Unsupported { + reason: String, + }, +} + +/// One concrete L4, REST, or filesystem action outside the maximum. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyCounterexample { + pub binary: String, + pub host: String, + pub port: u16, + pub protocol: String, + pub method: String, + pub path: String, + pub reason: String, +} + +#[derive(Debug, Clone, Copy)] +enum MaximumView { + All, + AutoEligible, +} + +struct SymbolicAction { + binary: Z3String, + host: Z3String, + port: Int, + layer: Z3String, + method: Z3String, + path: Z3String, +} + +#[must_use] +pub fn check_within_maximum(maximum: &PolicyModel, candidate: &PolicyModel) -> MaximumPolicyCheck { + check(maximum, candidate, MaximumView::All) +} + +#[must_use] +pub fn check_within_auto_eligible_maximum( + maximum: &PolicyModel, + requested_delta: &PolicyModel, +) -> MaximumPolicyCheck { + check(maximum, requested_delta, MaximumView::AutoEligible) +} + +fn check( + maximum: &PolicyModel, + candidate: &PolicyModel, + maximum_view: MaximumView, +) -> MaximumPolicyCheck { + if let Some(reason) = unsupported_reason("maximum", maximum) { + return MaximumPolicyCheck::Unsupported { reason }; + } + if let Some(reason) = unsupported_reason("candidate", candidate) { + return MaximumPolicyCheck::Unsupported { reason }; + } + if let Some(counterexample) = filesystem_counterexample(maximum, candidate) { + return MaximumPolicyCheck::ExceedsMax { + counterexample: Box::new(counterexample), + }; + } + + let solver = Solver::new(); + let action = symbolic_action("maximum_policy_action"); + assert_action_domain(&solver, &action); + solver.assert(Bool::and(&[ + policy_allows(candidate, &action, MaximumView::All), + !policy_allows(maximum, &action, maximum_view), + ])); + + match solver.check() { + SatResult::Unsat => MaximumPolicyCheck::WithinMax, + SatResult::Unknown => MaximumPolicyCheck::Unsupported { + reason: "Z3 returned unknown while checking maximum-policy containment".to_owned(), + }, + SatResult::Sat => { + let Some(model) = solver.get_model() else { + return MaximumPolicyCheck::Unsupported { + reason: "Z3 returned sat without a maximum-policy model".to_owned(), + }; + }; + let Some(counterexample) = counterexample_from_model(&model, &action) else { + return MaximumPolicyCheck::Unsupported { + reason: "Z3 returned a model that could not be decoded".to_owned(), + }; + }; + MaximumPolicyCheck::ExceedsMax { + counterexample: Box::new(counterexample), + } + } + } +} + +fn symbolic_action(name: &str) -> SymbolicAction { + let _context = Context::thread_local(); + SymbolicAction { + binary: Z3String::new_const(format!("{name}_binary")), + host: Z3String::new_const(format!("{name}_host")), + port: Int::new_const(format!("{name}_port")), + layer: Z3String::new_const(format!("{name}_layer")), + method: Z3String::new_const(format!("{name}_method")), + path: Z3String::new_const(format!("{name}_path")), + } +} + +fn assert_action_domain(solver: &Solver, action: &SymbolicAction) { + solver.assert(action.binary.regex_matches(&glob_regex("/**", "/"))); + solver.assert(action.host.regex_matches(&Regexp::full())); + solver.assert(Int::from_u64(1).le(&action.port)); + solver.assert(action.port.le(65_535)); + solver.assert(str_eq_any(&action.layer, &[LAYER_L4, LAYER_REST])); + solver.assert(!action.method.eq("")); + solver.assert( + Z3String::from_str("/") + .expect("valid Z3 string literal") + .prefix(&action.path), + ); +} + +fn policy_allows(policy: &PolicyModel, action: &SymbolicAction, view: MaximumView) -> Bool { + let allowed = bool_or( + policy + .network_policies + .values() + .map(|rule| rule_allows(rule, action, view)), + ); + let denied = bool_or( + policy + .network_policies + .values() + .map(|rule| rule_denies(rule, action)), + ); + Bool::and(&[allowed, !denied]) +} + +fn rule_allows(rule: &NetworkPolicyRule, action: &SymbolicAction, view: MaximumView) -> Bool { + Bool::and(&[ + binaries_match(rule, action), + bool_or( + rule.endpoints + .iter() + .map(|endpoint| endpoint_allows(endpoint, action, view)), + ), + ]) +} + +fn rule_denies(rule: &NetworkPolicyRule, action: &SymbolicAction) -> Bool { + Bool::and(&[ + binaries_match(rule, action), + bool_or( + rule.endpoints + .iter() + .map(|endpoint| endpoint_denies(endpoint, action)), + ), + ]) +} + +fn binaries_match(rule: &NetworkPolicyRule, action: &SymbolicAction) -> Bool { + bool_or( + rule.binaries + .iter() + .map(|binary| action.binary.regex_matches(&glob_regex(&binary.path, "/"))), + ) +} + +fn endpoint_allows(endpoint: &Endpoint, action: &SymbolicAction, view: MaximumView) -> Bool { + let common = endpoint_matches_connection(endpoint, action); + if matches!(view, MaximumView::AutoEligible) && endpoint.review.required { + return Bool::from_bool(false); + } + + match endpoint.protocol.trim().to_ascii_lowercase().as_str() { + "" => common, + "rest" => Bool::and(&[ + common, + action.layer.eq(LAYER_REST), + endpoint_path_matches(endpoint, action), + rest_endpoint_allows(endpoint, action, view), + ]), + _ => Bool::from_bool(false), + } +} + +fn endpoint_denies(endpoint: &Endpoint, action: &SymbolicAction) -> Bool { + if !endpoint.protocol.eq_ignore_ascii_case("rest") || endpoint.deny_rules.is_empty() { + return Bool::from_bool(false); + } + Bool::and(&[ + endpoint_matches_connection(endpoint, action), + action.layer.eq(LAYER_REST), + endpoint_path_matches(endpoint, action), + bool_or( + endpoint + .deny_rules + .iter() + .map(|deny| method_and_path_match(&deny.method, &deny.path, action)), + ), + ]) +} + +fn rest_endpoint_allows(endpoint: &Endpoint, action: &SymbolicAction, view: MaximumView) -> Bool { + match endpoint.access.as_str() { + "read-only" => methods_match(action, READ_ONLY_METHODS, "**"), + "read-write" => methods_match(action, READ_WRITE_METHODS, "**"), + "full" => any_method_matches(action, "**"), + _ => bool_or(endpoint.rules.iter().map(|rule| { + if matches!(view, MaximumView::AutoEligible) && rule.review.required { + Bool::from_bool(false) + } else { + method_and_path_match(&rule.method, &rule.path, action) + } + })), + } +} + +fn endpoint_matches_connection(endpoint: &Endpoint, action: &SymbolicAction) -> Bool { + Bool::and(&[ + bool_or( + endpoint + .effective_ports() + .into_iter() + .map(|port| action.port.eq(Int::from_u64(u64::from(port)))), + ), + action + .host + .regex_matches(&glob_regex(&endpoint.host.to_ascii_lowercase(), ".")), + ]) +} + +fn endpoint_path_matches(endpoint: &Endpoint, action: &SymbolicAction) -> Bool { + let path = if endpoint.path.is_empty() { + "**" + } else { + &endpoint.path + }; + action.path.regex_matches(&glob_regex(path, "/")) +} + +fn method_and_path_match(method: &str, path: &str, action: &SymbolicAction) -> Bool { + if method.is_empty() { + return Bool::from_bool(false); + } + let path = if path.is_empty() { "**" } else { path }; + if method == "*" { + any_method_matches(action, path) + } else if method.eq_ignore_ascii_case("GET") { + methods_match(action, &["GET", "HEAD"], path) + } else { + methods_match(action, &[method], path) + } +} + +fn any_method_matches(action: &SymbolicAction, path: &str) -> Bool { + action.path.regex_matches(&glob_regex(path, "/")) +} + +fn methods_match(action: &SymbolicAction, methods: &[&str], path: &str) -> Bool { + Bool::and(&[ + str_eq_any_case_insensitive(&action.method, methods), + action.path.regex_matches(&glob_regex(path, "/")), + ]) +} + +fn counterexample_from_model( + model: &z3::Model, + action: &SymbolicAction, +) -> Option { + let port = model.eval(&action.port, true)?.as_u64()?; + let protocol = model.eval(&action.layer, true)?.as_string()?; + let is_l4 = protocol == LAYER_L4; + Some(PolicyCounterexample { + binary: model.eval(&action.binary, true)?.as_string()?, + host: model.eval(&action.host, true)?.as_string()?, + port: u16::try_from(port).ok()?, + protocol, + method: if is_l4 { + String::new() + } else { + model.eval(&action.method, true)?.as_string()? + }, + path: if is_l4 { + String::new() + } else { + model.eval(&action.path, true)?.as_string()? + }, + reason: "candidate allows an action outside the managed maximum".to_owned(), + }) +} + +fn filesystem_counterexample( + maximum: &PolicyModel, + candidate: &PolicyModel, +) -> Option { + let maximum_read_write = filesystem_paths(maximum, true); + let mut maximum_read = filesystem_paths(maximum, false); + maximum_read.extend(maximum_read_write.iter().cloned()); + + if let Some(path) = filesystem_paths(candidate, true) + .iter() + .find(|path| !path_is_covered(path, &maximum_read_write)) + { + return Some(filesystem_counterexample_value(path, "write")); + } + filesystem_paths(candidate, false) + .iter() + .find(|path| !path_is_covered(path, &maximum_read)) + .map(|path| filesystem_counterexample_value(path, "read")) +} + +fn filesystem_paths(policy: &PolicyModel, write: bool) -> Vec { + let mut paths = if write { + policy.filesystem_policy.read_write.clone() + } else { + policy.filesystem_policy.read_only.clone() + }; + if write + && policy.filesystem_policy.include_workdir + && !paths.iter().any(|path| path == "/sandbox") + { + paths.push("/sandbox".to_owned()); + } + paths +} + +fn path_is_covered(candidate: &str, maximum_paths: &[String]) -> bool { + maximum_paths.iter().any(|maximum| { + candidate == maximum + || candidate + .strip_prefix(maximum.trim_end_matches('/')) + .is_some_and(|suffix| suffix.starts_with('/')) + }) +} + +fn filesystem_counterexample_value(path: &str, access: &str) -> PolicyCounterexample { + PolicyCounterexample { + binary: String::new(), + host: String::new(), + port: 0, + protocol: "filesystem".to_owned(), + method: access.to_owned(), + path: path.to_owned(), + reason: format!("candidate allows filesystem {access} outside the managed maximum"), + } +} + +fn unsupported_reason(prefix: &str, policy: &PolicyModel) -> Option { + if !policy.extra_fields.is_empty() { + return Some(format!( + "{prefix} policy uses unsupported top-level fields: {}", + policy.extra_fields.join(", ") + )); + } + if !policy.filesystem_policy.extra_fields.is_empty() { + return Some(format!( + "{prefix} policy uses unsupported filesystem fields: {}", + policy.filesystem_policy.extra_fields.join(", ") + )); + } + for (rule_name, rule) in &policy.network_policies { + if !rule.extra_fields.is_empty() { + return Some(format!( + "{prefix} policy rule '{rule_name}' uses unsupported fields: {}", + rule.extra_fields.join(", ") + )); + } + for endpoint in &rule.endpoints { + let context = format!("{prefix} policy rule '{rule_name}'"); + if endpoint.host.is_empty() || endpoint.effective_ports().is_empty() { + return Some(format!("{context} has no modeled host or port")); + } + if unsupported_glob_pattern(&endpoint.host) || unsupported_glob_pattern(&endpoint.path) + { + return Some(format!("{context} uses an unsupported endpoint glob")); + } + if !endpoint.allowed_ips.is_empty() { + return Some(format!( + "{context} uses allowed_ips/CIDR authority, which containment does not model" + )); + } + if !endpoint.extra_fields.is_empty() { + return Some(format!( + "{context} uses unsupported endpoint fields: {}", + endpoint.extra_fields.join(", ") + )); + } + if !endpoint.review.extra_fields.is_empty() { + return Some(format!("{context} uses unsupported review metadata")); + } + if !endpoint.tls.is_empty() { + return Some(format!( + "{context} uses TLS behavior that containment does not model" + )); + } + + let protocol = endpoint.protocol.trim().to_ascii_lowercase(); + if !matches!(protocol.as_str(), "" | "rest") { + return Some(format!( + "{context} uses protocol '{}'; this initial managed maximum supports only L4 and REST", + endpoint.protocol + )); + } + if protocol.is_empty() { + if !endpoint.path.is_empty() + || !endpoint.access.is_empty() + || !endpoint.rules.is_empty() + || !endpoint.deny_rules.is_empty() + { + return Some(format!("{context} mixes REST controls into an L4 endpoint")); + } + } else { + if endpoint.enforcement != "enforce" { + return Some(format!( + "{context} uses REST without enforcement mode 'enforce'" + )); + } + if !endpoint.access.is_empty() && !endpoint.rules.is_empty() { + return Some(format!("{context} combines REST access and rules")); + } + if endpoint.access.is_empty() && endpoint.rules.is_empty() { + return Some(format!("{context} has no REST allow rules")); + } + if !endpoint.access.is_empty() + && !matches!( + endpoint.access.as_str(), + "read-only" | "read-write" | "full" + ) + { + return Some(format!( + "{context} uses unsupported REST access preset '{}'", + endpoint.access + )); + } + } + + for allow in &endpoint.rules { + if unsupported_rest_allow(allow) { + return Some(format!( + "{context} uses an unsupported REST allow-rule field" + )); + } + if unsupported_glob_pattern(&allow.path) { + return Some(format!("{context} uses an unsupported allow-rule glob")); + } + } + for deny in &endpoint.deny_rules { + if unsupported_rest_deny(deny) { + return Some(format!( + "{context} uses an unsupported REST deny-rule field" + )); + } + if unsupported_glob_pattern(&deny.path) { + return Some(format!("{context} uses an unsupported deny-rule glob")); + } + } + } + for binary in &rule.binaries { + if !binary.extra_fields.is_empty() { + return Some(format!( + "{prefix} policy rule '{rule_name}' uses unsupported binary fields: {}", + binary.extra_fields.join(", ") + )); + } + if unsupported_glob_pattern(&binary.path) { + return Some(format!( + "{prefix} policy rule '{rule_name}' uses an unsupported binary glob" + )); + } + } + } + None +} + +fn unsupported_rest_allow(rule: &L7Rule) -> bool { + rule.method.is_empty() + || !rule.command.is_empty() + || !rule.extra_fields.is_empty() + || !rule.review.extra_fields.is_empty() +} + +fn unsupported_rest_deny(rule: &L7DenyRule) -> bool { + rule.method.is_empty() || !rule.command.is_empty() || !rule.extra_fields.is_empty() +} + +fn bool_or(values: impl IntoIterator) -> Bool { + let values: Vec = values.into_iter().collect(); + if values.is_empty() { + Bool::from_bool(false) + } else { + Bool::or(&values) + } +} + +fn str_eq_any(value: &Z3String, options: &[&str]) -> Bool { + bool_or(options.iter().map(|option| value.eq(*option))) +} + +fn str_eq_any_case_insensitive(value: &Z3String, options: &[&str]) -> Bool { + bool_or( + options + .iter() + .map(|option| value.eq(option.to_ascii_uppercase())), + ) +} + +// Reused from the maximal-policy spike. `*` does not cross the selected +// separator; `**` does. That matches OpenShell's host, path, and binary globs. +fn glob_regex(pattern: &str, separator: &str) -> Regexp { + if pattern == "**" { + return Regexp::full(); + } + let mut parts = Vec::new(); + let mut chars = pattern.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '*' && chars.peek() == Some(&'*') { + chars.next(); + parts.push(Regexp::full()); + } else if ch == '*' { + parts.push(non_separator_regex(separator).star()); + } else { + parts.push(Regexp::literal(&ch.to_string())); + } + } + if parts.is_empty() { + Regexp::literal("") + } else { + let refs: Vec<&Regexp> = parts.iter().collect(); + Regexp::concat(&refs) + } +} + +fn non_separator_regex(separator: &str) -> Regexp { + match separator { + "/" => Regexp::union(&[&Regexp::range(&' ', &'.'), &Regexp::range(&'0', &'~')]), + "." => Regexp::union(&[&Regexp::range(&' ', &'-'), &Regexp::range(&'/', &'~')]), + _ => Regexp::full(), + } +} + +fn unsupported_glob_pattern(pattern: &str) -> bool { + pattern + .chars() + .any(|character| matches!(character, '?' | '[' | ']' | '{' | '}' | '\\')) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::parse_policy_str; + + fn policy(endpoint: &str) -> PolicyModel { + parse_policy_str(&format!( + r"version: 1 +network_policies: + test: + endpoints: +{endpoint} + binaries: + - path: /usr/bin/gh +" + )) + .expect("policy should parse") + } + + fn filesystem(read_only: &str, read_write: &str) -> PolicyModel { + parse_policy_str(&format!( + r"version: 1 +filesystem_policy: + include_workdir: false + read_only: [{read_only}] + read_write: [{read_write}] +" + )) + .expect("filesystem policy should parse") + } + + fn rest(rules: &str) -> PolicyModel { + policy(&format!( + r" - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + rules: +{rules}" + )) + } + + #[test] + fn policy_is_contained_by_itself() { + let value = rest(" - allow: { method: GET, path: /repos/** }"); + assert_eq!( + check_within_maximum(&value, &value), + MaximumPolicyCheck::WithinMax + ); + } + + #[test] + fn filesystem_paths_are_contained_by_parent_authority() { + let maximum = filesystem("/usr", "/sandbox, /tmp"); + let candidate = filesystem("/usr/bin", "/sandbox/project"); + assert_eq!( + check_within_maximum(&maximum, &candidate), + MaximumPolicyCheck::WithinMax + ); + } + + #[test] + fn filesystem_write_cannot_use_read_only_maximum() { + let maximum = filesystem("/usr", "/sandbox"); + let candidate = filesystem("", "/usr/bin"); + let MaximumPolicyCheck::ExceedsMax { counterexample } = + check_within_maximum(&maximum, &candidate) + else { + panic!("write under read-only maximum should fail"); + }; + assert_eq!(counterexample.protocol, "filesystem"); + assert_eq!(counterexample.method, "write"); + } + + #[test] + fn narrower_rest_path_is_contained() { + let maximum = rest(" - allow: { method: GET, path: /repos/** }"); + let candidate = rest(" - allow: { method: GET, path: /repos/NVIDIA/OpenShell }"); + assert_eq!( + check_within_maximum(&maximum, &candidate), + MaximumPolicyCheck::WithinMax + ); + } + + #[test] + fn broader_rest_path_returns_counterexample() { + let maximum = rest(" - allow: { method: GET, path: /repos/NVIDIA/** }"); + let candidate = rest(" - allow: { method: GET, path: /repos/** }"); + let MaximumPolicyCheck::ExceedsMax { counterexample } = + check_within_maximum(&maximum, &candidate) + else { + panic!("broader path should exceed maximum"); + }; + assert_eq!(counterexample.protocol, "rest"); + assert_eq!(counterexample.method, "GET"); + } + + #[test] + fn l4_maximum_covers_rest_but_rest_does_not_cover_l4() { + let l4 = policy(" - host: api.github.com\n port: 443"); + let rest = rest(" - allow: { method: GET, path: /repos/** }"); + assert_eq!( + check_within_maximum(&l4, &rest), + MaximumPolicyCheck::WithinMax + ); + assert!(matches!( + check_within_maximum(&rest, &l4), + MaximumPolicyCheck::ExceedsMax { .. } + )); + } + + #[test] + fn explicit_deny_removes_authority() { + let maximum = rest( + " - allow: { method: \"*\", path: /** }\n deny_rules:\n - { method: DELETE, path: /** }", + ); + let candidate = + rest(" - allow: { method: DELETE, path: /repos/NVIDIA/OpenShell }"); + assert!(matches!( + check_within_maximum(&maximum, &candidate), + MaximumPolicyCheck::ExceedsMax { .. } + )); + } + + #[test] + fn full_rest_access_contains_extension_methods() { + let maximum = policy( + " - host: api.github.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: full", + ); + let candidate = rest(" - allow: { method: PROPFIND, path: /repos/acme }"); + assert_eq!( + check_within_maximum(&maximum, &candidate), + MaximumPolicyCheck::WithinMax + ); + } + + #[test] + fn finite_method_rules_do_not_contain_wildcard_authority() { + let maximum = rest( + " - allow: { method: GET, path: /** }\n - allow: { method: HEAD, path: /** }\n - allow: { method: OPTIONS, path: /** }\n - allow: { method: POST, path: /** }\n - allow: { method: PUT, path: /** }\n - allow: { method: PATCH, path: /** }\n - allow: { method: DELETE, path: /** }", + ); + let candidate = rest(" - allow: { method: \"*\", path: /** }"); + let MaximumPolicyCheck::ExceedsMax { counterexample } = + check_within_maximum(&maximum, &candidate) + else { + panic!("finite method rules must not contain wildcard authority"); + }; + assert!(!counterexample.method.is_empty()); + assert!( + !["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] + .contains(&counterexample.method.as_str()) + ); + } + + #[test] + fn wildcard_deny_blocks_extension_methods() { + let maximum = policy( + " - host: api.github.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: full\n deny_rules:\n - { method: \"*\", path: /private/** }", + ); + let candidate = rest(" - allow: { method: PROPFIND, path: /private/data }"); + assert!(matches!( + check_within_maximum(&maximum, &candidate), + MaximumPolicyCheck::ExceedsMax { .. } + )); + } + + #[test] + fn review_required_region_is_not_auto_eligible() { + let maximum = rest( + " - allow:\n method: POST\n path: /repos/*/pulls\n review: { required: true }", + ); + let delta = rest(" - allow: { method: POST, path: /repos/acme/pulls }"); + assert_eq!( + check_within_maximum(&maximum, &delta), + MaximumPolicyCheck::WithinMax + ); + assert!(matches!( + check_within_auto_eligible_maximum(&maximum, &delta), + MaximumPolicyCheck::ExceedsMax { .. } + )); + } + + #[test] + fn endpoint_path_scopes_rest_authority() { + let maximum = policy( + " - host: api.github.com\n port: 443\n path: /repos/NVIDIA/**\n protocol: rest\n enforcement: enforce\n access: full", + ); + let candidate = rest(" - allow: { method: GET, path: /repos/** }"); + assert!(matches!( + check_within_maximum(&maximum, &candidate), + MaximumPolicyCheck::ExceedsMax { .. } + )); + } + + #[test] + fn graphql_and_mcp_fail_closed_in_initial_scope() { + for protocol in ["graphql", "mcp"] { + let value = policy(&format!( + " - host: api.example.com\n port: 443\n protocol: {protocol}\n enforcement: enforce\n rules:\n - allow: {{ operation_type: query }}" + )); + assert!(matches!( + check_within_maximum(&value, &value), + MaximumPolicyCheck::Unsupported { reason } + if reason.contains("only L4 and REST") + )); + } + } + + #[test] + fn rest_query_matchers_fail_closed() { + let value = + rest(" - allow: { method: GET, path: /repos/**, query: { page: \"*\" } }"); + assert!(matches!( + check_within_maximum(&value, &value), + MaximumPolicyCheck::Unsupported { .. } + )); + } + + #[test] + fn unknown_authority_surfaces_fail_closed() { + let value = parse_policy_str( + "version: 1\nnetwork_policies: {}\nfuture_authority: { allow: all }\n", + ) + .expect("generic parser should preserve unknown fields"); + assert!(matches!( + check_within_maximum(&value, &value), + MaximumPolicyCheck::Unsupported { reason } + if reason.contains("future_authority") + )); + } +} diff --git a/crates/openshell-prover/src/finding.rs b/crates/openshell-prover/src/finding.rs deleted file mode 100644 index 4e06d1b4e8..0000000000 --- a/crates/openshell-prover/src/finding.rs +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Finding types emitted by verification queries. -//! -//! The prover answers four formal questions about a proposed policy and -//! emits one finding category per "yes" answer. Findings are categorical -//! (not severity-graded): the reviewer reads the category name and the -//! structured evidence to decide. The auto-approval gate is binary — -//! delta empty = candidate for auto-approval; any finding = human review. -//! -//! Categories: -//! -//! - `credential_reach_expansion` — a binary gained credentialed reach to -//! a (host, port) it could not reach before. -//! - `capability_expansion` — on a (binary, host, port) that already had -//! credentialed reach, a new HTTP method was added. -//! - `l7_bypass_credentialed` — a binary using a wire protocol the L7 -//! proxy cannot inspect (`git-remote-https`, `ssh`, `nc`) gained reach -//! to a host where a credential is in scope. -//! - `link_local_reach` — any reach to a link-local IP range -//! (`169.254.0.0/16`, `fe80::/10`), unconditional. Cloud metadata -//! endpoints serve credentials regardless of the sandbox's own -//! credential state. - -/// Stable category names. Used as the `query` field on [`Finding`] and -/// in the per-path key used by `finding_delta`. -pub mod category { - pub const CREDENTIAL_REACH_EXPANSION: &str = "credential_reach_expansion"; - pub const CAPABILITY_EXPANSION: &str = "capability_expansion"; - pub const L7_BYPASS_CREDENTIALED: &str = "l7_bypass_credentialed"; - pub const LINK_LOCAL_REACH: &str = "link_local_reach"; -} - -/// A concrete path through which the prover observed a tracked property. -/// -/// One `ExfilPath` per (binary, host, port, category) tuple — plus -/// `method` for `capability_expansion` so the gateway's per-path delta -/// surfaces the specific method that was added. -#[derive(Debug, Clone)] -pub struct ExfilPath { - pub binary: String, - pub endpoint_host: String, - pub endpoint_port: u16, - pub mechanism: String, - pub policy_name: String, - /// Category name (see `category::*` constants). - pub category: String, - /// HTTP method, populated only for `capability_expansion` paths. - /// Empty string for the other categories. - pub method: String, -} - -/// Concrete evidence attached to a [`Finding`]. -#[derive(Debug, Clone)] -pub enum FindingPath { - Exfil(ExfilPath), -} - -/// A single verification finding. -/// -/// `query` is the category name (one of the `category::*` constants). -/// Each finding carries one or more `paths` with the structured evidence -/// the reviewer needs to decide. There is no severity field — the -/// category itself is the signal. -#[derive(Debug, Clone)] -pub struct Finding { - pub query: String, - pub title: String, - pub description: String, - pub paths: Vec, - pub remediation: Vec, - pub accepted: bool, - pub accepted_reason: String, -} diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index 2267052048..e94dd150fe 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -1,293 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Formal policy verification for `OpenShell` sandboxes. -//! -//! Encodes sandbox policies, binary capabilities, and credential scopes as Z3 -//! SMT constraints, then checks reachability queries to detect data exfiltration -//! paths and write-bypass violations. +//! Semantic containment for managed `OpenShell` maximum policies. -pub mod accepted_risks; -pub mod credentials; -pub mod finding; -pub mod model; +pub mod envelope; pub mod policy; -pub mod queries; -pub mod registry; -pub mod report; - -use std::path::Path; - -use miette::Result; - -use accepted_risks::{apply_accepted_risks, load_accepted_risks}; -use model::build_model; -use policy::parse_policy; -use queries::run_all_queries; -use report::{render_compact, render_report}; - -/// Run the prover end-to-end and return an exit code. -/// -/// - `0` — pass (no critical/high findings, or all accepted) -/// - `1` — fail (critical or high findings present) -/// - `2` — input error -/// -/// Binary and API capability registries are embedded at compile time. -/// Pass `registry_dir` to override with a custom filesystem registry. -pub fn prove( - policy_path: &str, - credentials_path: &str, - registry_dir: Option<&str>, - accepted_risks_path: Option<&str>, - compact: bool, -) -> Result { - let policy = parse_policy(Path::new(policy_path))?; - - let (credential_set, binary_registry) = match registry_dir { - Some(dir) => { - let dir = Path::new(dir); - ( - credentials::load_credential_set_from_dir(Path::new(credentials_path), dir)?, - registry::load_binary_registry_from_dir(dir)?, - ) - } - None => ( - credentials::load_credential_set_embedded(Path::new(credentials_path))?, - registry::load_embedded_binary_registry()?, - ), - }; - - let z3_model = build_model(policy, credential_set, binary_registry); - let mut findings = run_all_queries(&z3_model); - - if let Some(ar_path) = accepted_risks_path { - let accepted = load_accepted_risks(Path::new(ar_path))?; - findings = apply_accepted_risks(findings, &accepted); - } - - let exit_code = if compact { - render_compact(&findings, policy_path, credentials_path) - } else { - render_report(&findings, policy_path, credentials_path) - }; - - Ok(exit_code) -} - -// =========================================================================== -// Tests -// =========================================================================== - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn testdata_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata") - } - - // 1. Parse testdata/policy.yaml, verify structure. - #[test] - fn test_parse_policy() { - let path = testdata_dir().join("policy.yaml"); - let model = parse_policy(&path).expect("failed to parse policy"); - assert_eq!(model.version, 1); - assert!(model.network_policies.contains_key("github_api")); - let rule = &model.network_policies["github_api"]; - assert_eq!(rule.name, "github-api"); - assert_eq!(rule.endpoints.len(), 2); - assert!(rule.binaries.len() >= 4); - } - - // 2. Verify readable_paths. - #[test] - fn test_filesystem_policy() { - let path = testdata_dir().join("policy.yaml"); - let model = parse_policy(&path).expect("failed to parse policy"); - let readable = model.filesystem_policy.readable_paths(); - assert!(readable.contains(&"/usr".to_owned())); - assert!(readable.contains(&"/sandbox".to_owned())); - assert!(readable.contains(&"/tmp".to_owned())); - } - - // 3. Workdir NOT included by default (matches runtime behavior). - #[test] - fn test_include_workdir_default() { - let yaml = r" -version: 1 -filesystem_policy: - read_only: - - /usr -"; - let model = policy::parse_policy_str(yaml).expect("parse"); - let readable = model.filesystem_policy.readable_paths(); - assert!(!readable.contains(&"/sandbox".to_owned())); - } - - // 4. Workdir excluded when include_workdir: false. - #[test] - fn test_include_workdir_false() { - let yaml = r" -version: 1 -filesystem_policy: - include_workdir: false - read_only: - - /usr -"; - let model = policy::parse_policy_str(yaml).expect("parse"); - let readable = model.filesystem_policy.readable_paths(); - assert!(!readable.contains(&"/sandbox".to_owned())); - } - - // 5. No duplicate when workdir already in read_write. - #[test] - fn test_include_workdir_no_duplicate() { - let yaml = r" -version: 1 -filesystem_policy: - include_workdir: true - read_write: - - /sandbox - - /tmp -"; - let model = policy::parse_policy_str(yaml).expect("parse"); - let readable = model.filesystem_policy.readable_paths(); - let sandbox_count = readable.iter().filter(|p| *p == "/sandbox").count(); - assert_eq!(sandbox_count, 1); - } - - // 6. End-to-end: testdata policy with a github credential in scope and a - // bypass-L7 binary (git) emits an `l7_bypass_credentialed` finding. - // The prover output is categorical, not severity-graded. - #[test] - fn test_findings_for_github_policy() { - use finding::category; - - let policy_path = testdata_dir().join("policy.yaml"); - let creds_path = testdata_dir().join("credentials.yaml"); - - let pol = parse_policy(&policy_path).expect("parse policy"); - let cred_set = credentials::load_credential_set_embedded(&creds_path).expect("load creds"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - - let z3_model = build_model(pol, cred_set, bin_reg); - let findings = run_all_queries(&z3_model); - - let categories: std::collections::HashSet<&str> = - findings.iter().map(|f| f.query.as_str()).collect(); - assert!( - categories.contains(category::L7_BYPASS_CREDENTIALED), - "expected l7_bypass_credentialed finding for bypass-L7 binary with credential in scope; \ - got categories: {categories:?}" - ); - // Every emitted category must be one of the four v1 categories. - let allowed: std::collections::HashSet<&str> = [ - category::LINK_LOCAL_REACH, - category::L7_BYPASS_CREDENTIALED, - category::CREDENTIAL_REACH_EXPANSION, - category::CAPABILITY_EXPANSION, - ] - .into_iter() - .collect(); - for c in &categories { - assert!( - allowed.contains(*c), - "unexpected category {c} emitted by the prover" - ); - } - } - - #[test] - fn test_wildcard_endpoint_covering_credential_host_emits_credential_reach() { - use finding::{FindingPath, category}; - - let policy = policy::parse_policy_str( - r#" -version: 1 -network_policies: - github_wildcard: - name: github-wildcard - endpoints: - - host: "*.github.com" - port: 443 - protocol: rest - enforcement: enforce - access: read-write - binaries: - - path: /usr/bin/curl -"#, - ) - .expect("parse policy"); - let cred_set = - credentials::load_credential_set_embedded(&testdata_dir().join("credentials.yaml")) - .expect("load creds"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - - let z3_model = build_model(policy, cred_set, bin_reg); - let findings = run_all_queries(&z3_model); - - let reach = findings - .iter() - .find(|finding| finding.query == category::CREDENTIAL_REACH_EXPANSION) - .expect("wildcard host covering api.github.com must be credentialed"); - assert!(reach.paths.iter().any(|path| matches!( - path, - FindingPath::Exfil(exfil) - if exfil.endpoint_host == "*.github.com" && exfil.binary == "/usr/bin/curl" - ))); - } - - #[test] - fn test_known_metadata_hostname_emits_link_local_finding() { - use finding::{FindingPath, category}; - - let policy = policy::parse_policy_str( - r" -version: 1 -network_policies: - metadata: - name: metadata - endpoints: - - host: metadata.google.internal - port: 80 - binaries: - - path: /usr/bin/curl -", - ) - .expect("parse policy"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - - let z3_model = build_model(policy, credentials::CredentialSet::default(), bin_reg); - let findings = run_all_queries(&z3_model); - - let link_local = findings - .iter() - .find(|finding| finding.query == category::LINK_LOCAL_REACH) - .expect("known metadata hostname must emit link-local/metadata finding"); - assert!(link_local.paths.iter().any(|path| matches!( - path, - FindingPath::Exfil(exfil) - if exfil.endpoint_host == "metadata.google.internal" - ))); - } - - // 7. Empty policy produces no findings. - #[test] - fn test_empty_policy_no_findings() { - let policy_path = testdata_dir().join("empty-policy.yaml"); - let creds_path = testdata_dir().join("credentials.yaml"); - - let pol = parse_policy(&policy_path).expect("parse policy"); - let cred_set = credentials::load_credential_set_embedded(&creds_path).expect("load creds"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - - let z3_model = build_model(pol, cred_set, bin_reg); - let findings = run_all_queries(&z3_model); - - assert!( - findings.is_empty(), - "deny-all policy should produce no findings, got: {findings:?}" - ); - } -} diff --git a/crates/openshell-prover/src/model.rs b/crates/openshell-prover/src/model.rs deleted file mode 100644 index bf52993d47..0000000000 --- a/crates/openshell-prover/src/model.rs +++ /dev/null @@ -1,394 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Z3 constraint model encoding policy, credentials, and binary capabilities. - -use std::collections::{HashMap, HashSet}; - -use z3::ast::Bool; -use z3::{Context, SatResult, Solver}; - -use crate::credentials::CredentialSet; -use crate::policy::{PolicyModel, WRITE_METHODS}; -use crate::registry::BinaryRegistry; - -/// Unique identifier for a network endpoint in the model. -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct EndpointId { - pub policy_name: String, - pub host: String, - pub port: u16, -} - -impl EndpointId { - /// Stable string key used for Z3 variable naming. - pub fn key(&self) -> String { - format!("{}:{}:{}", self.policy_name, self.host, self.port) - } -} - -/// Z3-backed reachability model for an `OpenShell` sandbox policy. -pub struct ReachabilityModel { - pub policy: PolicyModel, - pub credentials: CredentialSet, - pub binary_registry: BinaryRegistry, - - // Indexed facts - pub endpoints: Vec, - pub binary_paths: Vec, - - // Z3 solver - solver: Solver, - - // Boolean variable maps - policy_allows: HashMap, - l7_enforced: HashMap, - l7_allows_write: HashMap, - binary_bypasses_l7: HashMap, - binary_can_write: HashMap, - binary_can_exfil: HashMap, - binary_can_construct_http: HashMap, - credential_has_write: HashMap, - #[allow(dead_code)] - credential_has_destructive: HashMap, - #[allow(dead_code)] - filesystem_readable: HashMap, -} - -impl ReachabilityModel { - /// Build a new reachability model from the given inputs. - pub fn new( - policy: PolicyModel, - credentials: CredentialSet, - binary_registry: BinaryRegistry, - ) -> Self { - let solver = Solver::new(); - let mut model = Self { - policy, - credentials, - binary_registry, - endpoints: Vec::new(), - binary_paths: Vec::new(), - solver, - policy_allows: HashMap::new(), - l7_enforced: HashMap::new(), - l7_allows_write: HashMap::new(), - binary_bypasses_l7: HashMap::new(), - binary_can_write: HashMap::new(), - binary_can_exfil: HashMap::new(), - binary_can_construct_http: HashMap::new(), - credential_has_write: HashMap::new(), - credential_has_destructive: HashMap::new(), - filesystem_readable: HashMap::new(), - }; - model.build(); - model - } - - fn build(&mut self) { - self.index_endpoints(); - self.index_binaries(); - self.encode_policy_allows(); - self.encode_l7_enforcement(); - self.encode_binary_capabilities(); - self.encode_credentials(); - self.encode_filesystem(); - } - - fn index_endpoints(&mut self) { - for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { - for port in ep.effective_ports() { - self.endpoints.push(EndpointId { - policy_name: policy_name.clone(), - host: ep.host.clone(), - port, - }); - } - } - } - } - - fn index_binaries(&mut self) { - let mut seen = HashSet::new(); - for rule in self.policy.network_policies.values() { - for b in &rule.binaries { - if seen.insert(b.path.clone()) { - self.binary_paths.push(b.path.clone()); - } - } - } - } - - fn encode_policy_allows(&mut self) { - for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { - for port in ep.effective_ports() { - let eid = EndpointId { - policy_name: policy_name.clone(), - host: ep.host.clone(), - port, - }; - for b in &rule.binaries { - let key = format!("{}:{}", b.path, eid.key()); - let var = Bool::new_const(format!("policy_allows_{key}")); - self.solver.assert(&var); - self.policy_allows.insert(key, var); - } - } - } - } - } - - fn encode_l7_enforcement(&mut self) { - for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { - for port in ep.effective_ports() { - let eid = EndpointId { - policy_name: policy_name.clone(), - host: ep.host.clone(), - port, - }; - let ek = eid.key(); - - // L7 enforced? - let l7_var = Bool::new_const(format!("l7_enforced_{ek}")); - if ep.is_l7_enforced() { - self.solver.assert(&l7_var); - } else { - self.solver.assert(&!l7_var.clone()); - } - self.l7_enforced.insert(ek.clone(), l7_var); - - // L7 allows write? - let allowed = ep.allowed_methods(); - let write_set: HashSet<&str> = WRITE_METHODS.iter().copied().collect(); - let has_write = if allowed.is_empty() { - true // L4-only: all methods pass - } else { - allowed.iter().any(|m| write_set.contains(m.as_str())) - }; - - let l7_write_var = Bool::new_const(format!("l7_allows_write_{ek}")); - if ep.is_l7_enforced() { - if has_write { - self.solver.assert(&l7_write_var); - } else { - self.solver.assert(&!l7_write_var.clone()); - } - } else { - // L4-only: all methods pass through - self.solver.assert(&l7_write_var); - } - self.l7_allows_write.insert(ek, l7_write_var); - } - } - } - } - - fn encode_binary_capabilities(&mut self) { - for bpath in &self.binary_paths.clone() { - let cap = self.binary_registry.get_or_unknown(bpath); - - let bypass_var = Bool::new_const(format!("binary_bypasses_l7_{bpath}")); - if cap.bypasses_l7() { - self.solver.assert(&bypass_var); - } else { - self.solver.assert(&!bypass_var.clone()); - } - self.binary_bypasses_l7.insert(bpath.clone(), bypass_var); - - let write_var = Bool::new_const(format!("binary_can_write_{bpath}")); - if cap.can_write() { - self.solver.assert(&write_var); - } else { - self.solver.assert(&!write_var.clone()); - } - self.binary_can_write.insert(bpath.clone(), write_var); - - let exfil_var = Bool::new_const(format!("binary_can_exfil_{bpath}")); - if cap.can_exfiltrate { - self.solver.assert(&exfil_var); - } else { - self.solver.assert(&!exfil_var.clone()); - } - self.binary_can_exfil.insert(bpath.clone(), exfil_var); - - let http_var = Bool::new_const(format!("binary_can_construct_http_{bpath}")); - if cap.can_construct_http { - self.solver.assert(&http_var); - } else { - self.solver.assert(&!http_var.clone()); - } - self.binary_can_construct_http - .insert(bpath.clone(), http_var); - } - } - - fn encode_credentials(&mut self) { - let hosts: HashSet = self.endpoints.iter().map(|e| e.host.clone()).collect(); - - for host in &hosts { - let creds = self.credentials.credentials_for_host(host); - let api = self.credentials.api_for_host(host); - - let mut has_write = false; - let mut has_destructive = false; - - for cred in &creds { - if let Some(api) = api { - if !api.write_actions_for_scopes(&cred.scopes).is_empty() { - has_write = true; - } - if !api.destructive_actions_for_scopes(&cred.scopes).is_empty() { - has_destructive = true; - } - } else if !cred.scopes.is_empty() { - has_write = true; - } - } - - let cw_var = Bool::new_const(format!("credential_has_write_{host}")); - if has_write { - self.solver.assert(&cw_var); - } else { - self.solver.assert(&!cw_var.clone()); - } - self.credential_has_write.insert(host.clone(), cw_var); - - let destructive_var = Bool::new_const(format!("credential_has_destructive_{host}")); - if has_destructive { - self.solver.assert(&destructive_var); - } else { - self.solver.assert(&!destructive_var.clone()); - } - self.credential_has_destructive - .insert(host.clone(), destructive_var); - } - } - - fn encode_filesystem(&mut self) { - for path in self.policy.filesystem_policy.readable_paths() { - let var = Bool::new_const(format!("fs_readable_{path}")); - self.solver.assert(&var); - self.filesystem_readable.insert(path, var); - } - } - - // --- Query helpers --- - - fn false_val() -> Bool { - Bool::from_bool(false) - } - - /// Build a Z3 expression for whether a binary can write to an endpoint. - pub fn can_write_to_endpoint(&self, bpath: &str, eid: &EndpointId) -> Bool { - let ek = eid.key(); - let access_key = format!("{bpath}:{ek}"); - - let has_access = match self.policy_allows.get(&access_key) { - Some(v) => v.clone(), - None => return Self::false_val(), - }; - - let bypass = self - .binary_bypasses_l7 - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_enforced = self - .l7_enforced - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_write = self - .l7_allows_write - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let binary_write = self - .binary_can_write - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let cred_write = self - .credential_has_write - .get(&eid.host) - .cloned() - .unwrap_or_else(Self::false_val); - - Bool::and(&[ - has_access, - binary_write, - Bool::or(&[!l7_enforced, l7_write, bypass]), - cred_write, - ]) - } - - /// Build a Z3 expression for whether data can be exfiltrated via this path. - pub fn can_exfil_via_endpoint(&self, bpath: &str, eid: &EndpointId) -> Bool { - let ek = eid.key(); - let access_key = format!("{bpath}:{ek}"); - - let has_access = match self.policy_allows.get(&access_key) { - Some(v) => v.clone(), - None => return Self::false_val(), - }; - - let exfil = self - .binary_can_exfil - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let bypass = self - .binary_bypasses_l7 - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_enforced = self - .l7_enforced - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_write = self - .l7_allows_write - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let http = self - .binary_can_construct_http - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - - Bool::and(&[ - has_access, - exfil, - Bool::or(&[ - Bool::and(&[!l7_enforced, http.clone()]), - Bool::and(&[l7_write, http]), - bypass, - ]), - ]) - } - - /// Check satisfiability of an expression against the base constraints. - pub fn check_sat(&self, expr: &Bool) -> SatResult { - self.solver.push(); - self.solver.assert(expr); - let result = self.solver.check(); - self.solver.pop(1); - result - } -} - -/// Build a reachability model from the given inputs. -pub fn build_model( - policy: PolicyModel, - credentials: CredentialSet, - binary_registry: BinaryRegistry, -) -> ReachabilityModel { - // Ensure the thread-local Z3 context is initialized - let _ctx = Context::thread_local(); - ReachabilityModel::new(policy, credentials, binary_registry) -} diff --git a/crates/openshell-prover/src/policy.rs b/crates/openshell-prover/src/policy.rs index aa40d07560..cb00525c9f 100644 --- a/crates/openshell-prover/src/policy.rs +++ b/crates/openshell-prover/src/policy.rs @@ -7,45 +7,12 @@ //! types) because the prover needs fields like `access`, `protocol`, and //! individual L7 rules that the proto representation strips. -use std::collections::{BTreeMap, HashSet}; -use std::path::Path; +use std::collections::BTreeMap; use miette::{IntoDiagnostic, Result, WrapErr}; use serde::Deserialize; use serde::de::IgnoredAny; -// --------------------------------------------------------------------------- -// Policy intent -// --------------------------------------------------------------------------- - -/// The inferred access intent for an endpoint. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PolicyIntent { - L4Only, - ReadOnly, - ReadWrite, - Full, - Custom, -} - -impl std::fmt::Display for PolicyIntent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::L4Only => write!(f, "l4_only"), - Self::ReadOnly => write!(f, "read_only"), - Self::ReadWrite => write!(f, "read_write"), - Self::Full => write!(f, "full"), - Self::Custom => write!(f, "custom"), - } - } -} - -/// HTTP methods considered to be write operations. -pub const WRITE_METHODS: &[&str] = &["POST", "PUT", "PATCH", "DELETE"]; - -/// All standard HTTP methods. -const ALL_METHODS: &[&str] = &["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"]; - // --------------------------------------------------------------------------- // Serde types — mirrors the YAML schema // --------------------------------------------------------------------------- @@ -55,6 +22,8 @@ struct PolicyFile { #[allow(dead_code)] version: Option, #[serde(default)] + metadata: Option, + #[serde(default)] filesystem_policy: Option, #[serde(default)] network_policies: Option>, @@ -65,6 +34,24 @@ struct PolicyFile { #[serde(default)] #[allow(dead_code)] process: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Debug, Deserialize, Default)] +struct ManagedPolicyMetadataDef { + #[serde(default)] + policy_id: String, + #[serde(default)] + version: u64, + #[serde(default)] + allowed_modes: Vec, + #[serde(default)] + default_mode: String, + #[serde(default)] + audit_label: String, + #[serde(flatten)] + extra: BTreeMap, } #[derive(Debug, Deserialize)] @@ -75,6 +62,8 @@ struct FilesystemDef { read_only: Vec, #[serde(default)] read_write: Vec, + #[serde(flatten)] + extra: BTreeMap, } #[derive(Debug, Deserialize)] @@ -85,6 +74,8 @@ struct NetworkPolicyRuleDef { endpoints: Vec, #[serde(default)] binaries: Vec, + #[serde(flatten)] + extra: BTreeMap, } #[derive(Debug, Deserialize)] @@ -92,6 +83,8 @@ struct EndpointDef { #[serde(default)] host: String, #[serde(default)] + path: String, + #[serde(default)] port: u16, #[serde(default)] ports: Vec, @@ -107,11 +100,19 @@ struct EndpointDef { rules: Vec, #[serde(default)] allowed_ips: Vec, + #[serde(default)] + deny_rules: Vec, + #[serde(default)] + review: Option, + #[serde(flatten)] + extra: BTreeMap, } #[derive(Debug, Deserialize)] struct L7RuleDef { allow: L7AllowDef, + #[serde(flatten)] + extra: BTreeMap, } #[derive(Debug, Deserialize)] @@ -122,11 +123,39 @@ struct L7AllowDef { path: String, #[serde(default)] command: String, + #[serde(default)] + review: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct L7DenyRuleDef { + #[serde(default)] + method: String, + #[serde(default)] + path: String, + #[serde(default)] + command: String, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Debug, Deserialize, Default)] +struct ReviewRequirementDef { + #[serde(default)] + required: bool, + #[serde(default)] + reason: String, + #[serde(flatten)] + extra: BTreeMap, } #[derive(Debug, Deserialize)] struct BinaryDef { path: String, + #[serde(flatten)] + extra: BTreeMap, } // --------------------------------------------------------------------------- @@ -139,12 +168,32 @@ pub struct L7Rule { pub method: String, pub path: String, pub command: String, + pub review: ReviewRequirement, + pub extra_fields: Vec, +} + +/// A single L7 deny rule. +#[derive(Debug, Clone)] +pub struct L7DenyRule { + pub method: String, + pub path: String, + pub command: String, + pub extra_fields: Vec, +} + +/// Review metadata attached to an allow surface in a managed maximum. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReviewRequirement { + pub required: bool, + pub reason: String, + pub extra_fields: Vec, } /// A network endpoint in the policy. #[derive(Debug, Clone)] pub struct Endpoint { pub host: String, + pub path: String, pub port: u16, pub ports: Vec, pub protocol: String, @@ -153,6 +202,9 @@ pub struct Endpoint { pub access: String, pub rules: Vec, pub allowed_ips: Vec, + pub deny_rules: Vec, + pub review: ReviewRequirement, + pub extra_fields: Vec, } impl Endpoint { @@ -161,36 +213,6 @@ impl Endpoint { !self.protocol.is_empty() } - /// The inferred access intent. - pub fn intent(&self) -> PolicyIntent { - if self.protocol.is_empty() { - return PolicyIntent::L4Only; - } - match self.access.as_str() { - "read-only" => PolicyIntent::ReadOnly, - "read-write" => PolicyIntent::ReadWrite, - "full" => PolicyIntent::Full, - _ => { - if self.rules.is_empty() { - return PolicyIntent::Custom; - } - let methods: HashSet = - self.rules.iter().map(|r| r.method.to_uppercase()).collect(); - let read_only: HashSet = ["GET", "HEAD", "OPTIONS"] - .iter() - .map(|s| (*s).to_owned()) - .collect(); - if methods.is_subset(&read_only) { - PolicyIntent::ReadOnly - } else if !methods.contains("DELETE") { - PolicyIntent::ReadWrite - } else { - PolicyIntent::Full - } - } - } - } - /// The effective list of ports for this endpoint. pub fn effective_ports(&self) -> Vec { if !self.ports.is_empty() { @@ -201,44 +223,13 @@ impl Endpoint { } vec![] } - - /// The set of HTTP methods this endpoint allows. Empty means all (L4-only). - pub fn allowed_methods(&self) -> HashSet { - if self.protocol.is_empty() { - return HashSet::new(); // L4-only: all traffic passes - } - match self.access.as_str() { - "read-only" => ["GET", "HEAD", "OPTIONS"] - .iter() - .map(|s| (*s).to_owned()) - .collect(), - "read-write" => ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"] - .iter() - .map(|s| (*s).to_owned()) - .collect(), - "full" => ALL_METHODS.iter().map(|s| (*s).to_owned()).collect(), - _ => { - if !self.rules.is_empty() { - let mut methods = HashSet::new(); - for r in &self.rules { - let m = r.method.to_uppercase(); - if m == "*" { - return ALL_METHODS.iter().map(|s| (*s).to_owned()).collect(); - } - methods.insert(m); - } - return methods; - } - HashSet::new() - } - } - } } /// A binary path entry in a network policy rule. #[derive(Debug, Clone)] pub struct Binary { pub path: String, + pub extra_fields: Vec, } /// A named network policy rule containing endpoints and binaries. @@ -247,6 +238,7 @@ pub struct NetworkPolicyRule { pub name: String, pub endpoints: Vec, pub binaries: Vec, + pub extra_fields: Vec, } /// Filesystem access policy. @@ -255,95 +247,46 @@ pub struct FilesystemPolicy { pub include_workdir: bool, pub read_only: Vec, pub read_write: Vec, -} - -impl FilesystemPolicy { - /// All readable paths (union of `read_only` and `read_write`), with workdir - /// added when `include_workdir` is true and not already present. - pub fn readable_paths(&self) -> Vec { - let mut paths: Vec = self - .read_only - .iter() - .chain(self.read_write.iter()) - .cloned() - .collect(); - if self.include_workdir && !paths.iter().any(|p| p == "/sandbox") { - paths.push("/sandbox".to_owned()); - } - paths - } + pub extra_fields: Vec, } /// The top-level policy model used by the prover. #[derive(Debug, Clone)] pub struct PolicyModel { pub version: u32, + pub managed_metadata: Option, pub filesystem_policy: FilesystemPolicy, pub network_policies: BTreeMap, + pub extra_fields: Vec, +} + +/// Gateway-owned metadata present only on managed maximum policy documents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedPolicyMetadata { + pub policy_id: String, + pub version: u64, + pub allowed_modes: Vec, + pub default_mode: String, + pub audit_label: String, + pub extra_fields: Vec, } impl Default for PolicyModel { fn default() -> Self { Self { version: 1, + managed_metadata: None, filesystem_policy: FilesystemPolicy::default(), network_policies: BTreeMap::new(), + extra_fields: Vec::new(), } } } -impl PolicyModel { - /// All (`policy_name`, endpoint) pairs. - pub fn all_endpoints(&self) -> Vec<(&str, &Endpoint)> { - let mut result = Vec::new(); - for (name, rule) in &self.network_policies { - for ep in &rule.endpoints { - result.push((name.as_str(), ep)); - } - } - result - } - - /// Deduplicated list of all binary paths across all policies. - pub fn all_binaries(&self) -> Vec<&Binary> { - let mut seen = HashSet::new(); - let mut result = Vec::new(); - for rule in self.network_policies.values() { - for b in &rule.binaries { - if seen.insert(&b.path) { - result.push(b); - } - } - } - result - } - - /// All (binary, `policy_name`, endpoint) triples. - pub fn binary_endpoint_pairs(&self) -> Vec<(&Binary, &str, &Endpoint)> { - let mut result = Vec::new(); - for (name, rule) in &self.network_policies { - for b in &rule.binaries { - for ep in &rule.endpoints { - result.push((b, name.as_str(), ep)); - } - } - } - result - } -} - // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- -/// Parse an `OpenShell` policy YAML file into a [`PolicyModel`]. -pub fn parse_policy(path: &Path) -> Result { - let contents = std::fs::read_to_string(path) - .into_diagnostic() - .wrap_err_with(|| format!("reading policy file {}", path.display()))?; - parse_policy_str(&contents) -} - /// Parse a policy YAML string into a [`PolicyModel`]. pub fn parse_policy_str(yaml: &str) -> Result { let raw: PolicyFile = serde_yml::from_str(yaml) @@ -355,6 +298,7 @@ pub fn parse_policy_str(yaml: &str) -> Result { include_workdir: fs_def.include_workdir, read_only: fs_def.read_only, read_write: fs_def.read_write, + extra_fields: fs_def.extra.into_keys().collect(), }, None => FilesystemPolicy::default(), }; @@ -369,14 +313,34 @@ pub fn parse_policy_str(yaml: &str) -> Result { let rules = ep_raw .rules .into_iter() - .map(|r| L7Rule { - method: r.allow.method, - path: r.allow.path, - command: r.allow.command, + .map(|r| { + let allow = r.allow; + let mut extra_fields = r.extra.into_keys().collect::>(); + extra_fields.extend(allow.extra.into_keys()); + extra_fields.sort(); + extra_fields.dedup(); + L7Rule { + method: allow.method, + path: allow.path, + command: allow.command, + review: review_requirement(allow.review), + extra_fields, + } + }) + .collect(); + let deny_rules = ep_raw + .deny_rules + .into_iter() + .map(|deny| L7DenyRule { + method: deny.method, + path: deny.path, + command: deny.command, + extra_fields: deny.extra.into_keys().collect(), }) .collect(); Endpoint { host: ep_raw.host, + path: ep_raw.path, port: ep_raw.port, ports: ep_raw.ports, protocol: ep_raw.protocol, @@ -385,6 +349,9 @@ pub fn parse_policy_str(yaml: &str) -> Result { access: ep_raw.access, rules, allowed_ips: ep_raw.allowed_ips, + deny_rules, + review: review_requirement(ep_raw.review), + extra_fields: ep_raw.extra.into_keys().collect(), } }) .collect(); @@ -392,7 +359,10 @@ pub fn parse_policy_str(yaml: &str) -> Result { let binaries = rule_raw .binaries .into_iter() - .map(|b| Binary { path: b.path }) + .map(|b| Binary { + path: b.path, + extra_fields: b.extra.into_keys().collect(), + }) .collect(); let name = rule_raw.name.unwrap_or_else(|| key.clone()); @@ -402,6 +372,7 @@ pub fn parse_policy_str(yaml: &str) -> Result { name, endpoints, binaries, + extra_fields: rule_raw.extra.into_keys().collect(), }, ); } @@ -409,7 +380,24 @@ pub fn parse_policy_str(yaml: &str) -> Result { Ok(PolicyModel { version: raw.version.unwrap_or(1), + managed_metadata: raw.metadata.map(|metadata| ManagedPolicyMetadata { + policy_id: metadata.policy_id, + version: metadata.version, + allowed_modes: metadata.allowed_modes, + default_mode: metadata.default_mode, + audit_label: metadata.audit_label, + extra_fields: metadata.extra.into_keys().collect(), + }), filesystem_policy: fs, network_policies, + extra_fields: raw.extra.into_keys().collect(), + }) +} + +fn review_requirement(review: Option) -> ReviewRequirement { + review.map_or_else(ReviewRequirement::default, |review| ReviewRequirement { + required: review.required, + reason: review.reason, + extra_fields: review.extra.into_keys().collect(), }) } diff --git a/crates/openshell-prover/src/queries.rs b/crates/openshell-prover/src/queries.rs deleted file mode 100644 index 24e1402b7f..0000000000 --- a/crates/openshell-prover/src/queries.rs +++ /dev/null @@ -1,347 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Verification queries. -//! -//! The prover answers four formal questions about a policy and emits one -//! finding category per "yes" answer (see -//! [`crate::finding::category`] for the canonical names). The output is -//! categorical — there is no severity grade. The gateway's -//! `finding_delta` decides which findings are *new* relative to a -//! baseline, and the auto-approval gate triggers when no new findings -//! exist. -//! -//! Categories: -//! -//! 1. **Link-local reach** — any reachable path to a host in -//! `169.254.0.0/16` or `fe80::/10`. Emitted unconditionally: -//! cloud-metadata endpoints serve credentials, so reachability alone -//! is the risk. -//! 2. **L7-bypass + credential** — a binary whose wire protocol the L7 -//! proxy cannot inspect (`git-remote-https`, `ssh`, `nc`) gains reach -//! to a host where a sandbox credential is in scope. -//! 3. **Credential reach expansion** — a binary gains credentialed reach -//! to a host:port it could not reach before. The gateway's delta -//! surfaces only newly-reachable tuples. -//! 4. **Capability expansion** — on a (binary, host, port) that already -//! had credentialed reach, the policy adds a new HTTP method. The -//! gateway's delta surfaces only newly-allowed methods. -//! -//! These categories are intended to be (mostly) mutually exclusive per -//! underlying change: at the gateway, `capability_expansion` paths whose -//! `(binary, host, port)` is also in the `credential_reach_expansion` -//! delta are suppressed, so a brand-new credentialed reach surfaces as -//! one `credential_reach_expansion` finding rather than that plus N -//! capability findings. See `crates/openshell-server/src/grpc/policy.rs`. - -use std::collections::HashSet; -use std::net::IpAddr; - -use z3::SatResult; - -use crate::finding::{ExfilPath, Finding, FindingPath, category}; -use crate::model::ReachabilityModel; - -/// Return true iff the host string parses as an IP in a reserved -/// link-local range (IPv4 `169.254.0.0/16` or IPv6 `fe80::/10`). -/// -/// Hostname-only strings (not parseable as IPs) return false. We don't -/// perform DNS resolution at validation time; the model evaluates the -/// policy as written. -pub(crate) fn is_link_local(host: &str) -> bool { - match host.parse::() { - Ok(IpAddr::V4(v4)) => v4.is_link_local(), - Ok(IpAddr::V6(v6)) => v6.is_unicast_link_local(), - Err(_) => false, - } -} - -/// Return true for static cloud metadata hostnames that should be treated like -/// link-local metadata reach without performing DNS resolution. -pub(crate) fn is_known_metadata_hostname(host: &str) -> bool { - let normalized = host.trim().trim_end_matches('.').to_ascii_lowercase(); - matches!(normalized.as_str(), "metadata.google.internal") -} - -fn is_link_local_or_metadata_host(host: &str) -> bool { - is_link_local(host) || is_known_metadata_hostname(host) -} - -/// Run all four formal queries against the model and emit one finding -/// per category that has at least one path. -/// -/// We deliberately do NOT gate on `filesystem_policy.readable_paths()` -/// being non-empty: the credential itself is the lever for the tracked -/// risks, not anything in `/etc/`. -pub fn check_credential_safety(model: &ReachabilityModel) -> Vec { - let mut reach_paths: Vec = Vec::new(); - let mut capability_paths: Vec = Vec::new(); - let mut bypass_paths: Vec = Vec::new(); - let mut link_local_paths: Vec = Vec::new(); - - for bpath in &model.binary_paths { - let cap = model.binary_registry.get_or_unknown(bpath); - if !cap.can_exfiltrate { - continue; - } - - for eid in &model.endpoints { - let expr = model.can_exfil_via_endpoint(bpath, eid); - if model.check_sat(&expr) != SatResult::Sat { - continue; - } - - let host_is_link_local = is_link_local_or_metadata_host(&eid.host); - let has_credential = !model.credentials.credentials_for_host(&eid.host).is_empty(); - - // Tier 1: link-local/metadata. Unconditional. Other categories - // are not emitted on these hosts — the metadata signal is the - // story. - if host_is_link_local { - link_local_paths.push(ExfilPath { - binary: bpath.clone(), - endpoint_host: eid.host.clone(), - endpoint_port: eid.port, - mechanism: format!( - "Link-local endpoint — {bpath} can reach the host's metadata range \ - (cloud-credential exfiltration territory regardless of declared scopes)" - ), - policy_name: eid.policy_name.clone(), - category: category::LINK_LOCAL_REACH.to_string(), - method: String::new(), - }); - continue; - } - - // Un-credentialed reach is not a tracked risk. - if !has_credential { - continue; - } - - // Tier 2: bypass-L7 binary on a credentialed host. Wire - // protocol cannot be inspected; mark and move on. - if cap.bypasses_l7() { - bypass_paths.push(ExfilPath { - binary: bpath.clone(), - endpoint_host: eid.host.clone(), - endpoint_port: eid.port, - mechanism: format!( - "{} — uses non-HTTP protocol, bypasses L7 inspection, and a credential \ - is in scope for this host", - cap.description - ), - policy_name: eid.policy_name.clone(), - category: category::L7_BYPASS_CREDENTIALED.to_string(), - method: String::new(), - }); - continue; - } - - // Tiers 3 + 4: credentialed L7 reach. We emit both - // credential_reach_expansion and capability_expansion paths - // here; the gateway's delta will keep only the relevant - // category (see `finding_delta` and the suppression rule). - reach_paths.push(ExfilPath { - binary: bpath.clone(), - endpoint_host: eid.host.clone(), - endpoint_port: eid.port, - mechanism: format!( - "Binary {bpath} has credentialed reach to {host}:{port}", - host = eid.host, - port = eid.port, - ), - policy_name: eid.policy_name.clone(), - category: category::CREDENTIAL_REACH_EXPANSION.to_string(), - method: String::new(), - }); - - // One capability_expansion path per allowed method on this - // (binary, host:port) under this specific rule. - let methods = endpoint_allowed_methods_in_rule( - &model.policy, - &eid.policy_name, - &eid.host, - eid.port, - ); - for method in methods { - capability_paths.push(ExfilPath { - binary: bpath.clone(), - endpoint_host: eid.host.clone(), - endpoint_port: eid.port, - mechanism: format!( - "Method {method} allowed for {bpath} on {host}:{port}", - host = eid.host, - port = eid.port, - ), - policy_name: eid.policy_name.clone(), - category: category::CAPABILITY_EXPANSION.to_string(), - method, - }); - } - } - } - - let mut findings = Vec::new(); - if !link_local_paths.is_empty() { - findings.push(build_finding( - category::LINK_LOCAL_REACH, - "Link-Local or Metadata Reach", - "Reach to a host in a link-local range or known metadata hostname — cloud-metadata territory.", - link_local_paths, - vec![ - "Endpoint host is in a link-local range or known metadata hostname \ - (cloud-metadata territory). Sandboxes should not reach these \ - endpoints — reaching them can return host credentials the sandbox \ - should not have." - .to_owned(), - ], - )); - } - if !bypass_paths.is_empty() { - findings.push(build_finding( - category::L7_BYPASS_CREDENTIALED, - "L7-Bypass Binary with Credential in Scope", - "A binary using a wire protocol the L7 proxy cannot inspect has reach to \ - a host where a sandbox credential is in scope.", - bypass_paths, - vec![ - "Binaries using non-HTTP protocols (git, ssh, nc) bypass L7 inspection. \ - Remove these binaries from the policy if credentialed write access is \ - not intended." - .to_owned(), - ], - )); - } - if !reach_paths.is_empty() { - findings.push(build_finding( - category::CREDENTIAL_REACH_EXPANSION, - "Credentialed Reach Expansion", - "A binary gained credentialed reach to a (host, port) it could not reach \ - before.", - reach_paths, - vec![ - "Credentialed reach is a privileged action surface. A human reviewer \ - should confirm the binary should be able to authenticate to this host \ - at all." - .to_owned(), - ], - )); - } - if !capability_paths.is_empty() { - findings.push(build_finding( - category::CAPABILITY_EXPANSION, - "Capability Expansion on Credentialed Host", - "New methods were added on a (binary, host, port) that already had \ - credentialed reach. The agent is changing what the sandbox can do with \ - its credentials.", - capability_paths, - vec![ - "A capability expansion is a stated intent change. The reviewer should \ - confirm the new methods (especially mutating methods like PUT, POST, \ - PATCH, DELETE) are part of the agent's task." - .to_owned(), - ], - )); - } - findings -} - -fn build_finding( - query: &str, - title: &str, - description: &str, - paths: Vec, - remediation: Vec, -) -> Finding { - let n = paths.len(); - Finding { - query: query.to_owned(), - title: title.to_owned(), - // Per-finding description prefixes the count with the category's - // canonical sentence so the audit string is self-describing. - description: format!("{description} ({n} path(s).)"), - paths: paths.into_iter().map(FindingPath::Exfil).collect(), - remediation, - accepted: false, - accepted_reason: String::new(), - } -} - -/// Run all queries (single entry point for end-to-end callers). -pub fn run_all_queries(model: &ReachabilityModel) -> Vec { - check_credential_safety(model) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Allowed HTTP methods for the endpoint in `policy.network_policies[policy_name]` -/// matching `(host, port)`. Returns empty when the rule or endpoint is not -/// found (e.g. SAT path threaded through a stale model). -fn endpoint_allowed_methods_in_rule( - policy: &crate::policy::PolicyModel, - policy_name: &str, - host: &str, - port: u16, -) -> HashSet { - let Some(rule) = policy.network_policies.get(policy_name) else { - return HashSet::new(); - }; - for ep in &rule.endpoints { - if ep.host.eq_ignore_ascii_case(host) && ep.effective_ports().contains(&port) { - return ep.allowed_methods(); - } - } - HashSet::new() -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn is_link_local_recognises_ipv4_169_254() { - assert!(is_link_local("169.254.169.254")); - assert!(is_link_local("169.254.0.1")); - assert!(is_link_local("169.254.255.255")); - } - - #[test] - fn is_link_local_recognises_ipv6_fe80() { - assert!(is_link_local("fe80::1")); - assert!(is_link_local("fe80::abcd:ef01")); - } - - #[test] - fn is_link_local_rejects_non_link_local_ips() { - assert!(!is_link_local("8.8.8.8")); - assert!(!is_link_local("10.0.0.1")); - assert!(!is_link_local("192.168.1.1")); - assert!(!is_link_local("::1")); - assert!(!is_link_local("2001:db8::1")); - } - - #[test] - fn is_link_local_rejects_hostnames() { - assert!(!is_link_local("api.github.com")); - assert!(!is_link_local("")); - } - - #[test] - fn is_known_metadata_hostname_recognises_gcp_variants() { - assert!(is_known_metadata_hostname("metadata.google.internal")); - assert!(is_known_metadata_hostname("METADATA.GOOGLE.INTERNAL")); - assert!(is_known_metadata_hostname("metadata.google.internal.")); - } - - #[test] - fn is_known_metadata_hostname_rejects_other_hostnames() { - assert!(!is_known_metadata_hostname("api.github.com")); - assert!(!is_known_metadata_hostname("")); - } -} diff --git a/crates/openshell-prover/src/registry.rs b/crates/openshell-prover/src/registry.rs deleted file mode 100644 index 63a3fce3bc..0000000000 --- a/crates/openshell-prover/src/registry.rs +++ /dev/null @@ -1,278 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Binary capability registry — loads YAML descriptors that describe what each -//! binary can do (protocols, exfiltration, HTTP construction, etc.). -//! -//! The built-in registry is embedded at compile time via `include_dir!`. -//! A filesystem override can be provided at runtime for custom registries. - -use std::collections::HashMap; - -use include_dir::{Dir, include_dir}; -use miette::{IntoDiagnostic, Result, WrapErr}; -use serde::Deserialize; - -static EMBEDDED_REGISTRY: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/registry"); - -// --------------------------------------------------------------------------- -// Serde types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct BinaryCapabilityDef { - binary: String, - #[serde(default)] - description: String, - #[serde(default)] - protocols: Vec, - #[serde(default)] - spawns: Vec, - #[serde(default)] - can_exfiltrate: bool, - #[serde(default)] - exfil_mechanism: String, - #[serde(default)] - can_construct_http: bool, -} - -#[derive(Debug, Deserialize)] -struct BinaryProtocolDef { - #[serde(default)] - name: String, - #[serde(default)] - transport: String, - #[serde(default)] - description: String, - #[serde(default)] - bypasses_l7: bool, - #[serde(default)] - actions: Vec, -} - -#[derive(Debug, Deserialize)] -struct BinaryActionDef { - #[serde(default)] - name: String, - #[serde(default, rename = "type")] - action_type: String, - #[serde(default)] - description: String, -} - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/// Type of action a binary can perform. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ActionType { - Read, - Write, - Destructive, -} - -impl ActionType { - fn from_str(s: &str) -> Self { - match s { - "write" => Self::Write, - "destructive" => Self::Destructive, - _ => Self::Read, - } - } -} - -/// A single action a binary protocol supports. -#[derive(Debug, Clone)] -pub struct BinaryAction { - pub name: String, - pub action_type: ActionType, - pub description: String, -} - -/// A protocol supported by a binary. -#[derive(Debug, Clone)] -pub struct BinaryProtocol { - pub name: String, - pub transport: String, - pub description: String, - pub bypasses_l7: bool, - pub actions: Vec, -} - -impl BinaryProtocol { - /// Whether any action in this protocol is a write or destructive action. - pub fn can_write(&self) -> bool { - self.actions - .iter() - .any(|a| matches!(a.action_type, ActionType::Write | ActionType::Destructive)) - } -} - -/// Capability descriptor for a single binary. -#[derive(Debug, Clone)] -pub struct BinaryCapability { - pub path: String, - pub description: String, - pub protocols: Vec, - pub spawns: Vec, - pub can_exfiltrate: bool, - pub exfil_mechanism: String, - pub can_construct_http: bool, -} - -impl BinaryCapability { - /// Whether any protocol bypasses L7 inspection. - pub fn bypasses_l7(&self) -> bool { - self.protocols.iter().any(|p| p.bypasses_l7) - } - - /// Whether the binary can perform write actions. - pub fn can_write(&self) -> bool { - self.protocols.iter().any(BinaryProtocol::can_write) || self.can_construct_http - } - - /// Short mechanisms by which this binary can write. - pub fn write_mechanisms(&self) -> Vec { - let mut mechanisms = Vec::new(); - for p in &self.protocols { - if p.can_write() { - for a in &p.actions { - if matches!(a.action_type, ActionType::Write | ActionType::Destructive) { - mechanisms.push(format!("{}: {}", p.name, a.name)); - } - } - } - } - if self.can_construct_http { - mechanisms.push("arbitrary HTTP request construction".to_owned()); - } - mechanisms - } -} - -/// Registry of binary capability descriptors. -#[derive(Debug, Clone, Default)] -pub struct BinaryRegistry { - binaries: HashMap, -} - -impl BinaryRegistry { - /// Look up a binary by exact path. - pub fn get(&self, path: &str) -> Option<&BinaryCapability> { - self.binaries.get(path) - } - - /// Look up a binary, falling back to glob matching, then to a conservative - /// unknown descriptor. - pub fn get_or_unknown(&self, path: &str) -> BinaryCapability { - if let Some(cap) = self.binaries.get(path) { - return cap.clone(); - } - for (reg_path, cap) in &self.binaries { - if reg_path.contains('*') - && let Ok(pattern) = glob::Pattern::new(reg_path) - && pattern.matches(path) - { - return cap.clone(); - } - } - BinaryCapability { - path: path.to_owned(), - description: "Unknown binary — not in registry".to_owned(), - protocols: Vec::new(), - spawns: Vec::new(), - can_exfiltrate: true, - exfil_mechanism: String::new(), - can_construct_http: true, - } - } -} - -// --------------------------------------------------------------------------- -// Loading -// --------------------------------------------------------------------------- - -fn parse_binary_capability(contents: &str, source: &str) -> Result { - let raw: BinaryCapabilityDef = serde_yml::from_str(contents) - .into_diagnostic() - .wrap_err_with(|| format!("parsing binary descriptor {source}"))?; - - let protocols = raw - .protocols - .into_iter() - .map(|p| { - let actions = p - .actions - .into_iter() - .map(|a| BinaryAction { - name: a.name, - action_type: ActionType::from_str(&a.action_type), - description: a.description, - }) - .collect(); - BinaryProtocol { - name: p.name, - transport: p.transport, - description: p.description, - bypasses_l7: p.bypasses_l7, - actions, - } - }) - .collect(); - - Ok(BinaryCapability { - path: raw.binary, - description: raw.description, - protocols, - spawns: raw.spawns, - can_exfiltrate: raw.can_exfiltrate, - exfil_mechanism: raw.exfil_mechanism, - can_construct_http: raw.can_construct_http, - }) -} - -/// Load binary registry from the compile-time embedded registry data. -pub fn load_embedded_binary_registry() -> Result { - let mut binaries = HashMap::new(); - if let Some(dir) = EMBEDDED_REGISTRY.get_dir("binaries") { - for file in dir.files() { - if file.path().extension().is_some_and(|ext| ext == "yaml") { - let contents = file.contents_utf8().ok_or_else(|| { - miette::miette!("non-UTF8 registry file: {}", file.path().display()) - })?; - let cap = parse_binary_capability(contents, &file.path().display().to_string())?; - binaries.insert(cap.path.clone(), cap); - } - } - } - Ok(BinaryRegistry { binaries }) -} - -/// Load binary registry from a filesystem directory override. -pub fn load_binary_registry_from_dir(registry_dir: &std::path::Path) -> Result { - let mut binaries = HashMap::new(); - let binaries_dir = registry_dir.join("binaries"); - if binaries_dir.is_dir() { - let entries = std::fs::read_dir(&binaries_dir) - .into_diagnostic() - .wrap_err_with(|| format!("reading directory {}", binaries_dir.display()))?; - for entry in entries { - let entry = entry.into_diagnostic()?; - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let contents = std::fs::read_to_string(&path) - .into_diagnostic() - .wrap_err_with(|| format!("reading {}", path.display()))?; - let cap = parse_binary_capability(&contents, &path.display().to_string())?; - binaries.insert(cap.path.clone(), cap); - } - } - } - Ok(BinaryRegistry { binaries }) -} - -/// Accessor for the embedded registry (used by credentials module for API descriptors). -pub fn embedded_registry() -> &'static Dir<'static> { - &EMBEDDED_REGISTRY -} diff --git a/crates/openshell-prover/src/report.rs b/crates/openshell-prover/src/report.rs deleted file mode 100644 index f250eb1cd5..0000000000 --- a/crates/openshell-prover/src/report.rs +++ /dev/null @@ -1,367 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Terminal report rendering (full and compact). -//! -//! The prover output is categorical, not severity-graded. Each finding -//! names *what* the policy change does (e.g., `capability_expansion`); -//! per-path evidence carries the structured detail. There is no HIGH / -//! MEDIUM / CRITICAL grade — the category itself is the signal. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; - -use owo_colors::OwoColorize; - -use crate::finding::{Finding, FindingPath, category}; - -// --------------------------------------------------------------------------- -// Category labels (display strings keyed off `Finding.query`) -// --------------------------------------------------------------------------- - -fn category_label(query: &str) -> &str { - match query { - category::LINK_LOCAL_REACH => "link-local reach", - category::L7_BYPASS_CREDENTIALED => "L7-bypass binary with credential", - category::CREDENTIAL_REACH_EXPANSION => "credentialed reach expansion", - category::CAPABILITY_EXPANSION => "capability expansion on credentialed host", - _ => "unknown finding", - } -} - -// --------------------------------------------------------------------------- -// One-line shorthand (used by the gateway's `validation_result`) -// --------------------------------------------------------------------------- - -/// Render a finding as one or more single-line strings, suitable for -/// embedding in the gateway `validation_result`, demo output, and logs. -/// -/// Shape: `: ` — one line per path. The -/// gateway concatenates these into the chunk's `validation_result` so -/// the reviewer reads what changed without parsing the category enum. -pub fn finding_shorthand(finding: &Finding) -> String { - let mut lines = Vec::new(); - for path in &finding.paths { - let FindingPath::Exfil(p) = path; - lines.push(format_path_line(&finding.query, p)); - } - lines.join("\n ") -} - -fn format_path_line(query: &str, p: &crate::finding::ExfilPath) -> String { - let endpoint = format!("{}:{}", p.endpoint_host, p.endpoint_port); - match query { - category::LINK_LOCAL_REACH => { - format!("link_local_reach: {endpoint} via {}", p.binary) - } - category::L7_BYPASS_CREDENTIALED => { - format!("l7_bypass_credentialed: {endpoint} via {}", p.binary) - } - category::CREDENTIAL_REACH_EXPANSION => { - format!("credential_reach_expansion: {endpoint} via {}", p.binary) - } - category::CAPABILITY_EXPANSION => { - format!( - "capability_expansion: {method} on {endpoint} via {bin}", - method = p.method, - bin = p.binary - ) - } - _ => format!("{query}: {endpoint} via {}", p.binary), - } -} - -// --------------------------------------------------------------------------- -// Compact output (CLI lint mode) -// --------------------------------------------------------------------------- - -/// Render compact output (one-line-per-finding-line for demos and CI). -/// Returns exit code: 0 = pass, 1 = any findings present. -pub fn render_compact(findings: &[Finding], _policy_path: &str, _credentials_path: &str) -> i32 { - let active: Vec<&Finding> = findings.iter().filter(|f| !f.accepted).collect(); - let accepted: Vec<&Finding> = findings.iter().filter(|f| f.accepted).collect(); - - for finding in &active { - for path in &finding.paths { - let FindingPath::Exfil(p) = path; - println!(" {} {}", "•".yellow(), format_path_line(&finding.query, p)); - } - if !finding.paths.is_empty() { - println!(); - } - } - - for finding in &accepted { - println!( - " {} {}", - "ACCEPTED".dimmed(), - category_label(&finding.query).dimmed() - ); - } - if !accepted.is_empty() { - println!(); - } - - let accepted_note = if accepted.is_empty() { - String::new() - } else { - format!(", {} accepted", accepted.len()) - }; - - let path_count: usize = active.iter().map(|f| f.paths.len()).sum(); - if path_count > 0 { - println!( - " {} {path_count} finding path(s) require review{accepted_note}", - " REVIEW ".black().bold().on_yellow() - ); - 1 - } else { - println!( - " {} no findings{accepted_note}", - " PASS ".white().bold().on_green() - ); - 0 - } -} - -// --------------------------------------------------------------------------- -// Full terminal report -// --------------------------------------------------------------------------- - -/// Render a full terminal report with finding panels. -/// Returns exit code: 0 = pass, 1 = any findings present. -pub fn render_report(findings: &[Finding], policy_path: &str, credentials_path: &str) -> i32 { - let policy_name = Path::new(policy_path) - .file_name() - .map_or("policy.yaml", |n| n.to_str().unwrap_or("policy.yaml")); - let creds_name = Path::new(credentials_path) - .file_name() - .map_or("credentials.yaml", |n| { - n.to_str().unwrap_or("credentials.yaml") - }); - - println!(); - println!( - "{}", - "\u{250c}\u{2500}\u{2500} OpenShell Policy Prover \u{2500}\u{2500}\u{2510}".blue() - ); - println!(" Policy: {policy_name}"); - println!(" Credentials: {creds_name}"); - println!(); - - let active: Vec<&Finding> = findings.iter().filter(|f| !f.accepted).collect(); - let accepted: Vec<&Finding> = findings.iter().filter(|f| f.accepted).collect(); - - // Per-category summary - let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); - for f in &active { - *counts.entry(f.query.as_str()).or_default() += f.paths.len(); - } - - if active.is_empty() && accepted.is_empty() { - println!("{}", "No findings. Policy posture is clean.".green().bold()); - return 0; - } - - println!("{}", "Finding Summary".bold().underline()); - for (query, count) in &counts { - println!(" {:>40} {count} path(s)", category_label(query).yellow()); - } - if !accepted.is_empty() { - println!(" {:>40} {}", "ACCEPTED".dimmed(), accepted.len()); - } - println!(); - - for (i, finding) in active.iter().enumerate() { - println!( - "--- Finding #{} [{}] ---", - i + 1, - category_label(&finding.query) - ); - println!(" {}", finding.title.bold()); - println!(" {}", finding.description); - println!(); - render_paths(&finding.paths); - if !finding.remediation.is_empty() { - println!(" {}", "Remediation:".bold()); - for r in &finding.remediation { - println!(" - {r}"); - } - println!(); - } - } - - if !accepted.is_empty() { - println!("{}", "--- Accepted Findings ---".dimmed()); - for finding in &accepted { - println!( - " {} {}", - category_label(&finding.query).dimmed(), - finding.title.dimmed() - ); - println!( - " {}", - format!("Reason: {}", finding.accepted_reason).dimmed() - ); - println!(); - } - } - - let path_count: usize = active.iter().map(|f| f.paths.len()).sum(); - let accepted_note = if accepted.is_empty() { - String::new() - } else { - format!(" ({} accepted)", accepted.len()) - }; - if path_count > 0 { - println!( - "{}{accepted_note}", - "REVIEW \u{2014} prover findings require human attention." - .bold() - .yellow() - ); - 1 - } else { - println!( - "{}{accepted_note}", - "PASS \u{2014} All findings accepted.".bold().green() - ); - 0 - } -} - -fn render_paths(paths: &[FindingPath]) { - if paths.is_empty() { - return; - } - // Group paths by binary for compact display. - let mut by_binary: BTreeMap<&str, Vec<&crate::finding::ExfilPath>> = BTreeMap::new(); - for path in paths { - let FindingPath::Exfil(p) = path; - by_binary.entry(&p.binary).or_default().push(p); - } - for (binary, ps) in &by_binary { - println!(" Binary: {}", binary.cyan()); - let mut endpoints: BTreeSet = BTreeSet::new(); - let mut methods: BTreeSet = BTreeSet::new(); - for p in ps { - endpoints.insert(format!("{}:{}", p.endpoint_host, p.endpoint_port)); - if !p.method.is_empty() { - methods.insert(p.method.clone()); - } - } - println!( - " Endpoints: {}", - endpoints.iter().cloned().collect::>().join(", ") - ); - if !methods.is_empty() { - println!( - " Methods: {}", - methods.iter().cloned().collect::>().join(", ") - ); - } - } - println!(); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::finding::ExfilPath; - - fn exfil_path(category_name: &str, method: &str, host: &str, port: u16) -> ExfilPath { - ExfilPath { - binary: "/usr/bin/curl".to_owned(), - endpoint_host: host.to_owned(), - endpoint_port: port, - mechanism: String::new(), - policy_name: "rule".to_owned(), - category: category_name.to_owned(), - method: method.to_owned(), - } - } - - fn finding_with(category_name: &str, paths: Vec) -> Finding { - Finding { - query: category_name.to_owned(), - title: "test".to_owned(), - description: String::new(), - paths: paths.into_iter().map(FindingPath::Exfil).collect(), - remediation: vec![], - accepted: false, - accepted_reason: String::new(), - } - } - - #[test] - fn shorthand_renders_capability_expansion_with_method() { - let f = finding_with( - category::CAPABILITY_EXPANSION, - vec![exfil_path( - category::CAPABILITY_EXPANSION, - "PUT", - "api.github.com", - 443, - )], - ); - assert_eq!( - finding_shorthand(&f), - "capability_expansion: PUT on api.github.com:443 via /usr/bin/curl" - ); - } - - #[test] - fn shorthand_renders_credential_reach_expansion() { - let f = finding_with( - category::CREDENTIAL_REACH_EXPANSION, - vec![exfil_path( - category::CREDENTIAL_REACH_EXPANSION, - "", - "uploads.github.com", - 443, - )], - ); - assert_eq!( - finding_shorthand(&f), - "credential_reach_expansion: uploads.github.com:443 via /usr/bin/curl" - ); - } - - #[test] - fn shorthand_renders_link_local() { - let f = finding_with( - category::LINK_LOCAL_REACH, - vec![exfil_path( - category::LINK_LOCAL_REACH, - "", - "169.254.169.254", - 80, - )], - ); - assert_eq!( - finding_shorthand(&f), - "link_local_reach: 169.254.169.254:80 via /usr/bin/curl" - ); - } - - #[test] - fn shorthand_renders_l7_bypass() { - let f = finding_with( - category::L7_BYPASS_CREDENTIALED, - vec![exfil_path( - category::L7_BYPASS_CREDENTIALED, - "", - "github.com", - 443, - )], - ); - assert_eq!( - finding_shorthand(&f), - "l7_bypass_credentialed: github.com:443 via /usr/bin/curl" - ); - } -} diff --git a/crates/openshell-prover/testdata/accepted-risks.yaml b/crates/openshell-prover/testdata/accepted-risks.yaml deleted file mode 100644 index 6af5c7146c..0000000000 --- a/crates/openshell-prover/testdata/accepted-risks.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -accepted_risks: - - query: inference_relay - reason: "Demo environment — inference.local not configured." - accepted_by: demo diff --git a/crates/openshell-prover/testdata/credentials.yaml b/crates/openshell-prover/testdata/credentials.yaml deleted file mode 100644 index b186ccf9ae..0000000000 --- a/crates/openshell-prover/testdata/credentials.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -credentials: - - name: github-pat - type: github-pat - scopes: - - repo - injected_via: GITHUB_TOKEN - target_hosts: - - api.github.com - - github.com diff --git a/crates/openshell-prover/testdata/empty-policy.yaml b/crates/openshell-prover/testdata/empty-policy.yaml deleted file mode 100644 index 458bab0e43..0000000000 --- a/crates/openshell-prover/testdata/empty-policy.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -version: 1 diff --git a/crates/openshell-prover/testdata/policy.yaml b/crates/openshell-prover/testdata/policy.yaml deleted file mode 100644 index e98d95319f..0000000000 --- a/crates/openshell-prover/testdata/policy.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -version: 1 - -filesystem_policy: - include_workdir: true - read_only: - - /usr - - /lib - - /proc - - /dev/urandom - - /app - - /etc - - /var/log - read_write: - - /sandbox - - /tmp - - /dev/null - -landlock: - compatibility: best_effort - -process: - run_as_user: sandbox - run_as_group: sandbox - -# Intent: read-only GitHub access for the agent. -# The REST API has L7 enforcement — only GET allowed. -# github.com is included for git clone — "it just needs connectivity." -network_policies: - github_api: - name: github-api - endpoints: - # REST API — L7 enforced, read-only - - host: api.github.com - port: 443 - protocol: rest - enforcement: enforce - access: read-only - # github.com — for git clone. - # No protocol field — L4 only. - - host: github.com - port: 443 - binaries: - - { path: /usr/local/bin/claude } - - { path: /usr/bin/git } - - { path: /usr/bin/curl } - - { path: /usr/bin/gh } - - { path: /sandbox/.uv/python/**/python3* } diff --git a/crates/openshell-server/src/grpc/managed_policy.rs b/crates/openshell-server/src/grpc/managed_policy.rs new file mode 100644 index 0000000000..c80498646e --- /dev/null +++ b/crates/openshell-server/src/grpc/managed_policy.rs @@ -0,0 +1,909 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; + +use openshell_core::VERSION; +use openshell_core::proto::{ + DeleteManagedMaximumPolicyRequest, DeleteManagedMaximumPolicyResponse, + GetManagedMaximumPolicyRequest, ManagedMaximumPolicyResponse, Sandbox, SandboxPolicy, + SetManagedMaximumPolicyRequest, +}; +use sha2::{Digest, Sha256}; +use tonic::{Request, Response, Status}; +use tracing::info; + +use openshell_ocsf::{ + ConfigStateChangeBuilder, OCSF_TARGET, OcsfEvent, SandboxContext, SeverityId, StateId, StatusId, +}; + +use super::policy::{POLICY_SETTING_KEY, load_global_settings, save_global_settings}; +use super::{MAX_POLICY_SIZE, StoredSettingValue}; +use crate::ServerState; +use crate::managed_policy::{ + AdmissionDecision, AdmissionSource, ManagedPolicyConfig, PermissionMode, admit, +}; + +pub(super) const MANAGED_MAXIMUM_SETTING_KEY: &str = "managed_maximum_policy"; +pub(super) const MANAGED_PERMISSION_MODE_LABEL: &str = + openshell_core::driver_utils::LABEL_PERMISSION_MODE; + +pub(super) async fn resolve_create_permission_mode( + state: &ServerState, + requested: &str, +) -> Result, Status> { + let Some(config) = load_managed_policy_config(state).await? else { + if requested.trim().is_empty() { + return Ok(None); + } + return Err(Status::failed_precondition( + "--permission-mode requires a managed maximum policy on the gateway", + )); + }; + + let mode = if requested.trim().is_empty() { + config.default_mode + } else { + PermissionMode::parse(requested).map_err(Status::invalid_argument)? + }; + if !config.allowed_modes.contains(&mode) { + return Err(Status::failed_precondition(format!( + "permission mode '{}' is not allowed by managed maximum {}@{}; allowed modes: {}", + mode.as_str(), + config.id, + config.version, + config + .allowed_modes + .iter() + .map(|mode| mode.as_str()) + .collect::>() + .join(", ") + ))); + } + Ok(Some(mode)) +} + +pub(super) fn permission_mode_for_sandbox( + sandbox: &Sandbox, +) -> Result, Status> { + let Some(value) = sandbox + .metadata + .as_ref() + .and_then(|metadata| metadata.labels.get(MANAGED_PERMISSION_MODE_LABEL)) + else { + return Ok(None); + }; + PermissionMode::parse(value).map(Some).map_err(|error| { + Status::internal(format!( + "stored managed permission mode is invalid: {error}" + )) + }) +} + +pub(super) async fn load_managed_policy_config( + state: &ServerState, +) -> Result, Status> { + let settings = load_global_settings(state.store.as_ref()).await?; + let stored = settings.settings.get(MANAGED_MAXIMUM_SETTING_KEY); + if stored.is_some() { + require_managed_storage(state.store.is_single_replica())?; + } + stored + .map(decode_stored_yaml) + .transpose()? + .map(|yaml| parse_stored_config(&yaml)) + .transpose() +} + +pub(super) struct ManagedAuthorityRequest<'a> { + pub mode: Option, + pub source: AdmissionSource, + pub sandbox_id: &'a str, + pub sandbox_name: &'a str, + pub current: Option<(&'a SandboxPolicy, &'a [String])>, + pub candidate: (&'a SandboxPolicy, &'a [String]), + pub requested_delta: (&'a SandboxPolicy, &'a [String]), +} + +pub(super) async fn decide_managed_authority( + state: &ServerState, + request: ManagedAuthorityRequest<'_>, +) -> Result { + let ManagedAuthorityRequest { + mode, + source, + sandbox_id, + sandbox_name, + current, + candidate, + requested_delta, + } = request; + let Some(config) = load_managed_policy_config(state).await? else { + return Ok(AdmissionDecision::Unmanaged); + }; + let mode = mode.ok_or_else(|| { + Status::failed_precondition( + "sandbox is missing its immutable managed permission mode; recreate the sandbox", + ) + })?; + + let current = match current { + Some((policy, providers)) => Some(parse_effective_policy(state, policy, providers).await?), + None => None, + }; + let candidate = parse_effective_policy(state, candidate.0, candidate.1).await?; + let requested_delta = + parse_effective_policy(state, requested_delta.0, requested_delta.1).await?; + let decision = add_decision_context( + admit( + Some(&config.boundary()), + mode, + source, + current.as_ref(), + &candidate, + &requested_delta, + ), + &config, + mode, + source, + ); + emit_managed_admission_audit(sandbox_id, sandbox_name, &config, mode, source, &decision); + Ok(decision) +} + +fn add_decision_context( + decision: AdmissionDecision, + config: &ManagedPolicyConfig, + mode: PermissionMode, + source: AdmissionSource, +) -> AdmissionDecision { + let context = format!( + "maximum={}@{} mode={} source={}", + config.id, + config.version, + mode.as_str(), + source.as_str() + ); + match decision { + AdmissionDecision::Ask { + reason, + counterexample, + } => AdmissionDecision::Ask { + reason: format!("{reason}; {context}"), + counterexample, + }, + AdmissionDecision::Reject { + reason, + counterexample, + } => AdmissionDecision::Reject { + reason: format!("{reason}; {context}"), + counterexample, + }, + other => other, + } +} + +async fn parse_effective_policy( + state: &ServerState, + base: &SandboxPolicy, + provider_names: &[String], +) -> Result { + let layers = + super::policy::managed_profile_provider_policy_layers(state.store.as_ref(), provider_names) + .await?; + let effective = openshell_policy::compose_effective_policy(base, &layers); + let yaml = openshell_policy::serialize_sandbox_policy(&effective) + .map_err(|error| Status::internal(format!("serialize candidate policy failed: {error}")))?; + openshell_prover::policy::parse_policy_str(&yaml) + .map_err(|error| Status::internal(format!("parse candidate policy failed: {error}"))) +} + +fn emit_managed_admission_audit( + sandbox_id: &str, + sandbox_name: &str, + config: &ManagedPolicyConfig, + mode: PermissionMode, + source: AdmissionSource, + decision: &AdmissionDecision, +) { + let (decision_name, reason, severity, status) = match decision { + AdmissionDecision::Unmanaged => return, + AdmissionDecision::Apply => ( + "apply", + "candidate is within the managed maximum".to_owned(), + SeverityId::Informational, + StatusId::Success, + ), + AdmissionDecision::Ask { + reason, + counterexample, + } => ( + "ask", + decision_message(reason, counterexample.as_ref()), + SeverityId::Informational, + StatusId::Success, + ), + AdmissionDecision::Reject { + reason, + counterexample, + } => ( + "reject", + decision_message(reason, counterexample.as_ref()), + SeverityId::Medium, + StatusId::Failure, + ), + }; + let ctx = SandboxContext { + sandbox_id: sandbox_id.to_owned(), + sandbox_name: sandbox_name.to_owned(), + container_image: "openshell/gateway".to_owned(), + hostname: "openshell-gateway".to_owned(), + product_version: VERSION.to_owned(), + proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + proxy_port: 0, + }; + let event: OcsfEvent = ConfigStateChangeBuilder::new(&ctx) + .state(StateId::Other, decision_name) + .severity(severity) + .status(status) + .message(format!("managed admission {decision_name}: {reason}")) + .unmapped("managed_policy_id", config.id.clone()) + .unmapped("managed_policy_version", config.version.to_string()) + .unmapped("managed_audit_label", config.audit_label.clone()) + .unmapped("permission_mode", mode.as_str()) + .unmapped("source", source.as_str()) + .unmapped("decision", decision_name) + .build(); + info!( + target: OCSF_TARGET, + sandbox_id, + message = %event.format_shorthand() + ); +} + +fn emit_managed_config_audit(action: &str, config: &ManagedPolicyConfig, policy_hash: &str) { + let ctx = SandboxContext { + sandbox_id: String::new(), + sandbox_name: String::new(), + container_image: "openshell/gateway".to_owned(), + hostname: "openshell-gateway".to_owned(), + product_version: VERSION.to_owned(), + proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + proxy_port: 0, + }; + let event: OcsfEvent = ConfigStateChangeBuilder::new(&ctx) + .state(StateId::Other, action) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(format!( + "managed maximum {action}: {}@{}", + config.id, config.version + )) + .unmapped("managed_policy_id", config.id.clone()) + .unmapped("managed_policy_version", config.version.to_string()) + .unmapped("managed_audit_label", config.audit_label.clone()) + .unmapped("policy_hash", policy_hash) + .build(); + info!(target: OCSF_TARGET, message = %event.format_shorthand()); +} + +pub(super) fn require_applied(decision: AdmissionDecision) -> Result<(), Status> { + match decision { + AdmissionDecision::Unmanaged | AdmissionDecision::Apply => Ok(()), + AdmissionDecision::Ask { + reason, + counterexample, + } => Err(Status::failed_precondition(decision_message( + &reason, + counterexample.as_ref(), + ))), + AdmissionDecision::Reject { + reason, + counterexample, + } => Err(Status::permission_denied(decision_message( + &reason, + counterexample.as_ref(), + ))), + } +} + +fn decision_message( + reason: &str, + counterexample: Option<&openshell_prover::envelope::PolicyCounterexample>, +) -> String { + counterexample.map_or_else( + || reason.to_owned(), + |counterexample| { + format!( + "{reason}: protocol={} host={} port={} binary={} method={} path={}", + counterexample.protocol, + counterexample.host, + counterexample.port, + counterexample.binary, + counterexample.method, + counterexample.path + ) + }, + ) +} + +pub(super) async fn handle_set_managed_maximum_policy( + state: &Arc, + request: Request, +) -> Result, Status> { + require_managed_storage(state.store.is_single_replica())?; + let policy_yaml = request.into_inner().policy_yaml; + if policy_yaml.is_empty() { + return Err(Status::invalid_argument("policy_yaml is required")); + } + if policy_yaml.len() > MAX_POLICY_SIZE { + return Err(Status::invalid_argument(format!( + "managed maximum policy exceeds maximum size ({MAX_POLICY_SIZE} bytes)" + ))); + } + let yaml = std::str::from_utf8(&policy_yaml) + .map_err(|_| Status::invalid_argument("managed maximum policy must be UTF-8 YAML"))?; + let config = ManagedPolicyConfig::parse(yaml).map_err(Status::invalid_argument)?; + + let _settings_guard = state.settings_mutex.lock().await; + require_no_sandboxes(state).await?; + let mut settings = load_global_settings(state.store.as_ref()).await?; + if settings.settings.contains_key(POLICY_SETTING_KEY) { + return Err(Status::failed_precondition( + "managed maximum policy cannot coexist with the global policy override", + )); + } + + let stored = StoredSettingValue::Bytes(hex::encode(&policy_yaml)); + if let Some(existing) = settings.settings.get(MANAGED_MAXIMUM_SETTING_KEY) { + if existing == &stored { + return Ok(Response::new(config_response( + &config, + policy_yaml, + settings.revision, + ))); + } + let existing_yaml = decode_stored_yaml(existing)?; + let existing = parse_stored_config(&existing_yaml)?; + if existing.id == config.id && config.version <= existing.version { + return Err(Status::failed_precondition(format!( + "replacement for managed maximum '{}' must increase version above {}", + config.id, existing.version + ))); + } + } + + settings + .settings + .insert(MANAGED_MAXIMUM_SETTING_KEY.to_owned(), stored); + settings.revision = settings.revision.wrapping_add(1); + save_global_settings(state.store.as_ref(), &settings).await?; + + emit_managed_config_audit("set", &config, &hex::encode(Sha256::digest(&policy_yaml))); + + Ok(Response::new(config_response( + &config, + policy_yaml, + settings.revision, + ))) +} + +fn require_managed_storage(is_single_replica: bool) -> Result<(), Status> { + if is_single_replica { + Ok(()) + } else { + Err(Status::failed_precondition( + "managed maximum policies currently require single-replica SQLite storage; PostgreSQL/HA support requires database-backed admission coordination", + )) + } +} + +pub(super) async fn handle_get_managed_maximum_policy( + state: &Arc, + _request: Request, +) -> Result, Status> { + let settings = load_global_settings(state.store.as_ref()).await?; + let Some(stored) = settings.settings.get(MANAGED_MAXIMUM_SETTING_KEY) else { + return Ok(Response::new(ManagedMaximumPolicyResponse { + configured: false, + settings_revision: settings.revision, + ..Default::default() + })); + }; + let policy_yaml = decode_stored_yaml(stored)?; + let config = parse_stored_config(&policy_yaml)?; + Ok(Response::new(config_response( + &config, + policy_yaml, + settings.revision, + ))) +} + +pub(super) async fn handle_delete_managed_maximum_policy( + state: &Arc, + _request: Request, +) -> Result, Status> { + let _settings_guard = state.settings_mutex.lock().await; + require_no_sandboxes(state).await?; + let mut settings = load_global_settings(state.store.as_ref()).await?; + let removed = settings.settings.remove(MANAGED_MAXIMUM_SETTING_KEY); + let deleted = removed.is_some(); + if deleted { + settings.revision = settings.revision.wrapping_add(1); + save_global_settings(state.store.as_ref(), &settings).await?; + } + if let Some(stored) = removed { + let yaml = decode_stored_yaml(&stored)?; + let config = parse_stored_config(&yaml)?; + emit_managed_config_audit("delete", &config, &hex::encode(Sha256::digest(&yaml))); + } + Ok(Response::new(DeleteManagedMaximumPolicyResponse { + deleted, + settings_revision: settings.revision, + })) +} + +async fn require_no_sandboxes(state: &ServerState) -> Result<(), Status> { + let sandboxes: Vec = state + .store + .list_messages(1, 0) + .await + .map_err(|error| Status::internal(format!("list sandboxes failed: {error}")))?; + if sandboxes.is_empty() { + Ok(()) + } else { + Err(Status::failed_precondition( + "managed maximum policy can change only when no sandboxes exist", + )) + } +} + +fn decode_stored_yaml(value: &StoredSettingValue) -> Result, Status> { + let StoredSettingValue::Bytes(value) = value else { + return Err(Status::internal( + "stored managed maximum has invalid value type", + )); + }; + hex::decode(value) + .map_err(|error| Status::internal(format!("decode managed maximum failed: {error}"))) +} + +fn parse_stored_config(policy_yaml: &[u8]) -> Result { + let yaml = std::str::from_utf8(policy_yaml) + .map_err(|_| Status::internal("stored managed maximum is not UTF-8"))?; + ManagedPolicyConfig::parse(yaml) + .map_err(|error| Status::internal(format!("stored managed maximum is invalid: {error}"))) +} + +fn config_response( + config: &ManagedPolicyConfig, + policy_yaml: Vec, + settings_revision: u64, +) -> ManagedMaximumPolicyResponse { + ManagedMaximumPolicyResponse { + configured: true, + policy_hash: hex::encode(Sha256::digest(&policy_yaml)), + policy_yaml, + policy_id: config.id.clone(), + version: config.version, + allowed_modes: config + .allowed_modes + .iter() + .map(|mode| mode.as_str().to_owned()) + .collect(), + default_mode: config.default_mode.as_str().to_owned(), + audit_label: config.audit_label.clone(), + settings_revision, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::grpc::test_support::test_server_state; + use openshell_core::proto::datamodel::v1::ObjectMeta; + use openshell_core::proto::{ + AttachSandboxProviderRequest, CreateSandboxRequest, Provider, SandboxPolicy, SandboxSpec, + UpdateConfigRequest, + }; + use tonic::Code; + + fn maximum(version: u64) -> Vec { + format!( + r"version: 1 +metadata: + policy_id: engineering + version: {version} + allowed_modes: [ask, auto] + default_mode: auto +network_policies: + github: + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - path: /usr/bin/gh +" + ) + .into_bytes() + } + + async fn configure_maximum(state: &Arc) { + handle_set_managed_maximum_policy( + state, + Request::new(SetManagedMaximumPolicyRequest { + policy_yaml: maximum(1), + }), + ) + .await + .expect("maximum should configure"); + } + + #[test] + fn managed_maximum_requires_single_replica_storage() { + require_managed_storage(true).expect("SQLite should support managed maximums"); + let error = require_managed_storage(false) + .expect_err("multi-replica storage must fail closed until admission is coordinated"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("single-replica SQLite")); + } + + fn outside_policy() -> SandboxPolicy { + openshell_policy::parse_sandbox_policy( + r"version: 1 +network_policies: + github: + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: DELETE, path: /repos/acme/project } + binaries: + - path: /usr/bin/gh +", + ) + .expect("candidate should parse") + } + + async fn put_managed_sandbox( + state: &ServerState, + policy: Option, + providers: Vec, + ) { + state + .store + .put_message(&Sandbox { + metadata: Some(ObjectMeta { + id: "sandbox-id".to_owned(), + name: "sandbox".to_owned(), + labels: std::collections::HashMap::from([( + MANAGED_PERMISSION_MODE_LABEL.to_owned(), + "auto".to_owned(), + )]), + ..Default::default() + }), + spec: Some(SandboxSpec { + policy, + providers, + ..Default::default() + }), + ..Default::default() + }) + .await + .expect("sandbox should persist"); + } + + async fn put_unmodeled_provider(state: &ServerState) { + state + .store + .put_message(&Provider { + metadata: Some(ObjectMeta { + id: "provider-id".to_owned(), + name: "unmodeled".to_owned(), + ..Default::default() + }), + r#type: "unknown-provider-type".to_owned(), + credentials: std::collections::HashMap::from([( + "TOKEN".to_owned(), + "never-log-this-secret".to_owned(), + )]), + ..Default::default() + }) + .await + .expect("provider should persist"); + } + + #[tokio::test] + async fn managed_maximum_round_trips_and_deletes() { + let state = test_server_state().await; + let set = handle_set_managed_maximum_policy( + &state, + Request::new(SetManagedMaximumPolicyRequest { + policy_yaml: maximum(1), + }), + ) + .await + .expect("set should succeed") + .into_inner(); + assert!(set.configured); + assert_eq!(set.policy_id, "engineering"); + assert_eq!(set.version, 1); + assert_eq!(set.settings_revision, 1); + + let get = handle_get_managed_maximum_policy( + &state, + Request::new(GetManagedMaximumPolicyRequest {}), + ) + .await + .expect("get should succeed") + .into_inner(); + assert_eq!(get.policy_yaml, maximum(1)); + assert_eq!(get.policy_hash, set.policy_hash); + + let deleted = handle_delete_managed_maximum_policy( + &state, + Request::new(DeleteManagedMaximumPolicyRequest {}), + ) + .await + .expect("delete should succeed") + .into_inner(); + assert!(deleted.deleted); + assert_eq!(deleted.settings_revision, 2); + } + + #[tokio::test] + async fn managed_maximum_replacement_requires_increasing_version() { + let state = test_server_state().await; + handle_set_managed_maximum_policy( + &state, + Request::new(SetManagedMaximumPolicyRequest { + policy_yaml: maximum(2), + }), + ) + .await + .expect("initial set should succeed"); + + let error = handle_set_managed_maximum_policy( + &state, + Request::new(SetManagedMaximumPolicyRequest { + policy_yaml: maximum(1), + }), + ) + .await + .expect_err("version rollback should fail"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("increase version")); + } + + #[tokio::test] + async fn create_permission_mode_uses_managed_default_and_validates_override() { + let state = test_server_state().await; + configure_maximum(&state).await; + + assert_eq!( + resolve_create_permission_mode(&state, "").await.unwrap(), + Some(PermissionMode::Auto) + ); + assert_eq!( + resolve_create_permission_mode(&state, "ask").await.unwrap(), + Some(PermissionMode::Ask) + ); + } + + #[tokio::test] + async fn explicit_permission_mode_requires_managed_maximum() { + let state = test_server_state().await; + let error = resolve_create_permission_mode(&state, "auto") + .await + .expect_err("mode without maximum should fail"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("requires a managed maximum")); + } + + #[tokio::test] + async fn create_permission_mode_rejects_mode_outside_allowed_set() { + let state = test_server_state().await; + let auto_only = String::from_utf8(maximum(1)) + .unwrap() + .replace("allowed_modes: [ask, auto]", "allowed_modes: [auto]"); + handle_set_managed_maximum_policy( + &state, + Request::new(SetManagedMaximumPolicyRequest { + policy_yaml: auto_only.into_bytes(), + }), + ) + .await + .expect("maximum should be configured"); + + let error = resolve_create_permission_mode(&state, "ask") + .await + .expect_err("disallowed mode should fail"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("allowed modes: auto")); + } + + #[tokio::test] + async fn managed_maximum_mutation_rejects_when_sandbox_exists() { + let state = test_server_state().await; + put_managed_sandbox(&state, None, Vec::new()).await; + + let error = handle_set_managed_maximum_policy( + &state, + Request::new(SetManagedMaximumPolicyRequest { + policy_yaml: maximum(1), + }), + ) + .await + .expect_err("set with sandbox should fail"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("no sandboxes")); + } + + #[tokio::test] + async fn sandbox_create_rejects_authority_outside_managed_maximum() { + let state = test_server_state().await; + configure_maximum(&state).await; + let error = super::super::sandbox::handle_create_sandbox( + &state, + Request::new(CreateSandboxRequest { + name: "outside-maximum".to_owned(), + spec: Some(SandboxSpec { + policy: Some(outside_policy()), + ..Default::default() + }), + ..Default::default() + }), + ) + .await + .expect_err("outside authority should reject create"); + assert_eq!(error.code(), Code::PermissionDenied); + assert!(error.message().contains("exceeds managed maximum")); + assert!(error.message().contains("DELETE")); + } + + #[tokio::test] + async fn sandbox_create_rejects_provider_without_resolvable_profile() { + let state = test_server_state().await; + configure_maximum(&state).await; + put_unmodeled_provider(&state).await; + + let error = super::super::sandbox::handle_create_sandbox( + &state, + Request::new(CreateSandboxRequest { + name: "unmodeled-provider".to_owned(), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy::default()), + providers: vec!["unmodeled".to_owned()], + ..Default::default() + }), + ..Default::default() + }), + ) + .await + .expect_err("unmodeled provider authority must reject create"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("no resolvable policy profile")); + assert!(!error.message().contains("never-log-this-secret")); + } + + #[tokio::test] + async fn sandbox_attach_rejects_provider_without_resolvable_profile() { + let state = test_server_state().await; + configure_maximum(&state).await; + put_unmodeled_provider(&state).await; + put_managed_sandbox(&state, Some(SandboxPolicy::default()), Vec::new()).await; + + let error = super::super::sandbox::handle_attach_sandbox_provider( + &state, + Request::new(AttachSandboxProviderRequest { + sandbox_name: "sandbox".to_owned(), + provider_name: "unmodeled".to_owned(), + expected_resource_version: 0, + }), + ) + .await + .expect_err("unmodeled provider authority must reject attach"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("no resolvable policy profile")); + assert!(!error.message().contains("never-log-this-secret")); + } + + #[tokio::test] + async fn rejected_first_policy_backfill_does_not_mutate_sandbox_spec() { + let state = test_server_state().await; + configure_maximum(&state).await; + put_managed_sandbox(&state, None, Vec::new()).await; + let error = super::super::policy::handle_update_config( + &state, + Request::new(UpdateConfigRequest { + name: "sandbox".to_owned(), + policy: Some(outside_policy()), + ..Default::default() + }), + ) + .await + .expect_err("outside backfill should fail"); + assert_eq!(error.code(), Code::PermissionDenied); + + let stored = state + .store + .get_message::("sandbox-id") + .await + .expect("sandbox lookup should succeed") + .expect("sandbox should still exist"); + assert!( + stored.spec.and_then(|spec| spec.policy).is_none(), + "rejected admission must not persist the discovered policy" + ); + } + + #[tokio::test] + async fn attached_managed_provider_allows_value_rotation_but_rejects_new_credential_reach() { + let state = test_server_state().await; + configure_maximum(&state).await; + + state + .store + .put_message(&Provider { + metadata: Some(ObjectMeta { + id: "provider-id".to_owned(), + name: "work-github".to_owned(), + ..Default::default() + }), + r#type: "github".to_owned(), + credentials: std::collections::HashMap::from([( + "GH_TOKEN".to_owned(), + "old-secret".to_owned(), + )]), + ..Default::default() + }) + .await + .unwrap(); + put_managed_sandbox(&state, None, vec!["work-github".to_owned()]).await; + + super::super::provider::update_provider_record( + state.store.as_ref(), + Provider { + metadata: Some(ObjectMeta { + name: "work-github".to_owned(), + ..Default::default() + }), + credentials: std::collections::HashMap::from([( + "GH_TOKEN".to_owned(), + "rotated-secret".to_owned(), + )]), + ..Default::default() + }, + ) + .await + .expect("same-key credential rotation should succeed"); + + let error = super::super::provider::update_provider_record( + state.store.as_ref(), + Provider { + metadata: Some(ObjectMeta { + name: "work-github".to_owned(), + ..Default::default() + }), + credentials: std::collections::HashMap::from([( + "NEW_TOKEN".to_owned(), + "new-secret".to_owned(), + )]), + ..Default::default() + }, + ) + .await + .expect_err("new credential key should be rejected while attached"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("only credential-value rotation")); + assert!(!error.message().contains("new-secret")); + } +} diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index fe2eb331c9..5f3001b95e 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -4,6 +4,7 @@ //! gRPC service implementation. mod auth_rpc; +mod managed_policy; pub mod policy; pub mod provider; mod sandbox; @@ -15,16 +16,17 @@ use openshell_core::proto::{ ApproveDraftChunkResponse, AttachSandboxProviderRequest, AttachSandboxProviderResponse, ClearDraftChunksRequest, ClearDraftChunksResponse, ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, CreateProviderRequest, CreateSandboxRequest, - CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderProfileRequest, + CreateSshSessionRequest, CreateSshSessionResponse, DeleteManagedMaximumPolicyRequest, + DeleteManagedMaximumPolicyResponse, DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteServiceRequest, DeleteServiceResponse, DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, GatewayMessage, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, - GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderProfileRequest, - GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, - GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, + GetGatewayConfigRequest, GetGatewayConfigResponse, GetManagedMaximumPolicyRequest, + GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetServiceRequest, HealthRequest, HealthResponse, ImportProviderProfilesRequest, @@ -33,16 +35,17 @@ use openshell_core::proto::{ ListProviderProfilesResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ListServicesRequest, - ListServicesResponse, ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, - PushSandboxLogsResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, - RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, ReportPolicyStatusRequest, - ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, - RotateProviderCredentialRequest, RotateProviderCredentialResponse, SandboxResponse, - SandboxStreamEvent, ServiceEndpointResponse, ServiceStatus, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, - UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, - UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, - WatchSandboxRequest, open_shell_server::OpenShell, + ListServicesResponse, ManagedMaximumPolicyResponse, ProviderProfileResponse, ProviderResponse, + PushSandboxLogsRequest, PushSandboxLogsResponse, RefreshSandboxTokenRequest, + RefreshSandboxTokenResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, + ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, + SandboxResponse, SandboxStreamEvent, ServiceEndpointResponse, ServiceStatus, + SetManagedMaximumPolicyRequest, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, + SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, + UpdateConfigRequest, UpdateConfigResponse, UpdateProviderProfilesRequest, + UpdateProviderProfilesResponse, UpdateProviderRequest, WatchSandboxRequest, + open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -495,6 +498,30 @@ impl OpenShell for OpenShellService { policy::handle_get_gateway_config(&self.state, request).await } + #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] + async fn set_managed_maximum_policy( + &self, + request: Request, + ) -> Result, Status> { + managed_policy::handle_set_managed_maximum_policy(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "config:read", role = "admin")] + async fn get_managed_maximum_policy( + &self, + request: Request, + ) -> Result, Status> { + managed_policy::handle_get_managed_maximum_policy(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] + async fn delete_managed_maximum_policy( + &self, + request: Request, + ) -> Result, Status> { + managed_policy::handle_delete_managed_maximum_policy(&self.state, request).await + } + #[rpc_auth(auth = "sandbox")] async fn get_sandbox_provider_environment( &self, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cc8ff0d2e2..736961e666 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -50,21 +50,11 @@ use openshell_ocsf::{ }; use openshell_policy::{ PolicyMergeOp, ProviderPolicyLayer, compose_effective_policy, merge_policy, - serialize_sandbox_policy, -}; -use openshell_prover::{ - credentials::{Credential, CredentialSet}, - finding::{Finding, FindingPath}, - model::build_model, - policy::parse_policy_str, - queries::run_all_queries, - registry::load_embedded_binary_registry, - report::finding_shorthand, }; use openshell_providers::{get_default_profile, normalize_provider_type}; use prost::Message; use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use tonic::{Request, Response, Status}; @@ -87,12 +77,18 @@ const GLOBAL_SETTINGS_NAME: &str = "global"; /// Internal object type for durable sandbox-scoped settings. pub const SANDBOX_SETTINGS_OBJECT_TYPE: &str = "sandbox_settings"; /// Reserved settings key used to store global policy payload. -const POLICY_SETTING_KEY: &str = "policy"; +pub(super) const POLICY_SETTING_KEY: &str = "policy"; /// Sentinel `sandbox_id` used to store global policy revisions. const GLOBAL_POLICY_SANDBOX_ID: &str = "__global__"; /// Maximum number of optimistic retry attempts for policy version conflicts. const MERGE_RETRY_LIMIT: usize = 5; +#[derive(Clone, Copy)] +struct ManagedMergeAdmission<'a> { + state: &'a ServerState, + source: crate::managed_policy::AdmissionSource, +} + fn emit_sandbox_policy_update_success() { openshell_core::telemetry::emit_lifecycle( LifecycleResource::SandboxPolicy, @@ -169,27 +165,21 @@ fn emit_gateway_policy_audit_log( ); } -/// Emit a `CONFIG:APPROVED` audit event for an auto-approval — same event -/// class as a human approval, with extra unmapped fields carrying the -/// safety reasoning so the audit is reconstructable. `source` records the -/// proposer (`mechanistic` or `agent_authored`) for provenance. -/// `resolved_from` records the scope that supplied the `auto` mode setting -/// (`gateway`, `sandbox`, or `default`) so operators can see why a given -/// approval was auto vs manual. -fn emit_gateway_policy_auto_approve_audit_log( +/// Emit the chunk-level approval event after managed admission authorizes an +/// automatic apply. The separate managed-admission event carries maximum +/// identity and proof evidence; this event records proposal provenance. +fn emit_gateway_policy_managed_apply_audit_log( sandbox_id: &str, sandbox_name: &str, detail: impl Into, version: i64, policy_hash: &str, source: &str, - resolved_from: &str, ) { let extra = [ ("auto", "true".to_string()), ("source", source.to_string()), - ("prover_delta", "empty".to_string()), - ("resolved_from", resolved_from.to_string()), + ("approval_basis", "managed_maximum".to_string()), ]; let message = build_gateway_policy_audit_message( sandbox_id, @@ -416,267 +406,70 @@ fn summarize_draft_chunk_rule(chunk: &DraftChunkRecord) -> Result: ` -/// line per finding path (categorical shorthand from `openshell-prover`) -/// - `merge failed: ` — proposal won't merge into the current -/// policy -/// - `policy invalid: ` — merged policy fails the cheap -/// structural safety check -/// - `validation unavailable` — gateway-side infrastructure failure (registry -/// load, YAML serialize/parse). Internal error detail is logged via -/// `warn!`, never exposed to the reviewer. -fn validation_result_for_agent_proposal( - current_policy: ProtoSandboxPolicy, - rule_name: &str, - proposed_rule: &NetworkPolicyRule, - credentials: &CredentialSet, -) -> String { - let merge_op = PolicyMergeOp::AddRule { - rule_name: rule_name.to_string(), - rule: proposed_rule.clone(), - }; - let merged = match merge_policy(current_policy.clone(), &[merge_op]) { - Ok(result) => result.policy, - Err(error) => return format!("merge failed: {}", one_line(&error.to_string())), - }; - if let Err(error) = validate_policy_safety(&merged) { - return format!("policy invalid: {}", one_line(&error.to_string())); - } - - let merged_findings = match run_prover_findings(&merged, credentials) { - Ok(findings) => findings, - Err(error) => { - warn!(error = %error, "prover validation unavailable for merged policy"); - return "validation unavailable".to_string(); - } - }; - // If the baseline prover run fails (e.g. the current policy uses a shape - // the prover hasn't caught up to yet), fall back to an empty baseline so - // every merged finding surfaces as new. Safer to over-warn than miss a - // real regression introduced by the proposal. - let base_findings = match run_prover_findings(¤t_policy, credentials) { - Ok(findings) => findings, - Err(error) => { - warn!(error = %error, "prover baseline run failed; treating baseline as empty"); - Vec::new() - } - }; - - let new_findings = finding_delta(&base_findings, &merged_findings); - if new_findings.is_empty() { - return "prover: no new findings".to_string(); - } - let count = new_findings.len(); - let mut out = format!( - "prover: {} new finding{}", - count, - if count == 1 { "" } else { "s" } - ); - for finding in &new_findings { - out.push_str("\n "); - out.push_str(&finding_shorthand(finding)); - } - out -} - -/// Run the prover end-to-end against a single policy with the given -/// credential set. Returns the raw finding list, or a short error string -/// identifying which infrastructure step failed. -/// -/// The credential set is passed in because it's stable across all chunks in -/// one `SubmitPolicyAnalysis` batch — the caller builds it once and shares. -fn run_prover_findings( - policy: &ProtoSandboxPolicy, - credentials: &CredentialSet, -) -> Result, String> { - let yaml = - serialize_sandbox_policy(policy).map_err(|e| format!("serialize policy failed: {e}"))?; - let prover_policy = parse_policy_str(&yaml).map_err(|e| format!("parse policy failed: {e}"))?; - let registry = - load_embedded_binary_registry().map_err(|e| format!("load registry failed: {e}"))?; - let model = build_model(prover_policy, credentials.clone(), registry); - Ok(run_all_queries(&model)) -} - -/// Build a `CredentialSet` for the sandbox by walking its attached providers. -/// -/// v1 models "credential is present in scope for these hosts" — no scope -/// modeling. Each attached provider produces one [`Credential`] entry whose -/// `target_hosts` lists the hosts from the provider's profile endpoints. -/// Missing providers or providers whose type has no profile are skipped with -/// a `warn!` — the merged policy already excludes them at compose time, so -/// silently treating them as absent here keeps the credential set consistent -/// with the merged policy the prover validates against. -async fn build_credential_set_for_sandbox( +async fn provider_credential_target_hosts( store: &Store, provider_names: &[String], -) -> Result { - let mut credentials = Vec::new(); +) -> Result, Status> { + let layers = profile_provider_policy_layers(store, provider_names).await?; + let mut hosts = layers + .iter() + .flat_map(|layer| layer.rule.endpoints.iter()) + .map(|endpoint| { + endpoint + .host + .trim() + .trim_end_matches('.') + .to_ascii_lowercase() + }) + .filter(|host| !host.is_empty()) + .collect::>(); + hosts.sort(); + hosts.dedup(); + Ok(hosts) +} - for name in provider_names { - let Some(provider) = store - .get_message_by_name::(name) - .await - .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? - else { - warn!(provider_name = %name, "provider not found while building credential set; skipping"); +/// Describe credential-bearing raw L4 authority without participating in +/// admission. The managed maximum remains the sole source of apply/ask/reject +/// decisions; operators who want this authority reviewed mark the matching +/// maximum region `review.required`. +fn credentialed_l4_advisory( + proposed_rule: &NetworkPolicyRule, + credential_target_hosts: &[String], +) -> Option { + let mut evidence = BTreeSet::new(); + for endpoint in &proposed_rule.endpoints { + if !endpoint.protocol.trim().is_empty() + || endpoint.host.is_empty() + || !credential_target_hosts + .iter() + .any(|target| super::provider::host_patterns_can_overlap(&endpoint.host, target)) + { continue; - }; + } - let provider_type = provider.r#type.trim(); - let profile = if let Some(canonical_type) = normalize_provider_type(provider_type) { - let Some(profile) = get_default_profile(canonical_type) else { - warn!( - provider_name = %name, - provider_type, - "legacy provider type has no profile; skipping credential entry" - ); - continue; - }; - profile.clone() + let ports = if endpoint.ports.is_empty() { + vec![endpoint.port] } else { - let Some(profile) = - super::provider::get_provider_type_profile(store, provider_type).await? - else { - warn!( - provider_name = %name, - provider_type, - "provider type has no profile; skipping credential entry" - ); - continue; - }; - profile + endpoint.ports.clone() }; - - let target_hosts: Vec = profile - .endpoints - .iter() - .map(|ep| ep.host.to_lowercase()) - .filter(|h| !h.is_empty()) - .collect(); - - if target_hosts.is_empty() { - continue; + for binary in &proposed_rule.binaries { + for port in &ports { + evidence.insert(format!( + "binary={} host={} port={port}", + binary.path, endpoint.host + )); + } } - - credentials.push(Credential { - name: name.clone(), - cred_type: provider_type.to_string(), - scopes: Vec::new(), - injected_via: String::new(), - target_hosts, - }); } - Ok(CredentialSet { - credentials, - api_registries: HashMap::new(), + (!evidence.is_empty()).then(|| { + format!( + "advisory: credentialed raw L4 authority is not inspectable at L7: {}", + evidence.into_iter().collect::>().join(", ") + ) }) } -/// Stable identity key for a finding path. Deliberately excludes -/// `policy_name`: two paths with identical (binary, endpoint, mechanism) are -/// the same security gap whether they live in rule `foo` or rule `bar`. This -/// keeps the delta from spuriously surfacing baseline gaps just because the -/// proposal added a new rule name that produces the same gap shape. -fn finding_path_key(path: &FindingPath) -> String { - let FindingPath::Exfil(p) = path; - // Include the category and (for capability_expansion) the method so - // adding a new method on an already-reached host surfaces as a new - // path; reuse of an existing method does not. - format!( - "exfil|{}|{}:{}|{}|{}", - p.binary, p.endpoint_host, p.endpoint_port, p.category, p.method - ) -} - -/// Return the merged-policy findings that aren't already present in the -/// baseline. Comparison is per-(query, path) so that a single finding whose -/// evidence grew (e.g. a new method allowed on an already-reached host) -/// surfaces only the new evidence paths. -/// -/// **Category suppression:** `capability_expansion` paths whose (binary, -/// host, port) tuple appears in the `credential_reach_expansion` delta -/// are suppressed. A brand-new credentialed reach is described by the -/// reach-expansion finding alone; we don't double-report by also -/// flagging every method as a separate `capability_expansion`. -fn finding_delta(base: &[Finding], merged: &[Finding]) -> Vec { - use openshell_prover::finding::category; - - let base_keys: HashSet<(String, String)> = base - .iter() - .flat_map(|f| { - let query = f.query.clone(); - f.paths - .iter() - .map(move |p| (query.clone(), finding_path_key(p))) - }) - .collect(); - let mut delta: Vec = Vec::new(); - for finding in merged { - let new_paths: Vec = finding - .paths - .iter() - .filter(|p| !base_keys.contains(&(finding.query.clone(), finding_path_key(p)))) - .cloned() - .collect(); - if new_paths.is_empty() { - continue; - } - delta.push(Finding { - paths: new_paths, - ..finding.clone() - }); - } - - // Suppress capability_expansion paths whose (binary, host, port) - // appears in the credential_reach_expansion delta — a new reach is - // described once, by the reach-expansion category, not also by per- - // method capability findings. - let reach_tuples: HashSet<(String, String, u16)> = delta - .iter() - .filter(|f| f.query == category::CREDENTIAL_REACH_EXPANSION) - .flat_map(|f| { - f.paths.iter().map(|p| { - let FindingPath::Exfil(e) = p; - (e.binary.clone(), e.endpoint_host.clone(), e.endpoint_port) - }) - }) - .collect(); - delta.retain_mut(|f| { - if f.query != category::CAPABILITY_EXPANSION { - return true; - } - f.paths.retain(|p| { - let FindingPath::Exfil(e) = p; - !reach_tuples.contains(&(e.binary.clone(), e.endpoint_host.clone(), e.endpoint_port)) - }); - !f.paths.is_empty() - }); - - delta -} - -/// Collapse multi-line / multi-message error text to a single line so the -/// `validation_result` stays a clean, scannable string. -fn one_line(s: &str) -> String { - s.split('\n') - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>() - .join("; ") -} - /// Auto-reject any pending chunks for the same sandbox that share the /// `(host, port, binary)` of the newly-submitted chunk. Mode-agnostic: the /// rule is "the latest submission for this endpoint wins; older pending @@ -804,7 +597,7 @@ async fn self_reject_mechanistic_if_already_covered( }; let reason = format!( - "already covered by approved chunk {} (agent_authored or prior auto-approval)", + "already covered by approved chunk {} (agent_authored or prior automatic application)", covering.id ); match state @@ -838,55 +631,14 @@ async fn self_reject_mechanistic_if_already_covered( } } -/// Internally approve a chunk on the auto-approval path: merge into the -/// active policy, flip status to "approved", notify watchers, and emit a -/// `CONFIG:APPROVED` audit event carrying `auto=true`, `source=`, -/// `prover_delta=empty` so the audit trail records why no human approved -/// this chunk. -/// -/// `source` is the `analysis_mode` of the originating submission -/// (`mechanistic` or `agent_authored`). The audit copy says "auto-approved: -/// no new prover findings" — never "safe" — because the claim is about the -/// prover's reasoning, not the world. -/// Resolve the effective proposal-approval mode for a sandbox. -/// -/// Precedence (matches the rest of the settings model): gateway scope wins -/// over sandbox scope. A reviewer can pin manual mode fleet-wide by setting -/// it globally; per-sandbox overrides only apply when no global is set. -/// -/// Returns `(auto_approve_enabled, resolved_from)` where `resolved_from` -/// is `"gateway"`, `"sandbox"`, or `"default"`. Only an exact `"auto"` -/// value enables auto-approval; any other string (including future- -/// reserved modes like `"auto_on_low_risk"`) is conservatively treated as -/// manual. -async fn resolve_proposal_approval_mode( - store: &Store, - sandbox_name: &str, -) -> Result<(bool, &'static str), Status> { - let global = load_global_settings(store).await?; - if let Some(StoredSettingValue::String(value)) = - global.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY) - { - return Ok((value == "auto", "gateway")); - } - - let sandbox = load_sandbox_settings(store, sandbox_name).await?; - if let Some(StoredSettingValue::String(value)) = - sandbox.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY) - { - return Ok((value == "auto", "sandbox")); - } - - Ok((false, "default")) -} - -async fn auto_approve_chunk( +/// Apply a proposal after managed admission returns `Apply`, then mark the +/// draft approved and emit proposal-level audit evidence. +async fn apply_managed_proposal_chunk( state: &Arc, sandbox_id: &str, sandbox_name: &str, chunk_id: &str, source: &str, - resolved_from: &str, ) -> Result<(), Status> { // Same gate the human-driven approve paths apply: if a global policy is // active, sandbox-scoped chunk approvals are meaningless because @@ -910,7 +662,13 @@ async fn auto_approve_chunk( return Ok(()); } - let (version, hash) = merge_chunk_into_policy(state.store.as_ref(), sandbox_id, &chunk).await?; + let (version, hash) = merge_chunk_into_policy_managed( + state, + sandbox_id, + &chunk, + crate::managed_policy::AdmissionSource::AgentProposal, + ) + .await?; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -927,16 +685,15 @@ async fn auto_approve_chunk( } else { source }; - emit_gateway_policy_auto_approve_audit_log( + emit_gateway_policy_managed_apply_audit_log( sandbox_id, sandbox_name, format!( - "auto-approved: no new prover findings (source={source_label}) — chunk {chunk_id}: {chunk_summary}" + "managed maximum auto-applied proposal (source={source_label}) — chunk {chunk_id}: {chunk_summary}" ), version, &hash, source_label, - resolved_from, ); info!( @@ -946,63 +703,33 @@ async fn auto_approve_chunk( version = version, policy_hash = %hash, source = %source_label, - resolved_from = %resolved_from, - "Auto-approved chunk: no new prover findings" + approval_basis = "managed_maximum", + "Managed maximum auto-applied proposal chunk" ); Ok(()) } -// TODO: share effective-policy lookup with `load_sandbox_policy` / -// `GetSandboxConfig`. They re-implement very similar global-settings + -// providers_v2 + compose logic; consolidating them is out of scope for the -// agent-authored proposal validation slice. -async fn current_effective_policy_for_sandbox( +async fn current_base_policy_for_sandbox( state: &ServerState, sandbox: &Sandbox, sandbox_id: &str, ) -> Result { - let mut policy = if let Some(record) = state + if let Some(record) = state .store .get_latest_policy(sandbox_id) .await .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))? { - ProtoSandboxPolicy::decode(record.policy_payload.as_slice()) - .map_err(|e| Status::internal(format!("decode current policy failed: {e}")))? - } else { - sandbox - .spec - .as_ref() - .and_then(|spec| spec.policy.clone()) - .unwrap_or_default() - }; - - let global_settings = load_global_settings(state.store.as_ref()).await?; - let policy_source = decode_policy_from_global_settings(&global_settings)?.map_or( - PolicySource::Sandbox, - |global_policy| { - policy = global_policy; - PolicySource::Global - }, - ); - - let providers_v2_enabled = - bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)?; - if providers_v2_enabled && !matches!(policy_source, PolicySource::Global) { - let provider_names = sandbox - .spec - .as_ref() - .map(|spec| spec.providers.clone()) - .unwrap_or_default(); - let provider_layers = - profile_provider_policy_layers(state.store.as_ref(), &provider_names).await?; - if !provider_layers.is_empty() { - policy = compose_effective_policy(&policy, &provider_layers); - } + let policy = ProtoSandboxPolicy::decode(record.policy_payload.as_slice()) + .map_err(|e| Status::internal(format!("decode current policy failed: {e}")))?; + return Ok(policy); } - - Ok(policy) + Ok(sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.clone()) + .unwrap_or_default()) } fn truncate_for_log(input: &str, max_chars: usize) -> String { @@ -1319,9 +1046,24 @@ async fn hash_provider_profile_revision( Ok(()) } -async fn profile_provider_policy_layers( +pub(super) async fn profile_provider_policy_layers( + store: &Store, + provider_names: &[String], +) -> Result, Status> { + provider_policy_layers(store, provider_names, false).await +} + +pub(super) async fn managed_profile_provider_policy_layers( + store: &Store, + provider_names: &[String], +) -> Result, Status> { + provider_policy_layers(store, provider_names, true).await +} + +async fn provider_policy_layers( store: &Store, provider_names: &[String], + require_resolvable_profile: bool, ) -> Result, Status> { let mut layers = Vec::new(); @@ -1335,6 +1077,9 @@ async fn profile_provider_policy_layers( let provider_type = provider.r#type.trim(); let profile = if let Some(canonical_type) = normalize_provider_type(provider_type) { let Some(profile) = get_default_profile(canonical_type) else { + if require_resolvable_profile { + return Err(unmodeled_managed_provider(name, provider_type)); + } warn!( provider_name = %name, provider_type, @@ -1347,6 +1092,9 @@ async fn profile_provider_policy_layers( let Some(profile) = super::provider::get_provider_type_profile(store, provider_type).await? else { + if require_resolvable_profile { + return Err(unmodeled_managed_provider(name, provider_type)); + } warn!( provider_name = %name, provider_type, @@ -1367,6 +1115,12 @@ async fn profile_provider_policy_layers( Ok(layers) } +fn unmodeled_managed_provider(provider_name: &str, provider_type: &str) -> Status { + Status::failed_precondition(format!( + "provider '{provider_name}' type '{provider_type}' has no resolvable policy profile; managed maximum admission cannot prove its credential-bearing reach" + )) +} + fn bool_setting_enabled(settings: &StoredSettings, key: &str) -> Result { match settings.settings.get(key) { None => Ok(false), @@ -1492,6 +1246,16 @@ async fn handle_update_config_inner( if req.global { let _settings_guard = state.settings_mutex.lock().await; + if has_policy + && super::managed_policy::load_managed_policy_config(state) + .await? + .is_some() + { + return Err(Status::failed_precondition( + "global policy override cannot coexist with a managed maximum policy", + )); + } + if has_merge_ops { return Err(Status::invalid_argument( "merge_operations are not supported for global policy updates", @@ -1753,6 +1517,10 @@ async fn handle_update_config_inner( &sandbox_id, spec.policy.as_ref(), &merge_ops, + Some(ManagedMergeAdmission { + state, + source: crate::managed_policy::AdmissionSource::DirectAuthenticated, + }), ) .await?; @@ -1809,6 +1577,39 @@ async fn handle_update_config_inner( "policy is managed globally; delete global policy before sandbox policy update", )); } + let requested_policy_backfill = sandbox + .spec + .as_ref() + .is_some_and(|spec| spec.policy.is_none()); + + // Provider attachment changes the composed authority evaluated below. + // Hold the shared guard through proof and persistence so the candidate + // cannot change between those steps. + let _sandbox_sync_guard = if super::managed_policy::load_managed_policy_config(state) + .await? + .is_some() + { + Some(state.compute.sandbox_sync_guard().await) + } else { + None + }; + let sandbox = state + .store + .get_message::(&sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + if requested_policy_backfill && req.expected_resource_version != 0 { + let current_resource_version = sandbox + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + if current_resource_version != req.expected_resource_version { + return Err(Status::aborted(format!( + "backfill spec.policy was modified concurrently (current resource_version: {current_resource_version})" + ))); + } + } let spec = sandbox .spec @@ -1830,9 +1631,44 @@ async fn handle_update_config_inner( if let Some(baseline_policy) = spec.policy.as_ref() { validate_static_fields_unchanged(baseline_policy, &new_policy)?; validate_policy_safety(&new_policy)?; + } + + let latest = state + .store + .get_latest_policy(&sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; + + let current_policy = if let Some(record) = latest.as_ref() { + ProtoSandboxPolicy::decode(record.policy_payload.as_slice()) + .map_err(|e| Status::internal(format!("decode current policy failed: {e}")))? } else { - // Backfill spec.policy using CAS (first-time policy discovery) - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + spec.policy.clone().unwrap_or_default() + }; + let source = if sandbox_caller { + crate::managed_policy::AdmissionSource::AgentProposal + } else { + crate::managed_policy::AdmissionSource::DirectAuthenticated + }; + let decision = super::managed_policy::decide_managed_authority( + state, + super::managed_policy::ManagedAuthorityRequest { + mode: super::managed_policy::permission_mode_for_sandbox(&sandbox)?, + source, + sandbox_id: &sandbox_id, + sandbox_name: sandbox.object_name(), + current: Some((¤t_policy, &spec.providers)), + candidate: (&new_policy, &spec.providers), + requested_delta: (&new_policy, &spec.providers), + }, + ) + .await?; + super::managed_policy::require_applied(decision)?; + + if spec.policy.is_none() { + // First-time policy discovery is an authority commit point too. Do + // not persist the backfill until managed admission has accepted the + // fully composed candidate. let sandbox_id = sandbox.object_id().to_string(); let new_policy_clone = new_policy.clone(); state @@ -1856,12 +1692,6 @@ async fn handle_update_config_inner( ); } - let latest = state - .store - .get_latest_policy(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; - let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); @@ -2227,35 +2057,14 @@ pub(super) async fn handle_submit_policy_analysis( })); } - // `current_policy` is captured ONCE at the top of the batch and frozen - // for every chunk's delta computation, even if an earlier chunk in the - // batch auto-approves and merges. This is intentional v1 behavior: - // multi-chunk batches with overlapping endpoints would otherwise have - // chunk N+1 fail to see chunk N's contribution, which is a degenerate - // case for the common single-chunk submission shape. If real workloads - // surface a problem with batches that interact across chunks, the right - // fix is to recompute baseline after each successful auto-approve. - let current_policy = current_effective_policy_for_sandbox(state, &sandbox, &sandbox_id).await?; - - // Auto-approval is an opt-in behavior, sourced from the settings model - // (sandbox or gateway scope) so it can be flipped on a running sandbox - // and managed fleet-wide. Default (no setting, or any value other than - // exact "auto") preserves OpenShell's default-deny posture: every - // proposal lands in `pending` for a human reviewer. - let (auto_approve_enabled, resolved_from) = - resolve_proposal_approval_mode(state.store.as_ref(), sandbox.object_name()).await?; - - // The credential set is stable across all chunks in this batch, so build - // it once. v1 captures presence only — no scope modeling — so the prover - // can answer "is there a credential in scope for this host?" but not - // "what action class does that credential authorize?" - let provider_names_for_creds: Vec = sandbox + let current_base_policy = current_base_policy_for_sandbox(state, &sandbox, &sandbox_id).await?; + let provider_names: Vec = sandbox .spec .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let credential_set = - build_credential_set_for_sandbox(state.store.as_ref(), &provider_names_for_creds).await?; + let credential_target_hosts = + provider_credential_target_hosts(state.store.as_ref(), &provider_names).await?; let current_version = state .store @@ -2295,6 +2104,58 @@ pub(super) async fn handle_submit_policy_analysis( rejection_reasons.push(format!("chunk '{}' missing proposed_rule", chunk.rule_name)); continue; } + if let Err(status) = + validate_rule_not_always_blocked(chunk.proposed_rule.as_ref().expect("checked above")) + { + rejected += 1; + rejection_reasons.push(format!("chunk '{}': {}", chunk.rule_name, status.message())); + continue; + } + + let proposal_operations = [PolicyMergeOp::AddRule { + rule_name: chunk.rule_name.clone(), + rule: chunk.proposed_rule.clone().expect("checked above"), + }]; + let candidate = match merge_policy(current_base_policy.clone(), &proposal_operations) { + Ok(merged) => merged.policy, + Err(error) => { + rejected += 1; + rejection_reasons.push(format!("chunk '{}': {error}", chunk.rule_name)); + continue; + } + }; + let (managed_auto_apply, managed_validation) = match decide_managed_merge( + state, + &sandbox, + ¤t_base_policy, + &candidate, + &proposal_operations, + crate::managed_policy::AdmissionSource::AgentProposal, + ) + .await? + { + crate::managed_policy::AdmissionDecision::Unmanaged => (false, None), + crate::managed_policy::AdmissionDecision::Apply => ( + true, + Some("managed: apply: requested authority is auto-eligible".to_owned()), + ), + decision @ crate::managed_policy::AdmissionDecision::Ask { .. } => { + let status = super::managed_policy::require_applied(decision) + .expect_err("managed ask must produce an approval-required status"); + (false, Some(format!("managed: ask: {}", status.message()))) + } + decision @ crate::managed_policy::AdmissionDecision::Reject { .. } => { + rejected += 1; + let status = super::managed_policy::require_applied(decision) + .expect_err("managed reject must produce an error"); + rejection_reasons.push(format!( + "chunk '{}': {}", + chunk.rule_name, + status.message() + )); + continue; + } + }; let now_ms = current_time_ms(); let proposed_rule_bytes = chunk @@ -2313,16 +2174,16 @@ pub(super) async fn handle_submit_policy_analysis( .map(|b| b.path.clone()) .unwrap_or_default(); - // The prover runs on every proposal regardless of `analysis_mode`. - // Source provenance (mechanistic vs agent_authored) is preserved in - // OCSF audit fields, but the safety decision is grounded in the - // merged-policy consequence, not the author — proposer-agnostic. - let validation_result = validation_result_for_agent_proposal( - current_policy.clone(), - &chunk.rule_name, + let advisory = credentialed_l4_advisory( chunk.proposed_rule.as_ref().expect("checked above"), - &credential_set, + &credential_target_hosts, ); + let validation_result = match (managed_validation, advisory) { + (Some(managed), Some(advisory)) => format!("{managed}; {advisory}"), + (Some(managed), None) => managed, + (None, Some(advisory)) => advisory, + (None, None) => String::new(), + }; let record = DraftChunkRecord { // The handler proposes an id; the store may swap it for an @@ -2408,24 +2269,16 @@ pub(super) async fn handle_submit_policy_analysis( .await; } - // Auto-approval gate (proposer-agnostic, opt-in): only fire when - // BOTH the prover found nothing new in this proposal's delta AND - // the reviewer opted in via the `proposal_approval_mode` setting - // (gateway or sandbox scope). On any failure (merge conflict, - // status update error), the chunk stays pending so a human can - // review — never silently lose a proposal. The `validation_result` - // literal here is the canonical empty-delta verdict; any other - // string means findings or infrastructure error, both of which - // require human attention. - if auto_approve_enabled - && validation_result == "prover: no new findings" - && let Err(err) = auto_approve_chunk( + // Only managed admission can authorize automatic application. An + // unmanaged proposal always remains pending, and advisories never + // participate in this decision. + if managed_auto_apply + && let Err(err) = apply_managed_proposal_chunk( state, &sandbox_id, sandbox.object_name(), &effective_id, &req.analysis_mode, - resolved_from, ) .await { @@ -2433,7 +2286,7 @@ pub(super) async fn handle_submit_policy_analysis( chunk_id = %effective_id, sandbox_id = %sandbox_id, error = %err, - "auto-approval failed; chunk remains pending for human review" + "managed proposal apply failed; chunk remains pending for human review" ); } @@ -2576,8 +2429,13 @@ async fn handle_approve_draft_chunk_inner( "ApproveDraftChunk: merging rule into active policy" ); - let (version, hash) = - merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &chunk).await?; + let (version, hash) = merge_chunk_into_policy_managed( + state, + &sandbox_id, + &chunk, + crate::managed_policy::AdmissionSource::DirectAuthenticated, + ) + .await?; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -2788,8 +2646,13 @@ async fn handle_approve_all_draft_chunks_inner( "ApproveAllDraftChunks: merging chunk" ); - let (version, hash) = - merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, chunk).await?; + let (version, hash) = merge_chunk_into_policy_managed( + state, + &sandbox_id, + chunk, + crate::managed_policy::AdmissionSource::DirectAuthenticated, + ) + .await?; last_version = version; last_hash = hash; let chunk_summary = summarize_draft_chunk_rule(chunk)?; @@ -3502,12 +3365,72 @@ fn map_policy_merge_error(error: openshell_policy::PolicyMergeError) -> Status { } } +fn requested_delta_for_operations( + operations: &[PolicyMergeOp], + candidate: &ProtoSandboxPolicy, +) -> ProtoSandboxPolicy { + merge_policy(ProtoSandboxPolicy::default(), operations) + .map_or_else(|_| candidate.clone(), |merged| merged.policy) +} + +async fn decide_managed_merge( + state: &ServerState, + sandbox: &Sandbox, + current_policy: &ProtoSandboxPolicy, + candidate: &ProtoSandboxPolicy, + operations: &[PolicyMergeOp], + source: crate::managed_policy::AdmissionSource, +) -> Result { + let providers = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.as_slice()) + .ok_or_else(|| Status::failed_precondition("sandbox spec is missing"))?; + let requested_delta = if source == crate::managed_policy::AdmissionSource::AgentProposal { + requested_delta_for_operations(operations, candidate) + } else { + candidate.clone() + }; + super::managed_policy::decide_managed_authority( + state, + super::managed_policy::ManagedAuthorityRequest { + mode: super::managed_policy::permission_mode_for_sandbox(sandbox)?, + source, + sandbox_id: sandbox.object_id(), + sandbox_name: sandbox.object_name(), + current: Some((current_policy, providers)), + candidate: (candidate, providers), + requested_delta: (&requested_delta, providers), + }, + ) + .await +} + async fn apply_merge_operations_with_retry( store: &Store, sandbox_id: &str, baseline_policy: Option<&ProtoSandboxPolicy>, operations: &[PolicyMergeOp], + managed_admission: Option>, ) -> Result<(i64, String), Status> { + let managed_admission = match managed_admission { + Some(admission) + if super::managed_policy::load_managed_policy_config(admission.state) + .await? + .is_some() => + { + Some(admission) + } + _ => None, + }; + // Provider attachment and policy persistence both change effective + // authority. Serialize them so the candidate proven below is the one + // that is committed. + let _sandbox_sync_guard = match managed_admission { + Some(admission) => Some(admission.state.compute.sandbox_sync_guard().await), + None => None, + }; + for attempt in 1..=MERGE_RETRY_LIMIT { let latest = store .get_latest_policy(sandbox_id) @@ -3521,7 +3444,8 @@ async fn apply_merge_operations_with_retry( baseline_policy.cloned().unwrap_or_default() }; - let merged = merge_policy(current_policy, operations).map_err(map_policy_merge_error)?; + let merged = + merge_policy(current_policy.clone(), operations).map_err(map_policy_merge_error)?; let new_policy = merged.policy; let hash = deterministic_policy_hash(&new_policy); @@ -3530,6 +3454,26 @@ async fn apply_merge_operations_with_retry( } validate_policy_safety(&new_policy)?; + if let Some(admission) = managed_admission { + let sandbox = admission + .state + .store + .get_message::(sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let decision = decide_managed_merge( + admission.state, + &sandbox, + ¤t_policy, + &new_policy, + operations, + admission.source, + ) + .await?; + super::managed_policy::require_applied(decision)?; + } + if let Some(ref current) = latest && current.policy_hash == hash { @@ -3589,10 +3533,11 @@ async fn apply_merge_operations_with_retry( ))) } -pub(super) async fn merge_chunk_into_policy( - store: &Store, +pub(super) async fn merge_chunk_into_policy_managed( + state: &ServerState, sandbox_id: &str, chunk: &DraftChunkRecord, + source: crate::managed_policy::AdmissionSource, ) -> Result<(i64, String), Status> { let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) .map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?; @@ -3601,22 +3546,49 @@ pub(super) async fn merge_chunk_into_policy( rule, }]; validate_merge_operations_for_server(&operations)?; - apply_merge_operations_with_retry(store, sandbox_id, None, &operations).await -} - -async fn remove_chunk_from_policy( - state: &ServerState, - sandbox_id: &str, - chunk: &DraftChunkRecord, -) -> Result<(i64, String), Status> { apply_merge_operations_with_retry( state.store.as_ref(), sandbox_id, None, - &[PolicyMergeOp::RemoveBinary { + &operations, + Some(ManagedMergeAdmission { state, source }), + ) + .await +} + +#[cfg(test)] +async fn merge_chunk_into_policy( + store: &Store, + sandbox_id: &str, + chunk: &DraftChunkRecord, +) -> Result<(i64, String), Status> { + let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) + .map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?; + let operations = [PolicyMergeOp::AddRule { + rule_name: chunk.rule_name.clone(), + rule, + }]; + validate_merge_operations_for_server(&operations)?; + apply_merge_operations_with_retry(store, sandbox_id, None, &operations, None).await +} + +async fn remove_chunk_from_policy( + state: &ServerState, + sandbox_id: &str, + chunk: &DraftChunkRecord, +) -> Result<(i64, String), Status> { + apply_merge_operations_with_retry( + state.store.as_ref(), + sandbox_id, + None, + &[PolicyMergeOp::RemoveBinary { rule_name: chunk.rule_name.clone(), binary_path: chunk.binary.clone(), }], + Some(ManagedMergeAdmission { + state, + source: crate::managed_policy::AdmissionSource::DirectAuthenticated, + }), ) .await } @@ -3645,9 +3617,7 @@ fn proto_setting_to_stored(key: &str, value: &SettingValue) -> Result { - // Enforce per-key string whitelist at configure time so typos - // (e.g. `proposal_approval_mode=autom`) get rejected here instead - // of silently falling back to the default at runtime. + // Enforce any per-key string whitelist at configure time. if let Err(allowed) = setting.validate_string_value(v) { return Err(Status::invalid_argument(format!( "setting '{key}' expects one of [{}]; got '{}'", @@ -3932,6 +3902,292 @@ mod tests { request } + async fn configure_test_managed_maximum(state: &Arc) { + crate::grpc::managed_policy::handle_set_managed_maximum_policy( + state, + Request::new(openshell_core::proto::SetManagedMaximumPolicyRequest { + policy_yaml: br#"version: 1 +metadata: + policy_id: proposal-test + version: 1 + allowed_modes: [ask, auto] + default_mode: auto +network_policies: + github: + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: /repos/** } + - allow: + method: POST + path: /repos/** + review: { required: true, reason: write } + deny_rules: + - { method: DELETE, path: "**" } + binaries: + - path: /usr/bin/gh + credentialed_raw: + endpoints: + - host: api.credential.example + port: 443 + protocol: rest + enforcement: enforce + access: read-only + - host: api.credential.example + port: 443 + binaries: + - path: /usr/bin/curl +"# + .to_vec(), + }), + ) + .await + .expect("managed maximum should configure"); + } + + async fn put_managed_test_sandbox( + state: &Arc, + sandbox_id: &str, + sandbox_name: &str, + providers: Vec, + ) { + state + .store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: sandbox_id.to_owned(), + name: sandbox_name.to_owned(), + labels: HashMap::from([( + crate::grpc::managed_policy::MANAGED_PERMISSION_MODE_LABEL.to_owned(), + "auto".to_owned(), + )]), + ..Default::default() + }), + spec: Some(openshell_core::proto::SandboxSpec { + providers, + ..Default::default() + }), + ..Default::default() + }) + .await + .expect("sandbox should persist"); + } + + fn managed_rest_chunk(method: &str) -> PolicyChunk { + PolicyChunk { + rule_name: format!("github_{}", method.to_ascii_lowercase()), + proposed_rule: Some(NetworkPolicyRule { + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_owned(), + port: 443, + protocol: "rest".to_owned(), + enforcement: "enforce".to_owned(), + rules: vec![L7Rule { + allow: Some(openshell_core::proto::L7Allow { + method: method.to_owned(), + path: "/repos/acme/project".to_owned(), + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/gh".to_owned(), + ..Default::default() + }], + ..Default::default() + }), + rationale: "managed proposal test".to_owned(), + ..Default::default() + } + } + + #[tokio::test] + async fn managed_auto_proposals_apply_read_ask_for_reviewed_write_and_reject_delete() { + let state = test_server_state().await; + configure_test_managed_maximum(&state).await; + put_managed_test_sandbox(&state, "managed-auto-id", "managed-auto", Vec::new()).await; + + let read = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: "managed-auto".to_owned(), + proposed_chunks: vec![managed_rest_chunk("GET")], + analysis_mode: "agent_authored".to_owned(), + ..Default::default() + })), + ) + .await + .expect("read proposal should succeed") + .into_inner(); + assert_eq!(read.accepted_chunks, 1); + assert_eq!(read.rejected_chunks, 0); + + let draft = handle_get_draft_policy( + &state, + with_user(Request::new(GetDraftPolicyRequest { + name: "managed-auto".to_owned(), + status_filter: String::new(), + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(draft.chunks[0].status, "approved"); + + let write = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: "managed-auto".to_owned(), + proposed_chunks: vec![managed_rest_chunk("POST")], + analysis_mode: "agent_authored".to_owned(), + ..Default::default() + })), + ) + .await + .expect("reviewed write should enter the queue") + .into_inner(); + assert_eq!(write.accepted_chunks, 1); + let write_id = &write.accepted_chunk_ids[0]; + let draft = handle_get_draft_policy( + &state, + with_user(Request::new(GetDraftPolicyRequest { + name: "managed-auto".to_owned(), + status_filter: String::new(), + })), + ) + .await + .unwrap() + .into_inner(); + let reviewed_write = draft + .chunks + .iter() + .find(|chunk| &chunk.id == write_id) + .unwrap(); + assert_eq!(reviewed_write.status, "pending"); + assert!(reviewed_write.validation_result.contains("managed: ask")); + + let denied = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: "managed-auto".to_owned(), + proposed_chunks: vec![managed_rest_chunk("DELETE")], + analysis_mode: "agent_authored".to_owned(), + ..Default::default() + })), + ) + .await + .expect("denied proposal should return a structured rejection") + .into_inner(); + assert_eq!(denied.accepted_chunks, 0); + assert_eq!(denied.rejected_chunks, 1); + assert!(denied.rejection_reasons[0].contains("managed maximum proposal-test@1")); + } + + #[tokio::test] + #[allow(deprecated)] + async fn credentialed_l4_advisory_does_not_override_managed_apply() { + use openshell_core::proto::{ + ProviderProfile, ProviderProfileCategory, StoredProviderProfile, + }; + + let state = test_server_state().await; + configure_test_managed_maximum(&state).await; + state + .store + .put_message(&test_provider("credentialed-api", "credentialed-api")) + .await + .unwrap(); + state + .store + .put_message(&StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-credentialed-api".to_owned(), + name: "credentialed-api".to_owned(), + ..Default::default() + }), + profile: Some(ProviderProfile { + id: "credentialed-api".to_owned(), + display_name: "Credentialed API".to_owned(), + category: ProviderProfileCategory::Other as i32, + endpoints: vec![NetworkEndpoint { + host: "api.credential.example".to_owned(), + port: 443, + protocol: "rest".to_owned(), + enforcement: "enforce".to_owned(), + access: "read-only".to_owned(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_owned(), + ..Default::default() + }], + ..Default::default() + }), + }) + .await + .unwrap(); + put_managed_test_sandbox( + &state, + "managed-l4-id", + "managed-l4", + vec!["credentialed-api".to_owned()], + ) + .await; + + let response = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: "managed-l4".to_owned(), + proposed_chunks: vec![PolicyChunk { + rule_name: "credentialed_raw".to_owned(), + proposed_rule: Some(NetworkPolicyRule { + endpoints: vec![NetworkEndpoint { + host: "api.credential.example".to_owned(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_owned(), + ..Default::default() + }], + ..Default::default() + }), + rationale: "opaque protocol requires raw transport".to_owned(), + ..Default::default() + }], + analysis_mode: "agent_authored".to_owned(), + ..Default::default() + })), + ) + .await + .expect("in-boundary raw L4 proposal should be admitted") + .into_inner(); + assert_eq!(response.accepted_chunks, 1); + assert_eq!(response.rejected_chunks, 0); + + let draft = handle_get_draft_policy( + &state, + with_user(Request::new(GetDraftPolicyRequest { + name: "managed-l4".to_owned(), + status_filter: String::new(), + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(draft.chunks[0].status, "approved"); + assert!(draft.chunks[0].validation_result.contains("managed: apply")); + assert!( + draft.chunks[0] + .validation_result + .contains("advisory: credentialed raw L4 authority") + ); + } + /// Wrap a request with a sandbox `Principal` bound to `sandbox_id`. /// Use for tests that exercise sandbox-caller code paths. #[allow(dead_code)] @@ -4455,6 +4711,24 @@ mod tests { assert!(layers.is_empty()); } + #[tokio::test] + async fn managed_provider_policy_layers_reject_unknown_provider_types() { + let store = test_store().await; + store + .put_message(&test_provider("custom-provider", "custom")) + .await + .unwrap(); + + let error = + managed_profile_provider_policy_layers(&store, &["custom-provider".to_string()]) + .await + .expect_err("managed composition must reject unmodeled provider authority"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("no resolvable policy profile")); + assert!(!error.message().contains("ghp-test")); + } + #[tokio::test] async fn provider_policy_layers_skip_custom_profile_for_legacy_provider_type() { let store = test_store().await; @@ -5667,33 +5941,16 @@ mod tests { assert_eq!(policy.process.unwrap().run_as_user, "sandbox"); } - /// Test helper: pin the proposal approval mode for a sandbox via the - /// settings model, mirroring what `openshell settings set - /// proposal_approval_mode ` would do at runtime. - async fn seed_sandbox_approval_mode(state: &Arc, sandbox_name: &str, mode: &str) { - let mut settings = load_sandbox_settings(state.store.as_ref(), sandbox_name) - .await - .unwrap(); - settings.settings.insert( - settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), - StoredSettingValue::String(mode.to_string()), - ); - settings.revision = settings.revision.wrapping_add(1); - save_sandbox_settings(state.store.as_ref(), sandbox_name, &settings) - .await - .unwrap(); - } + const LEGACY_PROPOSAL_APPROVAL_MODE_KEY: &str = "proposal_approval_mode"; - /// Test helper: pin the gateway-wide proposal approval mode, mirroring - /// `openshell settings set --global proposal_approval_mode `. - async fn seed_global_approval_mode(state: &Arc, mode: &str) { - let mut settings = load_global_settings(state.store.as_ref()).await.unwrap(); - settings.settings.insert( - settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), - StoredSettingValue::String(mode.to_string()), + async fn seed_legacy_global_auto_setting(state: &Arc) { + let mut stored = load_global_settings(state.store.as_ref()).await.unwrap(); + stored.settings.insert( + LEGACY_PROPOSAL_APPROVAL_MODE_KEY.to_string(), + StoredSettingValue::String("auto".to_string()), ); - settings.revision = settings.revision.wrapping_add(1); - save_global_settings(state.store.as_ref(), &settings) + stored.revision = stored.revision.wrapping_add(1); + save_global_settings(state.store.as_ref(), &stored) .await .unwrap(); } @@ -5705,12 +5962,8 @@ mod tests { }; let state = test_server_state().await; - // Attach a github provider so the proposal below has a credential in - // scope for api.github.com. This causes the prover to emit a HIGH - // finding (L4 + credential in scope), keeping the chunk pending so - // the manual approve/reject lifecycle this test exercises is - // reachable. Without a provider, the proposal would auto-approve and - // the lifecycle assertions would no longer apply. + // Attach a GitHub provider so the raw L4 proposal records the + // credentialed-L4 advisory while remaining pending in unmanaged mode. state .store .put_message(&test_provider("github-pat", "github")) @@ -5786,10 +6039,12 @@ mod tests { .into_inner(); assert_eq!(draft_policy.draft_version, 1); assert_eq!(draft_policy.chunks.len(), 1); - // The proposal is L4 to a host with a credential in scope, so the - // prover emits a HIGH finding and the chunk stays pending for the - // manual approve path this test exercises. assert_eq!(draft_policy.chunks[0].status, "pending"); + assert!( + draft_policy.chunks[0] + .validation_result + .starts_with("advisory: credentialed raw L4 authority") + ); let chunk_id = draft_policy.chunks[0].id.clone(); let approve = handle_approve_draft_chunk( @@ -6011,15 +6266,11 @@ mod tests { rejected.rejection_reason, guidance, "reviewer's free-form reason must round-trip into the chunk for agent readback" ); - // The prover now runs on every proposal regardless of analysis_mode. - // For this rule (L4 to api.example.com, no provider attached, no - // credential in scope), v1 calibration emits no finding — so the - // verdict is the clean "no new findings" string, not empty. - assert_eq!(rejected.validation_result, "prover: no new findings"); + assert!(rejected.validation_result.is_empty()); } #[tokio::test] - async fn agent_authored_exact_l7_proposal_gets_prover_pass_verdict() { + async fn unmanaged_l7_proposal_remains_pending_without_advisory() { use openshell_core::proto::{ FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -6050,11 +6301,6 @@ mod tests { }; sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); - // Opt this sandbox into auto-approval via the settings model — same - // path the CLI's `--approval-mode auto` exercises — to test the - // empty-delta → approved path. - seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; - let proposed_rule = NetworkPolicyRule { name: "github_contents_write".to_string(), endpoints: vec![NetworkEndpoint { @@ -6104,19 +6350,10 @@ mod tests { .await .unwrap() .into_inner(); - let verdict = &draft.chunks[0].validation_result; + assert!(draft.chunks[0].validation_result.is_empty()); assert_eq!( - verdict, "prover: no new findings", - "exact L7 PUT against an inspected endpoint should not introduce \ - any new findings over baseline; got: {verdict}" - ); - // Auto-approval gate: empty delta + sandbox opted into auto mode → - // status flips to approved without human action. The canonical - // happy path for agent speed. - assert_eq!( - draft.chunks[0].status, "approved", - "empty-delta agent-authored proposal under auto mode must auto-approve; \ - got status: {}", + draft.chunks[0].status, "pending", + "unmanaged proposals require human approval; got status: {}", draft.chunks[0].status ); } @@ -6134,8 +6371,8 @@ mod tests { }; let state = test_server_state().await; - // github provider attached so the mechanistic L4 lands a HIGH - // finding and stays pending. + // GitHub provider attached so the mechanistic L4 proposal records an + // advisory while remaining pending in unmanaged mode. state .store .put_message(&test_provider("github-pat", "github")) @@ -6167,8 +6404,7 @@ mod tests { sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); - // Step 1: mechanistic submits a broad L4 grant; the prover flags it - // HIGH, so it lands in pending. + // Step 1: mechanistic submits a broad L4 grant. let mechanistic_rule = NetworkPolicyRule { name: "allow_api_github_com_443".to_string(), endpoints: vec![NetworkEndpoint { @@ -6200,8 +6436,8 @@ mod tests { .into_inner(); let mechanistic_chunk_id = mechanistic_submit.accepted_chunk_ids[0].clone(); - // Sanity-check: the mechanistic chunk is pending and carries a HIGH - // finding. + // Sanity-check: the mechanistic chunk is pending and carries the + // non-gating credentialed-L4 advisory. let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { @@ -6218,25 +6454,16 @@ mod tests { .find(|c| c.id == mechanistic_chunk_id) .expect("mechanistic chunk present"); assert_eq!(mech.status, "pending"); - // Mechanistic L4 with credential in scope flags as new credentialed - // reach for the binary on the host. assert!( mech.validation_result - .contains("credential_reach_expansion"), - "mechanistic L4 with credential in scope should emit \ - credential_reach_expansion; got: {}", + .starts_with("advisory: credentialed raw L4 authority"), + "mechanistic L4 with credential in scope should be advisory; got: {}", mech.validation_result ); - // Step 2: the agent refines into a narrow L7 proposal for the SAME - // (host, port, binary). Under the v1 calibration, an L7 PUT on a - // host where the binary already had credentialed reach (read-only) - // emits a capability_expansion finding (new method on already- - // reached host) rather than a fresh reach expansion. The agent - // chunk stays pending for human review. The mechanistic chunk gets - // auto-rejected as superseded regardless of the agent chunk's own - // validation verdict — supersede is unconditional on `(host, port, - // binary)` overlap. + // Step 2: the agent refines into a narrow L7 proposal for the same + // (host, port, binary). The L7 proposal has no advisory and remains + // pending for human review; structural supersede is unchanged. let agent_rule = NetworkPolicyRule { name: "github_contents_put".to_string(), endpoints: vec![NetworkEndpoint { @@ -6306,14 +6533,7 @@ mod tests { so the agent's chunk grants brand-new credentialed reach. got: {}", agent.status ); - assert!( - agent - .validation_result - .contains("credential_reach_expansion"), - "agent chunk should carry credential_reach_expansion (new credentialed reach \ - on api.github.com); got: {}", - agent.validation_result - ); + assert!(agent.validation_result.is_empty()); assert_eq!( mech_after.status, "rejected", "older mechanistic chunk for same (host, port, binary) should be superseded; \ @@ -6332,12 +6552,9 @@ mod tests { ); } - /// Auto-approval is **proposer-agnostic**: a mechanistic proposal whose - /// prover delta is empty auto-approves the same way an agent-authored one - /// does. Source provenance is preserved in the audit trail (OCSF event - /// `source=mechanistic`) but does not change the safety decision. + /// Unmanaged proposals remain pending regardless of proposer provenance. #[tokio::test] - async fn mechanistic_proposal_with_empty_delta_also_auto_approves() { + async fn unmanaged_mechanistic_proposal_remains_pending() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -6369,10 +6586,6 @@ mod tests { }; sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); - // Opt into auto mode via the settings model to test the - // proposer-agnostic gate. - seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; - let proposed_rule = NetworkPolicyRule { name: "anon_l4".to_string(), endpoints: vec![NetworkEndpoint { @@ -6413,23 +6626,18 @@ mod tests { .await .unwrap() .into_inner(); - let verdict = &draft.chunks[0].validation_result; - assert_eq!(verdict, "prover: no new findings"); + assert!(draft.chunks[0].validation_result.is_empty()); assert_eq!( - draft.chunks[0].status, "approved", - "empty-delta mechanistic proposal under auto mode must auto-approve \ - (proposer-agnostic); got status: {}", + draft.chunks[0].status, "pending", + "unmanaged mechanistic proposal must wait for human review; got status: {}", draft.chunks[0].status ); } - /// `protocol: rest, access: full` on a host where the binary had no - /// prior credentialed reach: the prover emits - /// `credential_reach_expansion`. (The per-method `capability_expansion` - /// paths are suppressed by the gateway delta because the reach is - /// new; one finding describes the change, not eight.) + /// Credential presence does not turn inspected L7 authority into a raw + /// L4 advisory. #[tokio::test] - async fn agent_authored_l7_full_with_credential_emits_reach_expansion() { + async fn credentialed_l7_proposal_has_no_l4_advisory() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -6466,8 +6674,6 @@ mod tests { }; sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); - seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; - // L7-annotated (protocol: rest, enforce) but access: full — no // method/path bound. Credential in scope. let proposed_rule = NetworkPolicyRule { @@ -6513,33 +6719,17 @@ mod tests { .await .unwrap() .into_inner(); - let verdict = &draft.chunks[0].validation_result; - assert!( - verdict.contains("credential_reach_expansion"), - "L7 `access: full` on a host the binary did not previously reach must emit \ - credential_reach_expansion; got: {verdict}" - ); - // Capability_expansion paths for the same (binary, host:port) are - // suppressed when the reach itself is new — one finding, not many. - assert!( - !verdict.contains("capability_expansion"), - "capability_expansion must be suppressed when reach itself is new; got: {verdict}" - ); + assert!(draft.chunks[0].validation_result.is_empty()); assert_eq!( draft.chunks[0].status, "pending", - "any prover finding must keep the chunk in pending despite auto mode; got: {}", + "unmanaged proposals require human approval; got: {}", draft.chunks[0].status ); } - /// Acceptance criterion #7: default approval mode is manual. A sandbox - /// with no `proposal_approval_mode` setting at either scope must NOT - /// auto-approve empty-delta proposals; the chunk lands in `pending` for - /// human review. This is the default-deny safeguard: auto-approval is - /// an explicit opt-in, not a global behavior change shipped under a - /// feature. + /// Unmanaged authority expansion always requires human approval. #[tokio::test] - async fn empty_delta_does_not_auto_approve_when_mode_unset() { + async fn unmanaged_proposal_remains_pending() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -6564,8 +6754,6 @@ mod tests { }), ..Default::default() }), - // No approval-mode setting seeded at sandbox or gateway - // scope — the resolver must treat absence as "manual". ..Default::default() }), ..Default::default() @@ -6594,7 +6782,7 @@ mod tests { proposed_chunks: vec![PolicyChunk { rule_name: "anon_l4".to_string(), proposed_rule: Some(proposed_rule), - rationale: "un-credentialed L4 — prover sees no finding".to_string(), + rationale: "uncredentialed L4".to_string(), ..Default::default() }], ..Default::default() @@ -6613,36 +6801,28 @@ mod tests { .await .unwrap() .into_inner(); - let verdict = &draft.chunks[0].validation_result; - assert_eq!( - verdict, "prover: no new findings", - "prover should still emit no findings; gate is downstream", - ); + assert!(draft.chunks[0].validation_result.is_empty()); assert_eq!( draft.chunks[0].status, "pending", - "default (unset) proposal_approval_mode must not auto-approve; \ - chunk should wait for human review. got status: {}", + "unmanaged proposal must wait for human review; got status: {}", draft.chunks[0].status ); } - /// Unknown `proposal_approval_mode` strings (typos, future-mode values - /// the gateway doesn't yet know about) fall back to manual. This locks - /// in forward-compat: a future CLI that learns about `"auto_on_low_risk"` - /// can never accidentally bypass an older gateway's review gate just by - /// virtue of an unrecognized value defaulting to "auto." + /// A stale persisted legacy auto setting cannot restore unmanaged + /// automatic approval after the setting is removed from the product. #[tokio::test] - async fn empty_delta_does_not_auto_approve_when_mode_unknown_string() { + async fn stale_legacy_auto_setting_is_inert() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, }; let state = test_server_state().await; - let sandbox_name = "unknown-mode".to_string(); + let sandbox_name = "gateway-auto-mode".to_string(); let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-unknown-mode".to_string(), + id: "sb-gateway-auto-mode".to_string(), name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), @@ -6663,8 +6843,7 @@ mod tests { }; sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); - // A future-CLI value the current gateway doesn't recognize. - seed_sandbox_approval_mode(&state, &sandbox_name, "auto_on_low_risk").await; + seed_legacy_global_auto_setting(&state).await; let proposed_rule = NetworkPolicyRule { name: "anon_l4".to_string(), @@ -6687,7 +6866,7 @@ mod tests { proposed_chunks: vec![PolicyChunk { rule_name: "anon_l4".to_string(), proposed_rule: Some(proposed_rule), - rationale: "un-credentialed L4".to_string(), + rationale: "uncredentialed unmanaged L4".to_string(), ..Default::default() }], ..Default::default() @@ -6708,26 +6887,28 @@ mod tests { .into_inner(); assert_eq!( draft.chunks[0].status, "pending", - "unknown approval-mode strings must fall back to manual; \ - only the literal \"auto\" opts in. got: {}", + "legacy gateway auto setting must not authorize unmanaged expansion; got: {}", draft.chunks[0].status ); } - /// Explicit `"manual"` is equivalent to the unset default — chunk lands - /// in pending even with empty delta. + /// Agent submissions targeting a `_provider_*` rule name are rejected at + /// the submit boundary. Provider-synthesized rules are a reserved + /// namespace; an agent that addresses one by name could otherwise + /// circumvent the merge guard that splits agent contributions into their + /// own rule and blur the user/provider ownership boundary. #[tokio::test] - async fn empty_delta_does_not_auto_approve_when_mode_explicit_manual() { + async fn submit_rejects_reserved_provider_rule_name_prefix() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, }; let state = test_server_state().await; - let sandbox_name = "explicit-manual-mode".to_string(); + let sandbox_name = "reject-provider-prefix".to_string(); let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-explicit-manual-mode".to_string(), + id: "sb-reject-provider-prefix".to_string(), name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), @@ -6748,12 +6929,11 @@ mod tests { }; sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); - seed_sandbox_approval_mode(&state, &sandbox_name, "manual").await; let proposed_rule = NetworkPolicyRule { - name: "anon_l4".to_string(), + name: "github".to_string(), endpoints: vec![NetworkEndpoint { - host: "example.com".to_string(), + host: "api.github.com".to_string(), port: 443, ..Default::default() }], @@ -6763,280 +6943,15 @@ mod tests { }], }; - handle_submit_policy_analysis( + let response = handle_submit_policy_analysis( &state, with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { - rule_name: "anon_l4".to_string(), + rule_name: "_provider_work_github".to_string(), proposed_rule: Some(proposed_rule), - rationale: "un-credentialed L4 — prover sees no finding".to_string(), - ..Default::default() - }], - ..Default::default() - })), - ) - .await - .unwrap(); - - let draft = handle_get_draft_policy( - &state, - with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, - status_filter: String::new(), - })), - ) - .await - .unwrap() - .into_inner(); - assert_eq!( - draft.chunks[0].status, "pending", - "explicit manual mode must equal default mode — no auto-approval; \ - got: {}", - draft.chunks[0].status - ); - } - - /// Gateway-scope `proposal_approval_mode = "auto"` enables auto-approval - /// for any sandbox under that gateway, with no per-sandbox setting - /// required. This is the fleet-wide opt-in path — a reviewer flips the - /// gateway setting once and every sandbox without an explicit override - /// gets prover-gated auto-approval. - #[tokio::test] - async fn empty_delta_auto_approves_from_gateway_scope_setting() { - use openshell_core::proto::{ - FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, - SandboxSpec, - }; - - let state = test_server_state().await; - let sandbox_name = "gateway-auto-mode".to_string(); - let mut sandbox = Sandbox { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-gateway-auto-mode".to_string(), - name: sandbox_name.clone(), - created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), - resource_version: 0, - }), - spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { - version: 1, - filesystem: Some(FilesystemPolicy { - read_write: vec!["/sandbox".to_string()], - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }; - sandbox.set_phase(SandboxPhase::Ready as i32); - state.store.put_message(&sandbox).await.unwrap(); - // Fleet-wide opt-in — no sandbox-scope setting. - seed_global_approval_mode(&state, "auto").await; - - let proposed_rule = NetworkPolicyRule { - name: "anon_l4".to_string(), - endpoints: vec![NetworkEndpoint { - host: "example.com".to_string(), - port: 443, - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - handle_submit_policy_analysis( - &state, - with_user(Request::new(SubmitPolicyAnalysisRequest { - name: sandbox_name.clone(), - analysis_mode: "agent_authored".to_string(), - proposed_chunks: vec![PolicyChunk { - rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), - rationale: "un-credentialed L4 — empty delta".to_string(), - ..Default::default() - }], - ..Default::default() - })), - ) - .await - .unwrap(); - - let draft = handle_get_draft_policy( - &state, - with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, - status_filter: String::new(), - })), - ) - .await - .unwrap() - .into_inner(); - assert_eq!( - draft.chunks[0].status, "approved", - "empty-delta proposal must auto-approve when the gateway-scope \ - setting is \"auto\" and no sandbox-scope override exists. got: {}", - draft.chunks[0].status - ); - } - - /// Gateway scope wins over sandbox scope. A reviewer can pin manual mode - /// fleet-wide; a per-sandbox `"auto"` value is silently ignored. Matches - /// the existing settings precedence convention (global wins, sandbox is - /// the per-sandbox override only when no global is set). - #[tokio::test] - async fn gateway_manual_overrides_sandbox_auto() { - use openshell_core::proto::{ - FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, - SandboxSpec, - }; - - let state = test_server_state().await; - let sandbox_name = "gateway-pinned-manual".to_string(); - let mut sandbox = Sandbox { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-gateway-pinned-manual".to_string(), - name: sandbox_name.clone(), - created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), - resource_version: 0, - }), - spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { - version: 1, - filesystem: Some(FilesystemPolicy { - read_write: vec!["/sandbox".to_string()], - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }; - sandbox.set_phase(SandboxPhase::Ready as i32); - state.store.put_message(&sandbox).await.unwrap(); - // Gateway pins manual; the sandbox-scope override is supplied (test - // helper bypasses the UpdateConfig precondition, simulating the - // before-pin state) to prove the resolver still picks the gateway - // value. - seed_global_approval_mode(&state, "manual").await; - seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; - - let proposed_rule = NetworkPolicyRule { - name: "anon_l4".to_string(), - endpoints: vec![NetworkEndpoint { - host: "example.com".to_string(), - port: 443, - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - handle_submit_policy_analysis( - &state, - with_user(Request::new(SubmitPolicyAnalysisRequest { - name: sandbox_name.clone(), - analysis_mode: "agent_authored".to_string(), - proposed_chunks: vec![PolicyChunk { - rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), - rationale: "un-credentialed L4 — empty delta".to_string(), - ..Default::default() - }], - ..Default::default() - })), - ) - .await - .unwrap(); - - let draft = handle_get_draft_policy( - &state, - with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, - status_filter: String::new(), - })), - ) - .await - .unwrap() - .into_inner(); - assert_eq!( - draft.chunks[0].status, "pending", - "gateway-scope \"manual\" must win over sandbox-scope \"auto\"; \ - got: {}", - draft.chunks[0].status - ); - } - - /// Agent submissions targeting a `_provider_*` rule name are rejected at - /// the submit boundary. Provider-synthesized rules are a reserved - /// namespace; an agent that addresses one by name could otherwise - /// circumvent the merge guard that splits agent contributions into their - /// own rule (so the prover sees them honestly). - #[tokio::test] - async fn submit_rejects_reserved_provider_rule_name_prefix() { - use openshell_core::proto::{ - FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, - SandboxSpec, - }; - - let state = test_server_state().await; - let sandbox_name = "reject-provider-prefix".to_string(); - let mut sandbox = Sandbox { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-reject-provider-prefix".to_string(), - name: sandbox_name.clone(), - created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), - resource_version: 0, - }), - spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { - version: 1, - filesystem: Some(FilesystemPolicy { - read_write: vec!["/sandbox".to_string()], - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }; - sandbox.set_phase(SandboxPhase::Ready as i32); - state.store.put_message(&sandbox).await.unwrap(); - - let proposed_rule = NetworkPolicyRule { - name: "github".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - let response = handle_submit_policy_analysis( - &state, - with_user(Request::new(SubmitPolicyAnalysisRequest { - name: sandbox_name.clone(), - analysis_mode: "agent_authored".to_string(), - proposed_chunks: vec![PolicyChunk { - rule_name: "_provider_work_github".to_string(), - proposed_rule: Some(proposed_rule), - rationale: "should be rejected — addresses provider rule by name".to_string(), + rationale: "should be rejected — addresses provider rule by name".to_string(), ..Default::default() }], ..Default::default() @@ -7146,12 +7061,10 @@ mod tests { ); } - /// v1 calibration row: **L4 with a credential in scope → HIGH finding.** - /// The sandbox has a github provider attached, so a credential is in - /// scope for api.github.com. A broad L4 proposal therefore lands in - /// pending with a HIGH finding. + /// Raw L4 authority to an attached provider's credential target records + /// a non-gating advisory. #[tokio::test] - async fn agent_authored_l4_proposal_with_credential_records_high_finding() { + async fn credentialed_l4_proposal_records_advisory() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -7231,29 +7144,20 @@ mod tests { .unwrap() .into_inner(); let verdict = &draft.chunks[0].validation_result; - let first_line = verdict.lines().next().unwrap_or(""); assert!( - first_line.starts_with("prover: ") && first_line.contains("new finding"), - "expected first line like `prover: N new finding(s)`, got: {verdict}" + verdict.starts_with("advisory: credentialed raw L4 authority"), + "expected credentialed L4 advisory, got: {verdict}" ); assert!( - verdict.contains("credential_reach_expansion"), - "L4 + credential in scope emits credential_reach_expansion (the binary gains \ - credentialed reach to a new host:port); got: {verdict}" - ); - assert!( - verdict.contains("api.github.com:443"), + verdict.contains("host=api.github.com port=443"), "expected the finding line to cite the proposed endpoint, got: {verdict}" ); } - /// v1 calibration row: **L4 with NO credential in scope → no finding.** - /// Without an attached provider, no credential targets api.github.com, - /// so the prover treats the L4 grant as bounded (no privileged action - /// available) and emits nothing. The proposal verdict reads - /// `prover: no new findings`, eligible for auto-approval. + /// Raw L4 authority without a matching provider credential target has no + /// credentialed-L4 advisory. #[tokio::test] - async fn agent_authored_l4_proposal_without_credential_emits_no_finding() { + async fn l4_proposal_without_credential_has_no_advisory() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -7327,18 +7231,12 @@ mod tests { .unwrap() .into_inner(); let verdict = &draft.chunks[0].validation_result; - assert_eq!( - verdict, "prover: no new findings", - "L4 grant with no credential in scope is bounded in v1; got: {verdict}" - ); + assert!(verdict.is_empty(), "unexpected advisory: {verdict}"); } - /// v1 calibration row: **link-local host → HIGH finding regardless of - /// credentials.** Even with no provider attached, a proposal targeting - /// `169.254.169.254` (AWS IMDS / cloud metadata) emits a HIGH finding. - /// This is the one categorical safety floor v1 ships. + /// Link-local authority is a gateway/runtime invariant, not an advisory. #[tokio::test] - async fn agent_authored_link_local_proposal_records_high_finding() { + async fn agent_authored_link_local_proposal_is_rejected() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -7384,7 +7282,7 @@ mod tests { }], }; - handle_submit_policy_analysis( + let response = handle_submit_policy_analysis( &state, with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), @@ -7399,32 +7297,16 @@ mod tests { })), ) .await - .unwrap(); - - let draft = handle_get_draft_policy( - &state, - with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, - status_filter: String::new(), - })), - ) - .await .unwrap() .into_inner(); - let verdict = &draft.chunks[0].validation_result; - assert!( - verdict.contains("link_local_reach"), - "link-local proposal must emit link_local_reach regardless of credentials; \ - got: {verdict}" - ); - assert!( - verdict.contains("169.254.169.254"), - "finding line must cite the link-local host; got: {verdict}" - ); + assert_eq!(response.accepted_chunks, 0); + assert_eq!(response.rejected_chunks, 1); + assert!(response.rejection_reasons[0].contains("169.254.169.254")); + assert!(response.rejection_reasons[0].contains("always-blocked")); } #[tokio::test] - async fn agent_authored_validation_uses_providers_v2_effective_policy() { + async fn provider_composition_does_not_create_l4_advisory_for_l7_proposal() { use openshell_core::proto::{ FilesystemPolicy, L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, ProviderProfile, ProviderProfileCategory, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -7552,35 +7434,13 @@ mod tests { .await .unwrap() .into_inner(); - let verdict = &draft.chunks[0].validation_result; - let first_line = verdict.lines().next().unwrap_or(""); - assert!( - first_line.starts_with("prover: "), - "validation should run end-to-end against the providers-v2 composed \ - effective policy and produce a prover verdict; got: {verdict}" - ); - assert!( - !verdict.contains("validation unavailable"), - "providers-v2 composition must not break the prover pipeline; \ - got: {verdict}" - ); - } - - /// End-to-end loop test against the v1 calibration and the auto-approval - /// gate. Mirrors the two-path flow in `examples/agent-driven-policy-management`: - /// - /// 1. Un-credentialed L7 proposal (raw.githubusercontent.com GET) → - /// prover sees no findings → sandbox in `auto` mode → chunk - /// auto-approves without human action. - /// - /// 2. Credentialed L7 proposal (api.github.com PUT) → prover sees - /// `github_token` in scope, emits MEDIUM → chunk lands in pending - /// for human review even under `auto` mode. - /// - /// This is the deterministic counterpart of the demo's product UX - /// claim: "narrow safe = free, narrow credentialed = one approval." + assert!(draft.chunks[0].validation_result.is_empty()); + assert_eq!(draft.chunks[0].status, "pending"); + } + + /// Unmanaged L7 proposals remain pending regardless of credential reach. #[tokio::test] - async fn full_loop_under_v2_auto_mode_splits_credentialed_and_uncredentialed() { + async fn unmanaged_l7_proposals_remain_pending() { use openshell_core::proto::{ FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -7589,10 +7449,8 @@ mod tests { let state = test_server_state().await; enable_providers_v2(&state).await; - // Github provider attached: a credential ends up in scope for - // api.github.com (PUT proposal flags MEDIUM). raw.githubusercontent.com - // is not declared by any provider, so the bootstrap fetch is - // un-credentialed and auto-approves. + // GitHub provider attached: only raw L4 authority would produce the + // credentialed-L4 advisory. state .store .put_message(&test_provider("github-pat", "github")) @@ -7624,9 +7482,7 @@ mod tests { }; sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); - seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; - - // ── Step 1: un-credentialed GET → expected auto-approve ── + // ── Step 1: uncredentialed GET ── let uncredentialed_rule = NetworkPolicyRule { name: "github_raw_openapi_get".to_string(), endpoints: vec![NetworkEndpoint { @@ -7668,7 +7524,7 @@ mod tests { .into_inner(); let step1_chunk_id = step1.accepted_chunk_ids[0].clone(); - // ── Step 2: credentialed PUT → expected MEDIUM, pending ── + // ── Step 2: credentialed PUT ── let credentialed_rule = NetworkPolicyRule { name: "github_contents_put".to_string(), endpoints: vec![NetworkEndpoint { @@ -7732,44 +7588,18 @@ mod tests { .expect("step2 chunk present"); assert_eq!( - step1_chunk.status, "approved", - "un-credentialed L7 proposal under v2 + auto mode must auto-approve; got: {}", + step1_chunk.status, "pending", + "unmanaged L7 proposal must remain pending; got: {}", step1_chunk.status ); - assert_eq!( - step1_chunk.validation_result, "prover: no new findings", - "un-credentialed L7 verdict should be `no new findings`; got: {}", - step1_chunk.validation_result - ); + assert!(step1_chunk.validation_result.is_empty()); assert_eq!( step2_chunk.status, "pending", - "credentialed L7 PUT under v2 + auto mode must stay pending; got: {}", + "unmanaged credentialed L7 proposal must remain pending; got: {}", step2_chunk.status ); - // This test's spec policy has no pre-existing rule for curl on - // api.github.com, so the agent's chunk grants brand-new - // credentialed reach: the finding is credential_reach_expansion, - // not capability_expansion. (The capability_expansion path is - // suppressed by the delta because the reach is new — one finding - // per change, not two.) The demo's policy.template.yaml has - // github_api_readonly which exercises the capability_expansion - // path; that's covered by the supersede test above. - assert!( - step2_chunk - .validation_result - .contains("credential_reach_expansion"), - "credentialed PUT on a host the binary did not previously reach must carry \ - credential_reach_expansion; got: {}", - step2_chunk.validation_result - ); - assert!( - !step2_chunk - .validation_result - .contains("capability_expansion"), - "capability_expansion must be suppressed when reach itself is new; got: {}", - step2_chunk.validation_result - ); + assert!(step2_chunk.validation_result.is_empty()); } /// Two agent-authored proposals targeting the same host/port/binary must @@ -8270,35 +8100,31 @@ mod tests { ); } - /// Auto-approval audit messages carry `auto=true`, `source=`, and - /// `prover_delta=empty` as extra unmapped fields so a reviewer can - /// reconstruct the safety reasoning without needing to grep the chunk - /// table. The message text itself says "auto-approved: no new prover - /// findings" — never "safe" — because the claim is about the prover's - /// reasoning, not the world. + /// Managed automatic application records proposal provenance without + /// reviving the legacy empty-finding approval basis. #[test] - fn build_gateway_policy_audit_message_carries_auto_approve_provenance() { + fn build_gateway_policy_audit_message_carries_managed_apply_provenance() { let extra = [ ("auto", "true".to_string()), ("source", "agent_authored".to_string()), - ("prover_delta", "empty".to_string()), + ("approval_basis", "managed_maximum".to_string()), ]; let message = build_gateway_policy_audit_message( "sb-123", "demo-sandbox", "approved", - "auto-approved: no new prover findings (source=agent_authored) — chunk abc: add-rule x", + "managed maximum auto-applied proposal (source=agent_authored) — chunk abc: add-rule x", 12, "sha256:autohash", &extra, ); assert!( message.contains("CONFIG:APPROVED"), - "auto-approval reuses CONFIG:APPROVED; got: {message}" + "managed automatic application uses CONFIG:APPROVED; got: {message}" ); assert!( - message.contains("auto-approved: no new prover findings"), - "audit copy must say `no new prover findings`, not `safe`; got: {message}" + message.contains("managed maximum auto-applied proposal"), + "audit copy must identify the managed approval basis; got: {message}" ); assert!( message.contains("auto:true"), @@ -8309,8 +8135,8 @@ mod tests { "missing source field: {message}" ); assert!( - message.contains("prover_delta:empty"), - "missing prover_delta field: {message}" + message.contains("approval_basis:managed_maximum"), + "missing managed approval basis: {message}" ); } @@ -8740,8 +8566,8 @@ mod tests { }]; let (left, right) = tokio::join!( - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_allow), - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_deny), + apply_merge_operations_with_retry(&store, sandbox_id, None, &add_allow, None), + apply_merge_operations_with_retry(&store, sandbox_id, None, &add_deny, None), ); let mut versions = vec![left.unwrap().0, right.unwrap().0]; @@ -9033,78 +8859,6 @@ mod tests { assert_eq!(stored, StoredSettingValue::Bool(true)); } - #[test] - fn proto_setting_to_stored_accepts_allowed_proposal_approval_mode_values() { - for raw in ["manual", "auto"] { - let value = SettingValue { - value: Some(setting_value::Value::StringValue(raw.to_string())), - }; - let stored = proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value) - .unwrap_or_else(|e| panic!("expected '{raw}' to be accepted, got: {e}")); - assert_eq!(stored, StoredSettingValue::String(raw.to_string())); - } - } - - #[test] - fn proto_setting_to_stored_rejects_invalid_proposal_approval_mode_value() { - // Typos and future-reserved modes must be rejected at configure time - // — without this, the value silently resolves to manual at runtime - // (fail-closed) and the operator never finds out they fat-fingered - // the setting. - for raw in ["autom", "AUTO", "Manual", "auto_on_low_risk", "", " auto"] { - let value = SettingValue { - value: Some(setting_value::Value::StringValue(raw.to_string())), - }; - let res = proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value); - assert!( - res.is_err(), - "expected '{raw}' to be rejected, got: {res:?}" - ); - let err = res.unwrap_err(); - assert_eq!(err.code(), Code::InvalidArgument); - } - } - - #[test] - fn proto_setting_to_stored_rejection_message_lists_allowed_proposal_approval_mode_values() { - let value = SettingValue { - value: Some(setting_value::Value::StringValue("autom".to_string())), - }; - let err = - proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value).unwrap_err(); - assert_eq!(err.code(), Code::InvalidArgument); - let msg = err.message(); - assert!(msg.contains("manual"), "missing 'manual' in {msg}"); - assert!(msg.contains("auto"), "missing 'auto' in {msg}"); - assert!(msg.contains("autom"), "missing offending value in {msg}"); - } - - /// Locks in that invalid `proposal_approval_mode` is rejected at the - /// `UpdateConfig` RPC boundary — not just in the `proto_setting_to_stored` - /// helper. Prevents a future refactor from accidentally routing setting - /// writes around the validation chokepoint. - #[tokio::test] - async fn update_config_global_rejects_invalid_proposal_approval_mode() { - let state = test_server_state().await; - let req = with_user(Request::new(UpdateConfigRequest { - global: true, - setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), - setting_value: Some(SettingValue { - value: Some(setting_value::Value::StringValue("autom".to_string())), - }), - ..Default::default() - })); - let err = handle_update_config(&state, req) - .await - .expect_err("invalid proposal_approval_mode must be rejected at UpdateConfig"); - assert_eq!(err.code(), Code::InvalidArgument); - assert!( - err.message().contains("autom") && err.message().contains("manual"), - "expected rejection message to echo the bad value and list allowed values; got: {}", - err.message() - ); - } - #[tokio::test] async fn update_config_global_policy_rejects_reserved_provider_key() { let state = test_server_state().await; @@ -9451,8 +9205,8 @@ mod tests { let sandbox_name = "my-sandbox"; let mut settings = StoredSettings::default(); settings.settings.insert( - settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), - StoredSettingValue::String("auto".to_string()), + "ocsf_json_enabled".to_string(), + StoredSettingValue::Bool(true), ); settings.revision = 3; save_sandbox_settings(&store, sandbox_name, &settings) @@ -9462,8 +9216,8 @@ mod tests { let loaded = load_sandbox_settings(&store, sandbox_name).await.unwrap(); assert_eq!(loaded.revision, 3); assert_eq!( - loaded.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY), - Some(&StoredSettingValue::String("auto".to_string())) + loaded.settings.get("ocsf_json_enabled"), + Some(&StoredSettingValue::Bool(true)) ); } diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c909..98cee5f9fd 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -232,6 +232,32 @@ pub(super) async fn update_provider_record( validate_provider_mutable_fields(&candidate)?; validate_provider_update_against_attached_sandboxes(store, &candidate).await?; + let managed_maximum_configured = super::policy::load_global_settings(store) + .await? + .settings + .contains_key(super::managed_policy::MANAGED_MAXIMUM_SETTING_KEY); + if managed_maximum_configured { + let attached_sandboxes = sandboxes_using_provider(store, candidate.object_name()).await?; + let existing_credential_keys = existing + .credentials + .keys() + .collect::>(); + let candidate_credential_keys = candidate + .credentials + .keys() + .collect::>(); + if !attached_sandboxes.is_empty() + && (existing.config != candidate.config + || existing_credential_keys != candidate_credential_keys) + { + return Err(Status::failed_precondition(format!( + "provider '{}' is attached to managed sandbox(es) {}; only credential-value rotation with unchanged keys and config is allowed", + candidate.object_name(), + attached_sandboxes.join(", ") + ))); + } + } + // Serialize labels for storage let labels_map = candidate.object_labels(); let labels_json = if labels_map @@ -734,7 +760,7 @@ fn count_as_u32(count: usize) -> u32 { u32::try_from(count).unwrap_or(u32::MAX) } -fn host_patterns_can_overlap(first: &str, second: &str) -> bool { +pub(super) fn host_patterns_can_overlap(first: &str, second: &str) -> bool { let first = first.to_ascii_lowercase(); let second = second.to_ascii_lowercase(); if !first.contains('*') { @@ -1728,6 +1754,10 @@ async fn profile_attached_sandbox_diagnostics( profiles: &[(String, ProviderTypeProfile)], operation: &str, ) -> Result, Status> { + let managed_maximum_configured = super::policy::load_global_settings(store) + .await? + .settings + .contains_key(super::managed_policy::MANAGED_MAXIMUM_SETTING_KEY); let mut candidate_profiles = std::collections::HashMap::::new(); for (source, profile) in profiles { @@ -1782,6 +1812,20 @@ async fn profile_attached_sandbox_diagnostics( if imported_profiles_used.is_empty() { continue; } + if managed_maximum_configured { + diagnostics.extend(imported_profiles_used.iter().map(|(source, profile_id)| { + ProfileValidationDiagnostic { + source: source.clone(), + profile_id: profile_id.clone(), + field: "id".to_string(), + message: format!( + "provider profile '{profile_id}' is attached to managed sandbox '{sandbox_name}' and cannot be changed while that sandbox exists" + ), + severity: "error".to_string(), + } + })); + continue; + } if let Err(err) = validate_dynamic_token_grant_bindings_unambiguous(&bindings) { for (source, profile_id) in &imported_profiles_used { diagnostics.push(ProfileValidationDiagnostic { @@ -1904,6 +1948,7 @@ pub(super) async fn handle_update_provider( provider .credential_expires_at_ms .extend(req.credential_expires_at_ms); + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let result = update_provider_record(state.store.as_ref(), provider).await; match result { Ok(provider) => { diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 04d5a4ed52..2360b89a57 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -10,7 +10,9 @@ #![allow(clippy::cast_possible_wrap)] // Intentional u32->i32 conversions for proto compat use crate::ServerState; +use crate::managed_policy::AdmissionSource; use crate::persistence::{ObjectType, WriteCondition, generate_name}; +use crate::policy_store::PolicyStoreExt; use futures::future; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateSandboxRequest, @@ -129,11 +131,28 @@ async fn handle_create_sandbox_inner( validate_sandbox_spec(&request.name, &spec)?; // Validate labels (keys and values must meet Kubernetes requirements). + if request + .labels + .contains_key(super::managed_policy::MANAGED_PERMISSION_MODE_LABEL) + { + return Err(Status::invalid_argument(format!( + "label '{}' is reserved by OpenShell", + super::managed_policy::MANAGED_PERMISSION_MODE_LABEL + ))); + } for (key, value) in &request.labels { crate::grpc::validation::validate_label_key(key)?; crate::grpc::validation::validate_label_value(value)?; } + // Serialize sandbox creation against managed-boundary mutations. The + // maximum can only change while no sandbox exists, so this guard closes + // the race between that check and sandbox persistence. + let _managed_settings_guard = state.settings_mutex.lock().await; + let permission_mode = + super::managed_policy::resolve_create_permission_mode(state, &request.permission_mode) + .await?; + let _sandbox_sync_guard = if spec.providers.is_empty() { None } else { @@ -173,14 +192,37 @@ async fn handle_create_sandbox_inner( request.name.clone() }; + let base_policy = spec.policy.clone().unwrap_or_default(); + let decision = super::managed_policy::decide_managed_authority( + state, + super::managed_policy::ManagedAuthorityRequest { + mode: permission_mode, + source: AdmissionSource::SandboxCreate, + sandbox_id: &id, + sandbox_name: &name, + current: None, + candidate: (&base_policy, &spec.providers), + requested_delta: (&base_policy, &spec.providers), + }, + ) + .await?; + super::managed_policy::require_applied(decision)?; + let now_ms = current_time_ms(); + let mut labels = request.labels.clone(); + if let Some(mode) = permission_mode { + labels.insert( + super::managed_policy::MANAGED_PERMISSION_MODE_LABEL.to_owned(), + mode.as_str().to_owned(), + ); + } let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.clone(), name: name.clone(), created_at_ms: now_ms, - labels: request.labels.clone(), + labels, resource_version: 0, }), spec: Some(spec), @@ -359,6 +401,32 @@ pub(super) async fn handle_attach_sandbox_provider( validate_provider_environment_keys_unique(state.store.as_ref(), &candidate_spec.providers) .await?; + let current_policy = if let Some(record) = state + .store + .get_latest_policy(&sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch latest policy failed: {error}")))? + { + openshell_core::proto::SandboxPolicy::decode(record.policy_payload.as_slice()) + .map_err(|error| Status::internal(format!("decode current policy failed: {error}")))? + } else { + spec.policy.clone().unwrap_or_default() + }; + let decision = super::managed_policy::decide_managed_authority( + state, + super::managed_policy::ManagedAuthorityRequest { + mode: super::managed_policy::permission_mode_for_sandbox(&sandbox)?, + source: AdmissionSource::DirectAuthenticated, + sandbox_id: &sandbox_id, + sandbox_name: sandbox.object_name(), + current: Some((¤t_policy, &spec.providers)), + candidate: (¤t_policy, &candidate_spec.providers), + requested_delta: (¤t_policy, &candidate_spec.providers), + }, + ) + .await?; + super::managed_policy::require_applied(decision)?; + let provider_name = request.provider_name.clone(); let attached = Arc::new(AtomicBool::new(false)); let attached_clone = attached.clone(); @@ -2640,6 +2708,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + permission_mode: String::new(), }), ) .await @@ -2672,6 +2741,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + permission_mode: String::new(), }), ) .await @@ -2703,6 +2773,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + permission_mode: String::new(), }), ) .await diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 6462ccbbf3..cb85492b87 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -32,6 +32,7 @@ mod defaults; mod grpc; mod http; mod inference; +mod managed_policy; mod multiplex; mod persistence; pub(crate) mod policy_store; diff --git a/crates/openshell-server/src/managed_policy.rs b/crates/openshell-server/src/managed_policy.rs new file mode 100644 index 0000000000..8589fb2baa --- /dev/null +++ b/crates/openshell-server/src/managed_policy.rs @@ -0,0 +1,492 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Pure admission decisions for a managed maximum policy. +//! +//! Callers compose the live candidate and requested delta before invoking this +//! module. Persistence remains outside this boundary so every commit point can +//! re-run the same decision immediately before applying a change. + +use openshell_prover::envelope::{ + MaximumPolicyCheck, PolicyCounterexample, check_within_auto_eligible_maximum, + check_within_maximum, +}; +use openshell_prover::policy::{PolicyModel, parse_policy_str}; + +/// User-selectable approval behavior inside a managed maximum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionMode { + Ask, + Auto, +} + +impl PermissionMode { + pub fn parse(mode: &str) -> Result { + match mode.trim().to_ascii_lowercase().as_str() { + "ask" => Ok(Self::Ask), + "auto" => Ok(Self::Auto), + _ => Err(format!( + "unsupported managed permission mode '{mode}'; expected ask or auto" + )), + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Ask => "ask", + Self::Auto => "auto", + } + } +} + +/// Origin of an authority-changing candidate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdmissionSource { + SandboxCreate, + DirectAuthenticated, + AgentProposal, +} + +impl AdmissionSource { + pub const fn as_str(self) -> &'static str { + match self { + Self::SandboxCreate => "sandbox_create", + Self::DirectAuthenticated => "direct_authenticated", + Self::AgentProposal => "agent_proposal", + } + } +} + +/// One active gateway-owned boundary. +pub struct ManagedBoundary<'a> { + pub id: &'a str, + pub version: u64, + pub maximum: &'a PolicyModel, + pub allowed_modes: &'a [PermissionMode], +} + +/// Validated, owned representation persisted by the gateway. +pub struct ManagedPolicyConfig { + pub id: String, + pub version: u64, + pub maximum: PolicyModel, + pub allowed_modes: Vec, + pub default_mode: PermissionMode, + pub audit_label: String, +} + +impl ManagedPolicyConfig { + /// Parse a managed maximum document without accepting a second policy + /// language. Metadata is read from the document's top-level `metadata` + /// block; authority is parsed by the normal prover policy model. + pub fn parse(yaml: &str) -> Result { + let maximum = parse_policy_str(yaml).map_err(|error| error.to_string())?; + if maximum.version != 1 { + return Err(format!( + "managed maximum uses unsupported policy version {}; expected version 1", + maximum.version + )); + } + let metadata = maximum + .managed_metadata + .as_ref() + .ok_or_else(|| "managed maximum requires a metadata block".to_owned())?; + if metadata.policy_id.trim().is_empty() { + return Err("managed maximum metadata.policy_id is required".to_owned()); + } + if metadata.version == 0 { + return Err("managed maximum metadata.version must be greater than zero".to_owned()); + } + if !metadata.extra_fields.is_empty() { + return Err(format!( + "managed maximum metadata contains unsupported fields: {}", + metadata.extra_fields.join(", ") + )); + } + + let mut allowed_modes = metadata + .allowed_modes + .iter() + .map(|mode| PermissionMode::parse(mode)) + .collect::, _>>()?; + allowed_modes.sort_by_key(|mode| match mode { + PermissionMode::Ask => 0, + PermissionMode::Auto => 1, + }); + allowed_modes.dedup(); + if allowed_modes.is_empty() { + return Err("managed maximum metadata.allowed_modes cannot be empty".to_owned()); + } + let default_mode = PermissionMode::parse(&metadata.default_mode)?; + if !allowed_modes.contains(&default_mode) { + return Err("managed maximum default_mode must be in allowed_modes".to_owned()); + } + + match check_within_maximum(&maximum, &maximum) { + MaximumPolicyCheck::WithinMax => {} + MaximumPolicyCheck::Unsupported { reason } => return Err(reason), + MaximumPolicyCheck::ExceedsMax { .. } => { + return Err("managed maximum was not self-contained".to_owned()); + } + } + + Ok(Self { + id: metadata.policy_id.clone(), + version: metadata.version, + allowed_modes, + default_mode, + audit_label: metadata.audit_label.clone(), + maximum, + }) + } + + pub fn boundary(&self) -> ManagedBoundary<'_> { + ManagedBoundary { + id: &self.id, + version: self.version, + maximum: &self.maximum, + allowed_modes: &self.allowed_modes, + } + } +} + +/// Result shared by all authority commit points. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdmissionDecision { + /// No managed maximum exists; the caller must use existing behavior. + Unmanaged, + Apply, + Ask { + reason: String, + counterexample: Option, + }, + Reject { + reason: String, + counterexample: Option, + }, +} + +/// Decide whether one fully composed candidate may be committed. +pub fn admit( + boundary: Option<&ManagedBoundary<'_>>, + mode: PermissionMode, + source: AdmissionSource, + current: Option<&PolicyModel>, + candidate: &PolicyModel, + requested_delta: &PolicyModel, +) -> AdmissionDecision { + let Some(boundary) = boundary else { + return AdmissionDecision::Unmanaged; + }; + + if !boundary.allowed_modes.contains(&mode) { + return AdmissionDecision::Reject { + reason: format!( + "permission mode {mode:?} is not allowed by managed maximum {}@{}", + boundary.id, boundary.version + ), + counterexample: None, + }; + } + + match check_within_maximum(boundary.maximum, candidate) { + MaximumPolicyCheck::WithinMax => {} + MaximumPolicyCheck::ExceedsMax { counterexample } => { + return AdmissionDecision::Reject { + reason: format!( + "candidate exceeds managed maximum {}@{}", + boundary.id, boundary.version + ), + counterexample: Some(*counterexample), + }; + } + MaximumPolicyCheck::Unsupported { reason } => { + return AdmissionDecision::Reject { + reason: format!( + "candidate cannot be evaluated against managed maximum {}@{}: {reason}", + boundary.id, boundary.version + ), + counterexample: None, + }; + } + } + + if source == AdmissionSource::SandboxCreate { + return match check_within_auto_eligible_maximum(boundary.maximum, candidate) { + MaximumPolicyCheck::WithinMax => AdmissionDecision::Apply, + MaximumPolicyCheck::ExceedsMax { counterexample } => AdmissionDecision::Reject { + reason: "sandbox creation includes review-required authority".to_owned(), + counterexample: Some(*counterexample), + }, + MaximumPolicyCheck::Unsupported { reason } => AdmissionDecision::Reject { + reason, + counterexample: None, + }, + }; + } + + if source == AdmissionSource::DirectAuthenticated + || current.is_some_and(|current| { + matches!( + check_within_maximum(current, candidate), + MaximumPolicyCheck::WithinMax + ) + }) + { + return AdmissionDecision::Apply; + } + + if mode == PermissionMode::Ask { + return AdmissionDecision::Ask { + reason: "managed permission mode requires approval".to_owned(), + counterexample: None, + }; + } + + match check_within_auto_eligible_maximum(boundary.maximum, requested_delta) { + MaximumPolicyCheck::WithinMax => AdmissionDecision::Apply, + MaximumPolicyCheck::ExceedsMax { counterexample } => AdmissionDecision::Ask { + reason: "requested authority is inside the maximum but requires review".to_owned(), + counterexample: Some(*counterexample), + }, + MaximumPolicyCheck::Unsupported { reason } => AdmissionDecision::Reject { + reason, + counterexample: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_prover::policy::parse_policy_str; + + fn policy(rule: &str) -> PolicyModel { + parse_policy_str(&format!( + r"version: 1 +network_policies: + github: + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + rules: +{rule} + binaries: + - path: /usr/bin/gh +" + )) + .expect("policy should parse") + } + + fn boundary(maximum: &PolicyModel) -> ManagedBoundary<'_> { + ManagedBoundary { + id: "engineering", + version: 7, + maximum, + allowed_modes: &[PermissionMode::Ask, PermissionMode::Auto], + } + } + + #[test] + fn managed_document_parses_policy_and_metadata_together() { + let config = ManagedPolicyConfig::parse( + r"version: 1 +metadata: + policy_id: engineering + version: 3 + allowed_modes: [ask, auto] + default_mode: auto + audit_label: corp-dev +network_policies: + github: + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - path: /usr/bin/gh +", + ) + .expect("managed document should parse"); + assert_eq!(config.id, "engineering"); + assert_eq!(config.version, 3); + assert_eq!(config.default_mode, PermissionMode::Auto); + assert_eq!(config.audit_label, "corp-dev"); + assert_eq!(config.boundary().allowed_modes.len(), 2); + } + + #[test] + fn managed_document_rejects_default_mode_outside_allowed_modes() { + let error = ManagedPolicyConfig::parse( + r"version: 1 +metadata: + policy_id: engineering + version: 1 + allowed_modes: [ask] + default_mode: auto +", + ) + .err() + .expect("invalid default should fail"); + assert!(error.contains("default_mode")); + } + + #[test] + fn checked_in_managed_maximum_examples_are_supported() { + ManagedPolicyConfig::parse(include_str!( + "../../../examples/managed-maximum-policies/github-rest.yaml" + )) + .expect("checked-in REST example should be supported"); + } + + #[test] + fn absent_boundary_preserves_unmanaged_behavior() { + let candidate = policy(" - allow: { method: GET, path: /repos/** }"); + assert_eq!( + admit( + None, + PermissionMode::Auto, + AdmissionSource::AgentProposal, + None, + &candidate, + &candidate, + ), + AdmissionDecision::Unmanaged + ); + } + + #[test] + fn outside_maximum_rejects_before_mode_logic() { + let maximum = policy(" - allow: { method: GET, path: /repos/acme/** }"); + let candidate = policy(" - allow: { method: DELETE, path: /repos/acme/project }"); + assert!(matches!( + admit( + Some(&boundary(&maximum)), + PermissionMode::Ask, + AdmissionSource::AgentProposal, + None, + &candidate, + &candidate, + ), + AdmissionDecision::Reject { + counterexample: Some(_), + .. + } + )); + } + + #[test] + fn ask_mode_holds_in_maximum_agent_grant() { + let maximum = policy(" - allow: { method: GET, path: /repos/** }"); + let candidate = policy(" - allow: { method: GET, path: /repos/acme/project }"); + assert!(matches!( + admit( + Some(&boundary(&maximum)), + PermissionMode::Ask, + AdmissionSource::AgentProposal, + None, + &candidate, + &candidate, + ), + AdmissionDecision::Ask { .. } + )); + } + + #[test] + fn auto_mode_applies_auto_eligible_agent_grant() { + let maximum = policy(" - allow: { method: GET, path: /repos/** }"); + let candidate = policy(" - allow: { method: GET, path: /repos/acme/project }"); + assert_eq!( + admit( + Some(&boundary(&maximum)), + PermissionMode::Auto, + AdmissionSource::AgentProposal, + None, + &candidate, + &candidate, + ), + AdmissionDecision::Apply + ); + } + + #[test] + fn review_required_agent_grant_asks_in_auto_mode() { + let maximum = policy( + " - allow:\n method: POST\n path: /repos/*/pulls\n review: { required: true }", + ); + let candidate = policy(" - allow: { method: POST, path: /repos/acme/pulls }"); + assert!(matches!( + admit( + Some(&boundary(&maximum)), + PermissionMode::Auto, + AdmissionSource::AgentProposal, + None, + &candidate, + &candidate, + ), + AdmissionDecision::Ask { + counterexample: Some(_), + .. + } + )); + } + + #[test] + fn review_required_starting_authority_rejects() { + let maximum = policy( + " - allow:\n method: POST\n path: /repos/*/pulls\n review: { required: true }", + ); + let candidate = policy(" - allow: { method: POST, path: /repos/acme/pulls }"); + assert!(matches!( + admit( + Some(&boundary(&maximum)), + PermissionMode::Auto, + AdmissionSource::SandboxCreate, + None, + &candidate, + &candidate, + ), + AdmissionDecision::Reject { .. } + )); + } + + #[test] + fn direct_authenticated_edit_is_approval_of_exact_request() { + let maximum = policy( + " - allow:\n method: POST\n path: /repos/*/pulls\n review: { required: true }", + ); + let candidate = policy(" - allow: { method: POST, path: /repos/acme/pulls }"); + assert_eq!( + admit( + Some(&boundary(&maximum)), + PermissionMode::Ask, + AdmissionSource::DirectAuthenticated, + None, + &candidate, + &candidate, + ), + AdmissionDecision::Apply + ); + } + + #[test] + fn removal_applies_without_asking() { + let current = policy(" - allow: { method: GET, path: /repos/** }"); + let candidate = policy(" - allow: { method: GET, path: /repos/acme/** }"); + assert_eq!( + admit( + Some(&boundary(¤t)), + PermissionMode::Ask, + AdmissionSource::AgentProposal, + Some(¤t), + &candidate, + &candidate, + ), + AdmissionDecision::Apply + ); + } +} diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 3934c8af42..b0f0c262ad 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -52,6 +52,27 @@ pub struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn set_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn get_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + + async fn delete_managed_maximum_policy( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test")) + } + async fn health( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index bd94d151e5..d7a214d348 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -48,6 +48,27 @@ struct RelayGateway { #[tonic::async_trait] impl OpenShell for RelayGateway { + async fn set_managed_maximum_policy( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn get_managed_maximum_policy( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn delete_managed_maximum_policy( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + type RelayStreamStream = std::pin::Pin< Box> + Send + 'static>, >; diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 3cbc315026..334eb10dd1 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -782,7 +782,7 @@ async fn fetch_chunk_or_404( /// next on the redraft loop — identity (`chunk_id`, `status`), the proposal /// it submitted (`rule_name`, `binary`), the two feedback signals /// (`rejection_reason` from the reviewer, `validation_result` from the -/// gateway prover), and (on /wait) `policy_reloaded` so the agent can tell +/// gateway admission or advisory context), and (on /wait) `policy_reloaded` so the agent can tell /// "approved AND the new rule is loaded — safe to retry" from "approved /// but the supervisor hasn't reloaded yet — re-issue /wait or surface to /// user". Display-only proto fields (`hit_count`, `confidence`, `stage`, diff --git a/crates/openshell-supervisor-process/src/skills/policy_advisor.md b/crates/openshell-supervisor-process/src/skills/policy_advisor.md index 4b7074f6dd..92360341fc 100644 --- a/crates/openshell-supervisor-process/src/skills/policy_advisor.md +++ b/crates/openshell-supervisor-process/src/skills/policy_advisor.md @@ -27,8 +27,8 @@ The sandbox-local policy API is reachable at `http://policy.local`: - `GET /v1/proposals/{chunk_id}` — immediate state of one proposal. Returns `status` (`pending` / `approved` / `rejected`), `rejection_reason` (the reviewer's free-form note, only set on reject), - and `validation_result` (the gateway prover's verdict on this chunk; - may be empty). + and `validation_result` (the gateway's managed-admission decision or a + non-blocking advisory; may be empty). - `GET /v1/proposals/{chunk_id}/wait?timeout=` — block on this proposal until the developer decides or the timeout expires. Default 60s, clamped [1, 300]. On timeout you get `status: "pending"` plus @@ -46,12 +46,9 @@ operations. Each `addRule` carries a complete narrow `NetworkPolicyRule`. `port`, `binary`, `rule_missing`, and `detail` as evidence. 2. Fetch the current policy from `/v1/policy/current`. 3. Fetch recent denials from `/v1/denials` if the response body is incomplete. -4. Prefer L7 REST rules for REST APIs. **Proposals against hosts where no - credential is in scope auto-approve** (see Auto-approval below). Any - credentialed reach or capability change goes to human review — that is - the design. L7 is still the agent-speed path because the prover can - precisely describe the change (which method was added on which path); - L4 to a credentialed host loses that precision. Use L4 only when the +4. Prefer L7 REST rules for REST APIs. L7 lets the gateway bound authority by + method and path. Raw L4 access to a credential target gives the binary the + whole host/port authority and produces an advisory. Use L4 only when the binary's wire protocol is opaque to L7 inspection (`ssh`, `nc`, `git-remote-http`) or the host has no documented REST surface. 5. Draft the narrowest rule: exact host, exact port, exact binary when known, @@ -76,10 +73,10 @@ operations. Each `addRule` carries a complete narrow `NetworkPolicyRule`. blind; do not loop tightly. - `status: "rejected"` — read `rejection_reason` and `validation_result`. `rejection_reason` is what the reviewer typed; - `validation_result` is the prover's verdict, which often explains - a reject driven by automated checks. If either has content, address - the specific feedback and submit a revised proposal. If both are - empty, draft something materially different or ask the user. + `validation_result` records gateway admission context or an advisory. + If either has content, address the specific feedback and submit a + revised proposal. If both are empty, draft something materially + different or ask the user. - `status: "pending"` with `timed_out: true` — call `/wait` again. - Any non-2xx response — surface to the user; do not retry the denied action without approval. @@ -125,65 +122,32 @@ A complete narrow REST-inspected rule looks like this: } ``` -## Auto-approval - -Auto-approval is opt-in via the `proposal_approval_mode` setting, -managed through the standard settings model. Reviewers set it at the -gateway scope (fleet-wide) with `openshell settings set --global -proposal_approval_mode auto` or at the sandbox scope with `openshell -settings set proposal_approval_mode auto`. The CLI's `openshell -sandbox create --approval-mode auto` is a shorthand that writes the -sandbox-scoped setting at create time. Gateway scope wins when both are -set; the default (no setting) is `"manual"`. - -When auto-approval is enabled and the prover finds nothing new, the -gateway approves the chunk with actor `system:auto` and the -`CONFIG:APPROVED` audit event carries `auto=true`, `source=`, -`prover_delta=empty`, and `resolved_from=`. The -agent's `/wait` returns approved in ~1 second. When the prover does -find something — or the setting is `"manual"`/unset — the chunk lands -in `pending` for human review. - -The prover answers four formal questions about each proposed change. -Each "yes" answer is its own categorical finding — there is no -severity grade. Any finding blocks auto-approval. - -- **`link_local_reach`** — the proposal grants reach to a link-local IP - range (`169.254.0.0/16`, `fe80::/10`) or a known metadata hostname - such as `metadata.google.internal`. Cloud metadata endpoints like - `169.254.169.254` live here. **Never** propose access to these — - these endpoints serve credentials regardless of what the sandbox - itself holds. -- **`l7_bypass_credentialed`** — the proposal lets a binary using a - wire protocol the L7 proxy cannot inspect (`/usr/bin/git`, - `/usr/lib/git-core/git-remote-http`, `/usr/bin/ssh`, `/usr/bin/nc`) - reach a host where a sandbox credential is in scope. Wire protocols - opaque to L7 are unbounded by L7 scoping; the reviewer must decide - whether to trust the binary with the credential. -- **`credential_reach_expansion`** — the proposal grants a binary - credentialed reach to a (host, port) it could not reach before. New - authenticated reach is a stated intent change — the reviewer - confirms whether the binary should be able to authenticate to the - host at all. -- **`capability_expansion`** — the proposal adds a new HTTP method on - a (binary, host, port) that already had credentialed reach. The - reviewer sees exactly which method was added and decides if it's - part of the agent's task. Mutating methods (PUT, POST, PATCH, - DELETE) are typical sources of this finding. - -What auto-approves (under `auto` mode): - -- Proposals where the prover finds zero of the four categories — for - example, L7 rules against hosts with no credential in scope - (public-content fetches from CDNs, schema URLs, public API - discovery). - -If your proposal escalates and you'd like it to auto-approve, look -first at whether the host actually needs a credentialed binary. A -public-content GET often doesn't, and switching to a different host -(or removing the credential dependency) makes the finding go away. -Credentialed mutations are *supposed* to escalate — propose the -narrow rule and wait for review. +## Admission and review + +The gateway has one enforcement decision surface: an optional managed maximum +policy. The sandbox's immutable managed permission mode controls proposals +inside that boundary: + +- Without a managed maximum, every accepted proposal remains `pending` for a + developer. +- In managed `ask` mode, every proposal inside the maximum remains `pending`. +- In managed `auto` mode, auto-eligible authority inside the maximum is + applied; authority marked `review.required` remains `pending`. +- Authority outside the maximum, explicitly denied, always blocked, or not + supported by managed containment is rejected at submission. + +The gateway rechecks the live policy and provider state before applying an +approved proposal. A stored decision never authorizes a stale candidate. +Managed automatic application emits `CONFIG:APPROVED` with `auto=true`, the +proposal source, and `approval_basis=managed_maximum`. + +Raw L4 authority to a host targeted by an attached provider credential adds a +non-blocking `advisory: credentialed raw L4 authority ...` message. The +advisory explains that OpenShell cannot constrain the connection by HTTP +method or path. It does not approve, reject, or override the managed maximum; +use the narrowest L7 rule available. Never propose loopback, link-local, +unspecified, or known metadata endpoints: the gateway rejects those as +always-blocked network authority. ## Refining an earlier auto-suggested rule diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 7992666d38..fb4c319bbb 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1360,6 +1360,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { ..Default::default() }), labels: HashMap::new(), + permission_mode: String::new(), }; let sandbox_name = diff --git a/crates/openshell-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs index 5fd11cb997..510f154219 100644 --- a/crates/openshell-tui/src/ui/sandbox_draft.rs +++ b/crates/openshell-tui/src/ui/sandbox_draft.rs @@ -479,59 +479,29 @@ fn approval_annotation(chunk: &PolicyChunk) -> Option { return None; } - if validation == "prover: no new findings" { - if chunk.status == "approved" { - return Some(ApprovalAnnotation { - kind: ApprovalAnnotationKind::AutoApproved, - short_label: "auto-approved".to_string(), - detail_label: "proposal was auto-approved; no additional risk detected".to_string(), - }); - } - + if validation.starts_with("managed: apply:") && chunk.status == "approved" { return Some(ApprovalAnnotation { - kind: ApprovalAnnotationKind::RequiresReview, - short_label: "review required".to_string(), - detail_label: "rule requires review; no additional risk detected".to_string(), + kind: ApprovalAnnotationKind::AutoApproved, + short_label: "auto-applied".to_string(), + detail_label: validation.to_string(), }); } - let issues = validation_issue_summary(validation); if chunk.status == "approved" { return Some(ApprovalAnnotation { kind: ApprovalAnnotationKind::Reviewed, short_label: "reviewed".to_string(), - detail_label: format!("rule was approved after review; possible issues: {issues}"), + detail_label: validation.to_string(), }); } Some(ApprovalAnnotation { kind: ApprovalAnnotationKind::RequiresReview, short_label: "review required".to_string(), - detail_label: format!( - "rule was not auto-approved and requires review; possible issues: {issues}" - ), + detail_label: validation.to_string(), }) } -fn validation_issue_summary(validation: &str) -> String { - let mut issues = Vec::new(); - for line in validation.lines().skip(1) { - let Some((category, _)) = line.trim().split_once(':') else { - continue; - }; - let label = category.trim().replace('_', " "); - if !label.is_empty() && !issues.contains(&label) { - issues.push(label); - } - } - - if issues.is_empty() { - validation.lines().next().unwrap_or(validation).to_string() - } else { - issues.join(", ") - } -} - fn format_endpoint_summary(endpoint: &NetworkEndpoint) -> String { let host_port = if endpoint.port > 0 { format!("{}:{}", endpoint.host, endpoint.port) diff --git a/deploy/docker/Dockerfile.cli-macos b/deploy/docker/Dockerfile.cli-macos index 7dce3a63db..ee6aa88ad0 100644 --- a/deploy/docker/Dockerfile.cli-macos +++ b/deploy/docker/Dockerfile.cli-macos @@ -70,34 +70,31 @@ COPY crates/openshell-core/Cargo.toml crates/openshell-core/Cargo.toml COPY crates/openshell-policy/Cargo.toml crates/openshell-policy/Cargo.toml COPY crates/openshell-providers/Cargo.toml crates/openshell-providers/Cargo.toml COPY crates/openshell-tui/Cargo.toml crates/openshell-tui/Cargo.toml -COPY crates/openshell-prover/Cargo.toml crates/openshell-prover/Cargo.toml COPY crates/openshell-core/build.rs crates/openshell-core/build.rs COPY proto/ proto/ # Scope workspace to CLI crates only — avoids compiling aws-lc-sys (pulled # by russh in openshell-sandbox/openshell-server) which is difficult to # cross-compile and unnecessary for the CLI binary. -RUN sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-cli", "crates/openshell-core", "crates/openshell-bootstrap", "crates/openshell-policy", "crates/openshell-prover", "crates/openshell-providers", "crates/openshell-tui"]|' Cargo.toml +RUN sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-cli", "crates/openshell-core", "crates/openshell-bootstrap", "crates/openshell-policy", "crates/openshell-providers", "crates/openshell-tui"]|' Cargo.toml RUN mkdir -p crates/openshell-cli/src \ crates/openshell-core/src \ crates/openshell-bootstrap/src \ crates/openshell-policy/src \ crates/openshell-providers/src \ - crates/openshell-prover/src \ crates/openshell-tui/src && \ echo "fn main() {}" > crates/openshell-cli/src/main.rs && \ touch crates/openshell-core/src/lib.rs && \ touch crates/openshell-bootstrap/src/lib.rs && \ touch crates/openshell-policy/src/lib.rs && \ touch crates/openshell-providers/src/lib.rs && \ - touch crates/openshell-prover/src/lib.rs && \ touch crates/openshell-tui/src/lib.rs RUN --mount=type=cache,id=cargo-registry-cli-macos,sharing=locked,target=/root/.cargo/registry \ --mount=type=cache,id=cargo-git-cli-macos,sharing=locked,target=/root/.cargo/git \ --mount=type=cache,id=cargo-target-cli-macos-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - cargo build --release --target aarch64-apple-darwin -p openshell-cli --features bundled-z3 2>/dev/null || true + cargo build --release --target aarch64-apple-darwin -p openshell-cli 2>/dev/null || true # --------------------------------------------------------------------------- # Stage 2: real build @@ -126,7 +123,7 @@ RUN --mount=type=cache,id=cargo-registry-cli-macos,sharing=locked,target=/root/. if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ fi && \ - cargo build --release --target aarch64-apple-darwin -p openshell-cli --features bundled-z3 && \ + cargo build --release --target aarch64-apple-darwin -p openshell-cli && \ cp target/aarch64-apple-darwin/release/openshell /openshell FROM scratch AS binary diff --git a/deploy/docker/Dockerfile.python-wheels b/deploy/docker/Dockerfile.python-wheels index e93bf8f22e..fe58e3fcc7 100644 --- a/deploy/docker/Dockerfile.python-wheels +++ b/deploy/docker/Dockerfile.python-wheels @@ -75,7 +75,7 @@ RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=loc --mount=type=cache,id=cargo-git-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ --mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ --mount=type=cache,id=sccache-python-wheels-${TARGETARCH},sharing=locked,target=/tmp/sccache \ - . cross-build.sh && cargo_cross_build --release -p openshell-cli --features bundled-z3 2>/dev/null || true + . cross-build.sh && cargo_cross_build --release -p openshell-cli 2>/dev/null || true # Copy actual source code and Python packaging files. COPY crates/ crates/ @@ -109,7 +109,7 @@ RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=loc if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ fi && \ - maturin build --release --target "${CARGO_BUILD_TARGET}" --features bundled-z3 --out /wheels + maturin build --release --target "${CARGO_BUILD_TARGET}" --out /wheels FROM scratch AS wheels COPY --from=builder /wheels/*.whl / diff --git a/deploy/docker/Dockerfile.python-wheels-macos b/deploy/docker/Dockerfile.python-wheels-macos index 45194b0690..15ba58e0e8 100644 --- a/deploy/docker/Dockerfile.python-wheels-macos +++ b/deploy/docker/Dockerfile.python-wheels-macos @@ -88,7 +88,7 @@ RUN mkdir -p crates/openshell-cli/src crates/openshell-core/src crates/openshell RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ --mount=type=cache,id=cargo-git-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ --mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - cargo build --release --target aarch64-apple-darwin -p openshell-cli --features bundled-z3 2>/dev/null || true + cargo build --release --target aarch64-apple-darwin -p openshell-cli 2>/dev/null || true # Copy actual source code and Python packaging files. COPY crates/ crates/ @@ -120,7 +120,7 @@ RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},shari if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ fi && \ - maturin build --release --target aarch64-apple-darwin --features bundled-z3 --out /wheels + maturin build --release --target aarch64-apple-darwin --out /wheels FROM scratch AS wheels COPY --from=builder /wheels/*.whl / diff --git a/deploy/man/openshell.1.md b/deploy/man/openshell.1.md index 6ba6f4afb7..1d57318ca9 100644 --- a/deploy/man/openshell.1.md +++ b/deploy/man/openshell.1.md @@ -94,9 +94,6 @@ development task, or behind a cloud reverse proxy. **policy list** *SANDBOX* : Show policy revision history. -**policy prove** **--policy** *FILE* \[**--credentials** *FILE*\] -: Verify policy properties. - ## Provider Management **provider create** **--name** *NAME* **--type** *TYPE* \[**--from-existing**\] \[**--credential** *KEY=VALUE*\] diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 049ad47979..e80ae8902c 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -32,6 +32,36 @@ network_policies: { ... } Static fields are set at sandbox creation time. Changing them requires destroying and recreating the sandbox. Dynamic fields can be updated on a running sandbox with `openshell policy update` for incremental merges or `openshell policy set` for full replacement, and take effect without restarting. +## Managed Maximum Extensions + +`openshell policy maximum set` accepts the same policy shape with one additional top-level +`metadata` block. These fields identify a gateway-owned ceiling and are not valid in ordinary +sandbox policy files. + +| Field | Type | Required | Description | +|---|---|---|---| +| `metadata.policy_id` | string | Yes | Stable administrator-chosen identity for the maximum. | +| `metadata.version` | integer | Yes | Positive version. Replacing the same policy ID requires a larger version. | +| `metadata.allowed_modes` | list | Yes | Non-empty set containing `ask`, `auto`, or both. | +| `metadata.default_mode` | string | Yes | Mode used when sandbox creation omits `--permission-mode`; must appear in `allowed_modes`. | +| `metadata.audit_label` | string | No | Operator label copied into managed admission audit events. | + +An endpoint or an allow payload can include a managed-only review annotation: + +```yaml showLineNumbers={false} +review: + required: true + reason: "write changes external state" +``` + +Endpoint-level review applies to all authority on that endpoint. Allow-level review applies only to +the matching REST method/path region. Review metadata changes managed automatic-application +eligibility; it does not remove the authority from the maximum. + +The initial managed containment surface models filesystem paths, L4 host/port/binary access, and +REST method/path access. `deny_rules` remove REST authority before containment. GraphQL, MCP, and +other endpoint controls fail closed in managed mode instead of being silently ignored. + ## Version The version field identifies which schema the policy uses: diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 212ba76fce..63d7e96f53 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -73,6 +73,54 @@ User-specified paths in your policy YAML are not pre-filtered. If you list a pat This distinction means baseline system paths degrade gracefully while user-specified paths surface configuration errors. +## Managed Maximum Policies + +A gateway administrator can configure one managed maximum policy that bounds every sandbox on +the gateway. The maximum is a ceiling: it does not become the sandbox's active policy and does not +grant runtime access by itself. OpenShell proves the sandbox policy plus attached provider policy +against the maximum before creation and before any authority-changing update. + +Configure the maximum before creating sandboxes: + +```shell +openshell policy maximum set --policy ./maximum.yaml --yes +openshell policy maximum get --full +openshell sandbox create --policy ./base.yaml --permission-mode auto +``` + +The maximum uses normal policy YAML plus a top-level `metadata` block. It can also mark an endpoint +or individual allow rule with `review.required: true`. Its `allowed_modes` and `default_mode` select +how agent proposals inside the boundary behave: + +| Mode | Auto-eligible proposal | Review-required proposal | Outside or unsupported proposal | +|---|---|---|---| +| `ask` | Keep pending for a reviewer. | Keep pending for a reviewer. | Reject before persistence. | +| `auto` | Apply after live-state revalidation. | Keep pending for a reviewer. | Reject before persistence. | + +An authenticated direct policy edit is approval of that exact change, but it must still fit inside +the maximum. Sandbox creation rejects review-required starting authority. Provider attachment and +provider-derived policy are part of the same composed candidate. Credential values are never +included in decision logs. + +The initial managed containment surface supports filesystem paths plus L4 and REST network +authority. Explicit deny rules take precedence. GraphQL, MCP, and endpoint behavior that the +containment prover does not model fail closed in managed mode. On an unmanaged gateway, accepted +agent proposals always wait for human review; there is no separate unmanaged automatic mode. An +attached provider must have a resolvable policy profile so the gateway can include its +credential-bearing reach in the proof. + +The maximum can be replaced or deleted only when no sandboxes exist, and it cannot coexist with a +gateway-global policy override. This initial implementation requires single-replica SQLite storage; +the gateway rejects managed maximum configuration with PostgreSQL/HA storage until admission uses +database-backed coordination: + +```shell +openshell policy maximum delete --yes +``` + +See the runnable [managed maximum examples](https://github.com/NVIDIA/OpenShell/tree/main/examples/managed-maximum-policies) +for REST read-auto/write-review behavior. + ## Apply a Custom Policy Pass a policy YAML file when creating the sandbox: diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index 6139aafb38..462fdb72b4 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -3,14 +3,14 @@ # SPDX-License-Identifier: Apache-2.0 title: "Use Policy Advisor" sidebar-title: "Policy Advisor" -description: "Let sandboxed agents propose narrow policy changes through policy.local while keeping developer approval in the loop." +description: "Let sandboxed agents propose narrow policy changes through policy.local under human or managed-maximum admission." keywords: "Generative AI, Cybersecurity, Policy Advisor, Policy, Sandbox, policy.local, Agent Policy" position: 6 --- -Policy advisor lets a running sandboxed agent ask for a narrow network policy change after OpenShell denies a request. The agent submits a draft through `policy.local`, a developer approves or rejects it from outside the sandbox, and approved network policy hot-reloads into the same sandbox. +Policy advisor lets a running sandboxed agent ask for a narrow network policy change after OpenShell denies a request. The agent submits a draft through `policy.local`; unmanaged changes wait for a developer, while a managed maximum can apply eligible changes automatically. Approved network policy hot-reloads into the same sandbox. -Policy advisor preserves OpenShell's default-deny posture. The structured rule is the approval contract, and the agent's rationale is supporting context. By default every proposal lands in the draft inbox for human review. Opt-in [auto mode](#approval-modes) lets the gateway approve provably safe proposals — those whose [prover delta](#what-auto-approval-checks) is empty — without a reviewer in the loop; proposals with any prover finding still require human approval. +Policy advisor preserves OpenShell's default-deny posture. The structured rule is the approval contract, and the agent's rationale is supporting context. Without a managed maximum policy, every accepted proposal lands in the draft inbox for human review. A gateway-managed maximum can bound proposals and allow eligible changes to apply automatically without creating a second security decision surface. ## Enable Policy Advisor @@ -47,45 +47,27 @@ openshell settings delete --global \ Set the value before creating a sandbox when you want the first denied request to include policy advisor guidance. Running sandboxes poll settings and can enable the surface after startup, but startup enablement gives the agent the clearest first-denial path. -## Approval Modes +## Admission Modes -Every proposal — mechanistic or agent-authored — is routed through the [policy prover](#what-auto-approval-checks). The `proposal_approval_mode` setting decides what happens when the prover finds nothing to flag. +The gateway has one automatic enforcement surface: the [managed maximum policy](/sandboxes/policies#managed-maximum-policies). The sandbox's immutable managed permission mode determines what happens to a proposal inside that boundary: -| Mode | When unset / `manual` | `auto` | +| Gateway state | Sandbox mode | Proposal behavior | |---|---|---| -| Empty prover delta | Lands in the draft inbox for human review. | Approved automatically; the sandbox hot-reloads the new rule and the agent retries. | -| Any prover finding | Lands in the draft inbox. | Lands in the draft inbox — auto-approval is gated on an empty delta. | +| No managed maximum | Unmanaged | Every accepted proposal waits for human review. | +| Managed maximum | `ask` | Every proposal inside the maximum waits for human review. | +| Managed maximum | `auto` | Auto-eligible authority applies immediately; `review.required` authority waits for human review. | -`manual` is the default. Auto mode is an explicit opt-in; OpenShell's default-deny posture is preserved unless you choose otherwise. - -Enable auto mode at gateway scope when you want every sandbox on this gateway to auto-approve safe proposals: - -```shell -openshell settings set --global \ - --key proposal_approval_mode \ - --value auto \ - --yes -``` - -Enable it for one sandbox when no global value is set: +In managed mode, authority outside the maximum, explicitly denied authority, always-blocked network targets, and authority the containment model does not support are rejected before entering the draft queue. Select a managed mode at sandbox creation: ```shell -openshell settings set \ - --key proposal_approval_mode \ - --value auto +openshell sandbox create --policy ./base.yaml --permission-mode auto ``` -The shorthand at create time writes the sandbox-scoped setting for you: - -```shell -openshell sandbox create --approval-mode auto -``` +Omit `--permission-mode` to use the maximum's default. If a local developer wants a hands-off workflow, they can install an explicit broad maximum and use `auto`; OpenShell does not provide a separate unmanaged auto-approval path. -Only `manual` and `auto` are accepted; typos like `autom` are rejected at configure time. Stale or unknown values found in storage are still treated as `manual` at runtime as a defense-in-depth measure. +OpenShell reruns containment against the live sandbox policy and provider state immediately before an automatically or manually approved proposal is merged. The stored proposal and earlier decision explain what happened; they do not authorize a stale candidate. -**Precedence.** Gateway scope wins over sandbox scope. A reviewer can pin `manual` for a fleet by setting it globally; per-sandbox overrides only apply when no global value is set. - -**Audit trail.** Every auto-approval emits a `CONFIG:APPROVED` event with `auto=true`, `source=`, `prover_delta=empty`, and `resolved_from=` so operators can reconstruct why a given approval ran without human review. +Every managed automatic application emits a `CONFIG:APPROVED` event with `auto=true`, `source=`, and `approval_basis=managed_maximum`. ## How It Works @@ -102,11 +84,11 @@ The loop has seven steps: 2. For inspected REST traffic, OpenShell returns a structured `403` body with fields such as `layer`, `host`, `port`, `binary`, `method`, `path`, `rule_missing`, `agent_guidance`, and `next_steps`. 3. The agent reads the policy advisor skill, inspects the current policy, and optionally reads recent denial log lines. 4. The agent submits one or more `addRule` proposals to `http://policy.local/v1/proposals`. -5. The gateway stores accepted proposals as pending draft chunks for the sandbox and runs the [policy prover](#what-auto-approval-checks) against the proposed delta. -6. Under `auto` mode, proposals with an empty prover delta are approved immediately and skipped past human review. Under `manual` mode (the default), every proposal — and under `auto` mode, every proposal with a prover finding — lands in the draft inbox for a developer to approve or reject. +5. The gateway rejects always-blocked targets and runs managed-maximum admission when a maximum is configured. It may attach the [credentialed raw-L4 advisory](#credentialed-raw-l4-advisory), but that warning never decides admission. +6. Unmanaged and managed `ask` proposals land in the draft inbox. Managed `auto` applies auto-eligible proposals, keeps review-required proposals pending, and rejects proposals outside the maximum. 7. The agent waits on `/v1/proposals/{chunk_id}/wait` until a decision is available. Approved proposals hot-reload into the sandbox; rejected proposals return `rejection_reason` and `validation_result` so the agent can revise. -When a proposal is approved, `/wait` reports `policy_reloaded: true` only after the local sandbox policy covers the approved rule. At that point the agent can retry the original denied action once. If a proposal is rejected, `/wait` returns `rejection_reason` and `validation_result` so the agent can revise or stop. `validation_result` carries the categorical prover findings — `link_local_reach`, `l7_bypass_credentialed`, `credential_reach_expansion`, `capability_expansion` — so the agent can narrow the next attempt to the specific concern the prover flagged. +When a proposal is approved, `/wait` reports `policy_reloaded: true` only after the local sandbox policy covers the approved rule. At that point the agent can retry the original denied action once. If a proposal is rejected, `/wait` returns `rejection_reason` and `validation_result` so the agent can revise or stop. `validation_result` records managed-admission context and any advisory attached to the proposal. ## What Gets Proposed @@ -160,20 +142,13 @@ The current `policy.local` JSON shape covers L4 endpoints and REST method or pat Policy advisor proposals do not add `allowed_ips` automatically. If an advisor-proposed hostname resolves to an internal or private address, OpenShell's SSRF protections still block the connection until a developer explicitly adds the required `allowed_ips` entry. Exact hostname trust for user-declared policy endpoints does not apply to advisor-generated proposal binaries. -## What Auto-Approval Checks - -The policy prover runs against every proposal — mechanistic and agent-authored alike — and asks four formal questions about the proposed change. Each "yes" is one categorical finding. Any finding blocks auto-approval; only an empty delta is eligible. +## Credentialed Raw-L4 Advisory -| Category | Triggered when | -|---|---| -| `link_local_reach` | The proposal reaches a host in `169.254.0.0/16`, `fe80::/10`, or a known metadata hostname such as `metadata.google.internal` (cloud-metadata territory, which serves credentials regardless of sandbox state). Unconditional. | -| `l7_bypass_credentialed` | A binary using a wire protocol the L7 proxy cannot inspect (`git-remote-https`, `ssh`, `nc`) gains reach to a host where a credential is in scope. | -| `credential_reach_expansion` | A binary gains credentialed reach to a `(host, port)` it could not reach before. | -| `capability_expansion` | On a `(binary, host, port)` that already had credentialed reach, the proposal adds a new HTTP method. The finding cites the specific method. | +A proposal for a raw L4 endpoint has no HTTP method or path boundary. When that endpoint overlaps a host targeted by an attached provider credential, OpenShell adds a deterministic advisory to `validation_result`, including the binary, host, and port. -Findings are categorical — there is no severity tier. The reviewer reads the category and the structured evidence to decide. When the prover delta is empty, the proposal is provably safe under the model and auto-approval (if enabled) can fire. +The advisory is evidence for the policy author, not an enforcement primitive. It never approves, rejects, or overrides managed-maximum admission. Prefer an enforced L7 rule when the protocol supports one. If opaque protocol access is intentional, review the raw L4 authority as granting the named binary the full host and port. -The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Providers v2](/sandboxes/providers-v2) are part of the effective policy the prover reasons over. +Loopback, link-local, unspecified, and known metadata targets are different: OpenShell rejects them as always-blocked authority rather than reporting an advisory. Managed maximum containment decides capability expansion and credential-bearing reach through the same maximum-policy boundary used for every other authority change. ## Review Proposals @@ -183,7 +158,7 @@ Review pending chunks from the host: openshell rule get --status pending ``` -Under `auto` mode, only proposals the prover flagged appear here; empty-delta proposals are already approved and visible under `--status approved` with the auto-approval audit fields described in [Approval Modes](#approval-modes). Under `manual` mode, every proposal — regardless of prover verdict — shows up as pending. +Unmanaged and managed `ask` proposals appear here. In managed `auto`, review-required proposals appear here while auto-eligible proposals are already visible under `--status approved`. The output shows the chunk ID, status, rationale, binary, and endpoint summary. For L7 proposals, the endpoint summary includes the protocol, method, and path: @@ -231,7 +206,7 @@ Policy advisor emits audit events into the sandbox log. Use these lines to trace openshell logs --since 10m ``` -Look for `HTTP:* DENIED`, `CONFIG:PROPOSED`, `CONFIG:APPROVED` or `CONFIG:REJECTED`, `CONFIG:LOADED`, and the final allowed request if the agent retries successfully. Auto-approved chunks emit `CONFIG:APPROVED` with `auto=true`, `source=`, `prover_delta=empty`, and `resolved_from=`. +Look for `HTTP:* DENIED`, `CONFIG:PROPOSED`, `CONFIG:APPROVED` or `CONFIG:REJECTED`, `CONFIG:LOADED`, and the final allowed request if the agent retries successfully. Managed automatic applications emit `CONFIG:APPROVED` with `auto=true`, `source=`, and `approval_basis=managed_maximum`. ## Next Steps diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 8ccb7859e9..645b5b2474 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -74,7 +74,7 @@ The following Providers v2 design items are not part of the current behavior: | Automatic credential scope extraction | OpenShell does not yet inspect upstream provider responses to discover credential scopes. | | Inference mounting from attached providers | `inference_capable` is profile metadata. Attaching an inference-capable provider does not yet create `inference.local` routes. | | Multi-provider inference routing | Path-based routing such as `inference.local/openai/...` and `inference.local/anthropic/...` is not yet wired to provider profiles. | -| Policy prover integration | OpenShell does not yet run the policy prover automatically on sandbox startup or block startup based on prover findings. | +| Multiple managed policy inputs | Managed admission composes attached provider policy into each candidate and checks it against one gateway-managed maximum. Selecting and intersecting multiple maximums for agents and subagents is future work. | | Refresh telemetry as OCSF events | Credential refresh logs are secret-safe gateway logs. OCSF refresh events and metrics are future work. | Use [Inference Routing](/sandboxes/inference-routing) for the current `inference.local` model. diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 467d419f02..6b04a038f5 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -124,8 +124,8 @@ GATEWAY_PID_FILE="${RUN_STATE_DIR}/gateway.pid" GATEWAY_ARGS_FILE="${RUN_STATE_DIR}/gateway.args" GATEWAY_CONFIG="${RUN_STATE_DIR}/gateway.toml" GATEWAY_DB="${RUN_STATE_DIR}/gateway.db" -JWT_DIR="${RUN_STATE_DIR}/jwt" PKI_DIR="${RUN_STATE_DIR}/pki" +JWT_DIR="${PKI_DIR}/jwt" GATEWAY_NAME="openshell-e2e-vm-${HOST_PORT}" # ── Cleanup (trap) ─────────────────────────────────────────────────── @@ -194,7 +194,6 @@ echo "==> Starting openshell-gateway on 127.0.0.1:${HOST_PORT} (state: ${RUN_STA # host-loopback proxy carries the connection while keeping the endpoint aligned # with package-managed gateway certificates. gvproxy's bare gateway IP # (192.168.127.1) does NOT forward arbitrary host ports. -e2e_generate_gateway_jwt "${JWT_DIR}" e2e_generate_pki "${GATEWAY_BIN}" "${PKI_DIR}" cat >"${GATEWAY_CONFIG}" < (bool, String) { + let output = openshell_cmd() + .args(args) + .output() + .await + .expect("spawn openshell"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + (output.status.success(), strip_ansi(&combined)) +} + +fn temp_policy(contents: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().expect("create policy file"); + file.write_all(contents.as_bytes()).expect("write policy"); + file.flush().expect("flush policy"); + file +} + +fn json_from_exec(output: &str) -> Value { + let json = output + .lines() + .find(|line| line.trim_start().starts_with('{')) + .unwrap_or_else(|| panic!("sandbox output did not contain JSON:\n{output}")); + serde_json::from_str(json).expect("parse policy.local response") +} + +async fn submit(sandbox: &SandboxGuard, method: &str, suffix: &str) -> Value { + let script = r#"import json, sys, urllib.request, urllib.error +method, suffix = sys.argv[1], sys.argv[2] +payload = { + "intent_summary": "managed maximum e2e", + "operations": [{"addRule": { + "ruleName": f"managed_{suffix}", + "rule": { + "name": f"managed_{suffix}", + "endpoints": [{ + "host": "api.github.com", + "port": 443, + "protocol": "rest", + "enforcement": "enforce", + "rules": [{"allow": {"method": method, "path": f"/repos/acme/{suffix}"}}] + }], + "binaries": [{"path": "/usr/bin/curl"}] + } + }}] +} +request = urllib.request.Request( + "http://policy.local/v1/proposals", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST") +try: + response = urllib.request.urlopen(request) + print(response.read().decode()) +except urllib.error.HTTPError as error: + print(error.read().decode()) + raise +"#; + let invocation = format!("exec({script:?})"); + let output = sandbox + .exec(&["python3", "-c", &invocation, method, suffix]) + .await + .expect("submit policy.local proposal"); + json_from_exec(&output) +} + +async fn wait_for_proposal(sandbox: &SandboxGuard, chunk_id: &str) -> Value { + let script = r#"import sys, urllib.request +url = f"http://policy.local/v1/proposals/{sys.argv[1]}/wait?timeout=30" +print(urllib.request.urlopen(url).read().decode()) +"#; + let invocation = format!("exec({script:?})"); + let output = sandbox + .exec(&["python3", "-c", &invocation, chunk_id]) + .await + .expect("wait for proposal"); + json_from_exec(&output) +} + +async fn wait_for_rule_status(sandbox_name: &str, chunk_id: &str, status: &str) { + let start = Instant::now(); + loop { + let (success, output) = cli(&["rule", "get", sandbox_name, "--status", status]).await; + if success && output.contains(chunk_id) { + return; + } + assert!( + start.elapsed() < Duration::from_secs(30), + "timed out waiting for chunk {chunk_id} to become {status}:\n{output}" + ); + sleep(Duration::from_secs(1)).await; + } +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn managed_maximum_auto_ask_reject_and_reload() { + assert!( + sandbox_names().await.expect("list sandboxes").is_empty(), + "managed maximum e2e requires the isolated gateway to start without sandboxes" + ); + let _cleanup = Cleanup; + + let maximum = temp_policy(include_str!( + "../../../examples/managed-maximum-policies/github-rest.yaml" + )); + let base = temp_policy( + r"version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +network_policies: {} +", + ); + let maximum_path = maximum.path().to_str().expect("maximum path is utf-8"); + let base_path = base.path().to_str().expect("base path is utf-8"); + + let (success, output) = cli(&[ + "settings", + "set", + "--global", + "--key", + "agent_policy_proposals_enabled", + "--value", + "true", + "--yes", + ]) + .await; + assert!(success, "enable policy advisor:\n{output}"); + + let (success, output) = + cli(&["policy", "maximum", "set", "--policy", maximum_path, "--yes"]).await; + assert!(success, "set managed maximum:\n{output}"); + assert!(output.contains("github-read-auto-write-review@1")); + println!("managed maximum: configured github-read-auto-write-review@1"); + + let sandbox_name = format!("managed-maximum-{}", std::process::id()); + let mut sandbox = SandboxGuard::create(&[ + "--name", + &sandbox_name, + "--policy", + base_path, + "--permission-mode", + "auto", + "--no-tty", + "--", + "sh", + "-c", + "echo Ready", + ]) + .await + .expect("create managed sandbox"); + assert!( + strip_ansi(&sandbox.create_output).contains("Permission mode: auto (managed)"), + "sandbox create did not confirm the managed permission mode:\n{}", + sandbox.create_output + ); + println!("sandbox UX: confirmed permission mode auto (managed)"); + + let read = submit(&sandbox, "GET", "read").await; + let read_id = read["accepted_chunk_ids"][0] + .as_str() + .expect("read proposal id"); + wait_for_rule_status(&sandbox.name, read_id, "approved").await; + let read_wait = wait_for_proposal(&sandbox, read_id).await; + assert_eq!(read_wait["status"], "approved"); + assert_eq!(read_wait["policy_reloaded"], true); + println!("GET proposal: auto-approved and live policy reloaded"); + + let write = submit(&sandbox, "POST", "write").await; + let write_id = write["accepted_chunk_ids"][0] + .as_str() + .expect("write proposal id"); + wait_for_rule_status(&sandbox.name, write_id, "pending").await; + let (success, output) = cli(&[ + "rule", + "approve", + &sandbox.name, + "--chunk-id", + write_id, + ]) + .await; + assert!(success, "approve reviewed write:\n{output}"); + let write_wait = wait_for_proposal(&sandbox, write_id).await; + assert_eq!(write_wait["status"], "approved"); + assert_eq!(write_wait["policy_reloaded"], true); + println!("POST proposal: held for review, approved, and live policy reloaded"); + + let denied = submit(&sandbox, "DELETE", "delete").await; + assert_eq!(denied["accepted_chunks"], 0); + assert_eq!(denied["rejected_chunks"], 1); + assert!(denied["rejection_reasons"][0] + .as_str() + .is_some_and(|reason| reason.contains("maximum=github-read-auto-write-review@1"))); + println!("DELETE proposal: rejected by the managed maximum"); + + let (success, logs) = cli(&[ + "logs", + &sandbox.name, + "--source", + "gateway", + "--since", + "10m", + ]) + .await; + assert!(success, "read gateway logs:\n{logs}"); + assert!(logs.contains("managed admission"), "missing managed audit event:\n{logs}"); + println!("audit: managed admission event visible in gateway logs"); + + sandbox.cleanup().await; + let (success, output) = cli(&["policy", "maximum", "delete", "--yes"]).await; + assert!(success, "delete managed maximum:\n{output}"); + println!("cleanup: sandbox and managed maximum deleted"); +} diff --git a/e2e/support/gateway-common.sh b/e2e/support/gateway-common.sh index a52b4c0770..ec8a606eb5 100644 --- a/e2e/support/gateway-common.sh +++ b/e2e/support/gateway-common.sh @@ -136,18 +136,6 @@ e2e_toml_string() { printf '"%s"' "${value}" } -e2e_generate_gateway_jwt() { - local jwt_dir=$1 - - mkdir -p "${jwt_dir}" - ( - umask 077 - openssl genpkey -algorithm Ed25519 -out "${jwt_dir}/signing.pem" >/dev/null 2>&1 - ) - openssl pkey -in "${jwt_dir}/signing.pem" -pubout -out "${jwt_dir}/public.pem" >/dev/null 2>&1 - openssl rand -hex 16 >"${jwt_dir}/kid" -} - e2e_write_gateway_jwt_config() { local jwt_dir=$1 local gateway_id=$2 diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 64062b74d6..c948e24ebd 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -439,7 +439,7 @@ e2e_generate_pki "${GATEWAY_BIN}" "${PKI_DIR}" HOST_PORT=$(e2e_pick_port) STATE_DIR="${XDG_STATE_HOME}" mkdir -p "${STATE_DIR}" -JWT_DIR="${STATE_DIR}/jwt" +JWT_DIR="${PKI_DIR}/jwt" GATEWAY_ENDPOINT="https://host.openshell.internal:${HOST_PORT}" E2E_NAMESPACE="e2e-docker-$$-${HOST_PORT}" @@ -459,8 +459,6 @@ fi echo "Starting openshell-gateway on port ${HOST_PORT} (namespace: ${E2E_NAMESPACE})..." echo "Using sandbox image: ${SANDBOX_IMAGE} (pull policy: ${SANDBOX_IMAGE_PULL_POLICY})" -e2e_generate_gateway_jwt "${JWT_DIR}" - # Driver-specific options moved from CLI flags into a TOML config table # (commit 560550d2). Synthesize a minimal config here and pass --config. # Quote a value as a TOML basic string: wrap in double quotes and escape diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index 77b4c1324c..75ebb87c9f 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -372,7 +372,7 @@ HEALTH_PORT=$(e2e_pick_port) STATE_DIR="${WORKDIR}/state" mkdir -p "${STATE_DIR}" export XDG_STATE_HOME="${STATE_DIR}" -JWT_DIR="${STATE_DIR}/jwt" +JWT_DIR="${PKI_DIR}/jwt" E2E_NAMESPACE="e2e-podman-$$-${HOST_PORT}" PODMAN_NETWORK_NAME="${E2E_NAMESPACE}" @@ -383,8 +383,6 @@ export OPENSHELL_E2E_NETWORK_NAME="${PODMAN_NETWORK_NAME}" export OPENSHELL_E2E_SANDBOX_NAMESPACE="${E2E_NAMESPACE}" echo "Starting openshell-gateway on port ${HOST_PORT} (namespace: ${E2E_NAMESPACE})..." -e2e_generate_gateway_jwt "${JWT_DIR}" - # Driver-specific options moved from CLI flags into a TOML config table # (commit 560550d2). Synthesize a minimal config here and pass --config. # Quote a value as a TOML basic string: see with-docker-gateway.sh for diff --git a/examples/agent-driven-policy-management/README.md b/examples/agent-driven-policy-management/README.md index a0eaa63585..a087ffa39d 100644 --- a/examples/agent-driven-policy-management/README.md +++ b/examples/agent-driven-policy-management/README.md @@ -12,15 +12,14 @@ Run the full agent-driven policy loop end-to-end: 3. The agent reads `/etc/openshell/skills/policy_advisor.md`, drafts the narrowest rule needed, and submits it to `http://policy.local/v1/proposals`. It saves the returned `chunk_id`. -4. The gateway merges the proposed rule with the current sandbox policy, runs - the policy prover, and stores a concise `validation_result` on the pending - chunk. This is deterministic control-plane evidence, not agent prose. +4. The gateway validates the proposed rule. This demo does not install a + managed maximum, so every accepted proposal remains pending for review. 5. The agent calls `GET /v1/proposals/{chunk_id}/wait?timeout=300` — a single HTTP request that the supervisor holds open until the developer decides. This is the load-bearing UX point: the agent burns zero LLM tokens while it waits; it's literally sleeping on a socket. 6. You approve the proposal from the host with one keystroke after seeing the - exact rule and the prover verdict in `openshell rule get`. + exact rule in `openshell rule get`. 7. The agent's `/wait` returns within ~1 second of the approval. The sandbox has hot-reloaded the merged policy; the agent retries the original PUT once and exits. @@ -60,7 +59,7 @@ under `openshell-policy-advisor-demo/` per run. DEMO_MANUAL_APPROVE=1 bash examples/agent-driven-policy-management/demo.sh ``` -Same flow, but the script no longer auto-approves. When the agent submits a +Same flow, but the script no longer issues approvals on your behalf. When the agent submits a proposal, the demo prints the exact `approve` and `reject --reason` commands and pauses until you run one from another terminal. This is how you'd review a coding agent's privilege ask in a real session — read the structured grant, @@ -81,7 +80,7 @@ reject with `--reason "scope to docs/ paths only"` and the agent reads | `DEMO_GITHUB_TOKEN` | falls back to `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token` | | `DEMO_KEEP_SANDBOX` | `0` (set `1` to inspect the sandbox after the demo) | | `DEMO_MANUAL_APPROVE` | `0` (set `1` to pause for host-side `rule approve` / `rule reject --reason`) | -| `DEMO_APPROVAL_TIMEOUT_SECS` | `240` (auto), `1800` (manual mode) | +| `DEMO_APPROVAL_TIMEOUT_SECS` | `240` (scripted approval), `1800` (manual approval) | | `DEMO_CODEX_MODEL` | `gpt-5.4-mini` (pinned for ChatGPT-account compatibility; override if your account supports a different model) | | `DEMO_CODEX_REASONING` | `low` (the demo task is mechanical; `medium`/`high` slow it down without changing outcomes) | | `OPENSHELL_BIN` | `target/debug/openshell` if present, else `openshell` on `PATH` | @@ -102,32 +101,24 @@ with three parts, each with a different trust level: |---|---|---| | `proposed_rule` (host, port, method, path, binary) | agent, schema-validated by the gateway | structured contract — *this* is what you're approving | | `rationale` (free-form prose) | agent | hint only — a compromised agent can lie here | -| `validation_result` (prover output) | gateway-side prover | trust signal — but this surface is in progress (see [RFC 0002](../../rfc/0002-agent-driven-policy-management/README.md)) | +| `validation_result` | gateway managed admission and advisory logic | deterministic context; empty for ordinary unmanaged L7 proposals | -The MVP today shows the structured rule plus the agent's rationale in -`openshell rule get` and the TUI inbox panel. With prover validation wired -into the gateway, `openshell rule get` also shows a `Validation:` line for -agent-authored chunks. The value is the prover's verdict in OCSF-shorthand -style — one short, scannable string per chunk: +The CLI and TUI show the structured rule plus the agent's rationale. When a +gateway-managed maximum is configured, `validation_result` records whether the +proposal was auto-eligible or required review. A raw L4 proposal to a provider +credential target also carries a non-gating advisory such as: ```text -Validation: prover: no new findings +Validation: advisory: credentialed raw L4 authority is not inspectable at L7: binary=/usr/bin/curl host=api.github.com port=443 ``` -```text -Validation: prover: 1 new finding - capability_expansion: PUT on api.github.com:443 via /usr/bin/curl -``` - -Other possible verdicts: `validation unavailable` (gateway-side prover infra -issue — surfaces in the gateway log, not as proposal failure), `merge failed: -…` (proposal won't merge into the current policy), and `policy invalid: …` -(merged policy fails the structural safety check). +That advisory does not approve or reject the proposal. The managed maximum is +the only automatic admission boundary. Loopback, link-local, unspecified, and +known metadata targets are rejected as always-blocked authority. -Read the structured rule (Endpoints + Binary). Read the Validation line. -Approve if both look right. The demo's `openshell rule approve-all` -auto-approves to keep the loop short; in a real session a developer makes -that judgment per chunk before pressing `a`. +Read the structured rule (Endpoints + Binary), rationale, and any validation +context. The demo driver runs `openshell rule approve-all` to keep the loop +short; in a real unmanaged session a developer makes that judgment per chunk. ## Going further diff --git a/examples/agent-driven-policy-management/agent-task.md b/examples/agent-driven-policy-management/agent-task.md index 69e1a4e559..1051f163aa 100644 --- a/examples/agent-driven-policy-management/agent-task.md +++ b/examples/agent-driven-policy-management/agent-task.md @@ -85,12 +85,9 @@ proposal, submit it to `http://policy.local/v1/proposals`, wait on 4. Block on the developer's decision by calling `GET http://policy.local/v1/proposals/{chunk_id}/wait?timeout=300`. - - This time the prover emits a `capability_expansion` finding: PUT - is a new method on a host the binary already had credentialed - reach to (read-only). That's a stated intent change, so the - gateway holds the chunk in `pending` for human review instead of - auto-approving. The `/wait` call still parks on a socket — zero - LLM tokens burn while the human decides. + - This sandbox is unmanaged, so the gateway holds every accepted + proposal in `pending` for human review. The `/wait` call parks on + a socket, so no LLM tokens burn while the human decides. - `status: "approved"` — retry the PUT once. Policy has hot-reloaded. - `status: "rejected"` — read `rejection_reason`. If it has text, address the specific feedback and submit a revised proposal (back diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index 1a451da384..4d4ac16e65 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -7,9 +7,8 @@ # # Shows the approval loop in one run: # deny → agent proposes narrow access → gateway validates → approve → retry. -# A public raw.githubusercontent.com GET auto-approves; the GitHub PUT waits -# for review because a GitHub credential is in scope. See README.md for the -# full walkthrough. +# This demo runs unmanaged, so every accepted proposal waits for host review. +# See README.md for the full walkthrough. set -euo pipefail @@ -40,7 +39,7 @@ DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-policy-demo-${DEM DEMO_CODEX_MODEL="${DEMO_CODEX_MODEL:-gpt-5.4-mini}" DEMO_CODEX_LOCAL_BIN="${DEMO_CODEX_LOCAL_BIN:-}" DEMO_MANUAL_APPROVE="${DEMO_MANUAL_APPROVE:-0}" -# Manual approvals need more headroom than the auto-approve loop — a human +# Manual approvals need more headroom than the scripted approval loop — a human # reads the proposal, thinks, and decides. Bump the default to 30 min when # the developer is in the loop. Explicit overrides still win. if [[ "$DEMO_MANUAL_APPROVE" == "1" ]]; then @@ -365,7 +364,7 @@ start_agent_sandbox() { "$OPENSHELL_BIN" sandbox delete "$DEMO_SANDBOX_NAME" >/dev/null 2>&1 || true info "policy: raw GitHub schema path denied; GitHub writes denied" - info "approval: auto for no new findings; review for credential risk" + info "approval: unmanaged; every proposal waits for host review" info "target: PUT /repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/${DEMO_FILE_PATH}" info "log: ${AGENT_LOG}" @@ -380,7 +379,6 @@ start_agent_sandbox() { --provider "$DEMO_CODEX_PROVIDER_NAME" \ --provider "$DEMO_GITHUB_PROVIDER_NAME" \ --policy "$POLICY_FILE" \ - --approval-mode auto \ --upload "${PAYLOAD_DIR}:/sandbox" \ --no-git-ignore \ --no-auto-providers \ @@ -391,20 +389,16 @@ start_agent_sandbox() { } # Strip `rule get` down to the approval contract: chunk, binary, access, -# and the prover's categorical findings (no severity grade — the prover -# emits category names like `credential_reach_expansion` and -# `capability_expansion`). +# gateway admission context, and any advisory. summarize_pending() { local pending="$1" sed 's/\x1b\[[0-9;]*m//g' "$pending" \ | awk ' BEGIN { - in_validation = 0 chunk_count = 0 validation_printed = 0 } /^[[:space:]]*Chunk:/ { - in_validation = 0 chunk_count++ validation_printed = 0 if (chunk_count > 1) print "" @@ -415,17 +409,15 @@ summarize_pending() { next } /Binary:/ { - in_validation = 0 sub(/^[[:space:]]*/, "") sub(/^Binary:/, "Binary: ") print " " $0 next } /Endpoints:/ { - in_validation = 0 sub(/^[[:space:]]*/, "") if (!validation_printed) { - print " Prover: no verdict shown" + print " Admission: unmanaged; review required" validation_printed = 1 } sub(/^Endpoints:/, "Access: ") @@ -433,52 +425,24 @@ summarize_pending() { next } /Validation:/ { - in_validation = 1 validation_printed = 1 sub(/^[[:space:]]*/, "") - sub(/^Validation:[[:space:]]*(prover:[[:space:]]*)?/, "Prover: ") + sub(/^Validation:[[:space:]]*/, "Admission: ") print " " $0 next } /Rationale:/ { - in_validation = 0 sub(/^[[:space:]]*/, "") sub(/^Rationale:/, "Reason: ") print " " $0 next } - # Indented continuation lines of the validation block are - # category-named finding rows (e.g., - # `capability_expansion: PUT on api.github.com:443 via /usr/bin/curl`). - in_validation && /^[[:space:]]+(credential_reach_expansion|capability_expansion|l7_bypass_credentialed|link_local_reach):/ { - sub(/^[[:space:]]*/, "") - print " Finding: " $0 - next - } - { in_validation = 0 } ' } -pending_requires_review() { - local pending="$1" - local clean - # Empty-delta chunks can appear in the pending view for a moment before the - # gateway records auto-approval. Keep the demo focused on actual review - # work: findings, merge failures, or policy validation failures. - clean="$(sed 's/\x1b\[[0-9;]*m//g' "$pending")" - if grep -Eq 'Validation: (prover: [1-9][0-9]* new finding|merge failed|policy invalid)|^[[:space:]]+(credential_reach_expansion|capability_expansion|l7_bypass_credentialed|link_local_reach):' <<<"$clean"; then - return 0 - fi - if grep -q 'Validation:' <<<"$clean"; then - return 1 - fi - return 0 -} - narrate_sandbox_workflow() { - info "Loop: deny → propose → validate → decide → retry" - info " auto: scoped requests with no new findings continue" - info " review: credentialed or risky requests pause here" + info "Loop: deny → propose → admit → review → retry" + info " review: every accepted unmanaged proposal pauses here" info "" info "${DIM}Watching for review requests...${RESET}" } @@ -530,10 +494,7 @@ approve_pending_until_agent_exits() { approval_count=0 while true; do - # Agent finished? Drain its exit status and we're done. Under v1 - # auto-approval, the agent's narrow L7 proposals auto-approve at the - # gateway and the agent can exit without any escalation surfacing - # here. That's the success case — no human action required. + # Agent finished? Drain its exit status and we're done. if ! kill -0 "$AGENT_PID" >/dev/null 2>&1; then spin_clear if ! wait "$AGENT_PID"; then @@ -542,21 +503,16 @@ approve_pending_until_agent_exits() { fi AGENT_PID="" if (( approval_count == 0 )); then - info "agent exited with zero review approvals (all proposals auto-approved)" + info "agent exited without requesting additional authority" else info "agent exited after ${approval_count} review approval(s)" fi return fi - # Anything pending needs an explicit host-side decision. Auto mode only - # bypasses this when the gateway validation finds no new risk. + # Anything pending needs an explicit host-side decision. if "$OPENSHELL_BIN" rule get "$DEMO_SANDBOX_NAME" --status pending >"$pending" 2>/dev/null \ && grep -q "Chunk:" "$pending" && grep -q "pending" "$pending"; then - if ! pending_requires_review "$pending"; then - spin_wait "waiting for auto-approvals to settle" 2 - continue - fi spin_clear info "" info "${YELLOW}approval requested${RESET}" @@ -612,7 +568,7 @@ verify_github_write() { show_logs() { step "Decision trace" "$OPENSHELL_BIN" logs "$DEMO_SANDBOX_NAME" --since 10m -n 200 2>&1 \ - | grep -E 'HTTP:PUT.*(DENIED|ALLOWED)|agent_authored proposal|auto-approved: no new prover findings \(source=agent_authored\)|gateway approved draft chunk .*PUT|Policy reloaded successfully' \ + | grep -E 'HTTP:PUT.*(DENIED|ALLOWED)|agent_authored proposal|gateway approved draft chunk .*PUT|Policy reloaded successfully' \ | grep -v 'source=mechanistic' \ | sed 's/^/ /' || true } diff --git a/examples/agent-driven-policy-management/policy.template.yaml b/examples/agent-driven-policy-management/policy.template.yaml index 0498ecfcc8..94a3806d22 100644 --- a/examples/agent-driven-policy-management/policy.template.yaml +++ b/examples/agent-driven-policy-management/policy.template.yaml @@ -3,21 +3,20 @@ # Initial sandbox policy for the agent-driven policy demo. # -# The demo exercises two flavors of denial-→-propose-→-decision: +# The demo exercises two denial → proposal → review loops: # # - Step 1 hits raw.githubusercontent.com (no credential in scope). The # host is pre-listed at L7 with no allowed paths, so the agent's GET -# structured-403's. The agent proposes the exact path; the prover -# sees no credential exposure and the gateway auto-approves. +# structured-403's. The agent proposes the exact path and the host-side +# demo driver approves the pending unmanaged proposal. # # - Step 2 hits api.github.com PUT (github credential in scope). The # host is pre-allowed for read-only access, so the PUT -# structured-403's. The agent proposes the narrow PUT path; the -# prover sees github_token in scope and emits MEDIUM. The chunk +# structured-403's. The agent proposes the narrow PUT path. The chunk # lands in pending for human review; demo.sh approves on behalf. # -# This shows both halves of the loop in one run: free path for safe -# changes, single human approval for credentialed ones. +# Every proposal waits because the sandbox has no managed maximum. Set +# DEMO_MANUAL_APPROVE=1 to make the developer issue each decision directly. version: 1 @@ -50,7 +49,7 @@ network_policies: github_api_readonly: # api.github.com pre-allowed for read-only access. Writes (PUT/POST/PATCH/DELETE) # structured-403 at L7 — the agent proposes the specific method/path, - # and the prover gates on credential-in-scope (github provider attached). + # and the reviewer sees the exact requested method and path. name: github-api-readonly endpoints: - host: api.github.com @@ -64,11 +63,7 @@ network_policies: github_raw_scoped: # raw.githubusercontent.com — pre-listed at L7 with one bootstrap # path so the L7 validator accepts the rule. The agent must propose - # any additional GET paths it actually needs. Each new proposal is - # un-credentialed (no provider declares this host), so the prover - # sees no findings and the gateway auto-approves narrow scoped reads - # when `proposal_approval_mode = auto` (set via `--approval-mode auto` - # at create or via `openshell settings set` at runtime). + # any additional GET paths it actually needs. name: github-raw-scoped endpoints: - host: raw.githubusercontent.com diff --git a/examples/managed-maximum-policies/README.md b/examples/managed-maximum-policies/README.md new file mode 100644 index 0000000000..49409dcd61 --- /dev/null +++ b/examples/managed-maximum-policies/README.md @@ -0,0 +1,34 @@ + + + +# Managed maximum policies + +These examples are gateway-owned ceilings for managed sandboxes. They use the normal sandbox +policy shape plus `metadata` and optional `review` annotations. The maximum does not grant access; +each sandbox still starts with its own narrower policy. + +Set a maximum before creating sandboxes: + +```shell +openshell policy maximum set --policy examples/managed-maximum-policies/github-rest.yaml --yes +openshell policy maximum get --full +``` + +Then create a sandbox in the maximum's default mode or select an allowed mode explicitly: + +```shell +openshell sandbox create \ + --policy examples/sandbox-policy-quickstart/policy.yaml \ + --permission-mode auto +``` + +Delete every sandbox before replacing or deleting the maximum: + +```shell +openshell policy maximum delete --yes +``` + +- `github-rest.yaml` makes reads auto-eligible, marks writes for review, and denies deletes. + +This first implementation models filesystem paths plus L4 and REST authority. Other protocols and +unsupported endpoint options fail closed with administrator guidance. diff --git a/examples/managed-maximum-policies/github-rest.yaml b/examples/managed-maximum-policies/github-rest.yaml new file mode 100644 index 0000000000..3164353ac9 --- /dev/null +++ b/examples/managed-maximum-policies/github-rest.yaml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 +metadata: + policy_id: github-read-auto-write-review + version: 1 + allowed_modes: [ask, auto] + default_mode: auto + audit_label: github-engineering + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + github: + endpoints: + - host: api.github.com + port: 443 + path: /repos/** + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: /repos/** } + - allow: + method: POST + path: /repos/** + review: { required: true, reason: "GitHub write" } + deny_rules: + - { method: DELETE, path: "**" } + binaries: + - { path: /usr/bin/gh } + - { path: /usr/bin/curl } diff --git a/proto/openshell.proto b/proto/openshell.proto index d2d884f2e8..4a294306fa 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -137,6 +137,18 @@ service OpenShell { rpc GetGatewayConfig(openshell.sandbox.v1.GetGatewayConfigRequest) returns (openshell.sandbox.v1.GetGatewayConfigResponse); + // Configure the gateway-owned maximum authority boundary. + rpc SetManagedMaximumPolicy(SetManagedMaximumPolicyRequest) + returns (ManagedMaximumPolicyResponse); + + // Get the configured maximum authority boundary. + rpc GetManagedMaximumPolicy(GetManagedMaximumPolicyRequest) + returns (ManagedMaximumPolicyResponse); + + // Delete the configured maximum authority boundary. + rpc DeleteManagedMaximumPolicy(DeleteManagedMaximumPolicyRequest) + returns (DeleteManagedMaximumPolicyResponse); + // Update settings or policy at sandbox or global scope. rpc UpdateConfig(UpdateConfigRequest) returns (UpdateConfigResponse); @@ -326,10 +338,9 @@ message SandboxSpec { ResourceRequirements resource_requirements = 9; reserved 10; reserved "gpu_device"; - // Field 11 was `proposal_approval_mode`. The approval mode is now a - // runtime setting (gateway or sandbox scope) read via UpdateConfig / - // GetSandboxConfig, so it can be flipped on a running sandbox and - // managed fleet-wide. + // Field 11 was the legacy unmanaged proposal approval mode. Automatic + // proposal application now requires a gateway-managed maximum policy and + // the sandbox's immutable managed permission mode. reserved 11; reserved "proposal_approval_mode"; } @@ -447,6 +458,10 @@ message CreateSandboxRequest { string name = 2; // Optional labels for the sandbox (key-value metadata). map labels = 3; + // Managed permission mode for this sandbox (`ask` or `auto`). When omitted, + // a configured managed maximum supplies its default. Rejected when no + // managed maximum is configured. + string permission_mode = 4; } // Get sandbox request. @@ -1283,6 +1298,34 @@ message UpdateConfigResponse { bool deleted = 4; } +// Set one gateway-owned managed maximum policy document. +message SetManagedMaximumPolicyRequest { + // OpenShell policy YAML plus a top-level managed `metadata` block. + bytes policy_yaml = 1; +} + +message GetManagedMaximumPolicyRequest {} + +message DeleteManagedMaximumPolicyRequest {} + +// Current managed maximum policy and its validated metadata. +message ManagedMaximumPolicyResponse { + bool configured = 1; + bytes policy_yaml = 2; + string policy_id = 3; + uint64 version = 4; + repeated string allowed_modes = 5; + string default_mode = 6; + string audit_label = 7; + string policy_hash = 8; + uint64 settings_revision = 9; +} + +message DeleteManagedMaximumPolicyResponse { + bool deleted = 1; + uint64 settings_revision = 2; +} + // Get sandbox policy status request. message GetSandboxPolicyStatusRequest { // Sandbox name (canonical lookup key). Ignored when global is true. @@ -1852,8 +1895,8 @@ message DraftChunkPayload { string binary = 9; // Current draft version for the owning sandbox. int64 draft_version = 10; - // Gateway prover verdict for this chunk; empty until prover runs. - // Mirrors PolicyChunk.validation_result. + // Managed-admission context or a non-gating gateway advisory. Empty when + // neither applies. Mirrors PolicyChunk.validation_result. string validation_result = 11; // Operator-supplied free-form rejection text; empty for non-rejected // chunks. Mirrors PolicyChunk.rejection_reason. @@ -1892,7 +1935,7 @@ message StoredDraftChunk { int32 hit_count = 15; int64 first_seen_ms = 16; int64 last_seen_ms = 17; - // Gateway prover verdict; empty until the prover runs. See PolicyChunk. + // Managed-admission context or a non-gating gateway advisory. See PolicyChunk. string validation_result = 18; // Operator-supplied free-form rejection text. See PolicyChunk. string rejection_reason = 19; diff --git a/rfc/0002-agent-driven-policy-management/README.md b/rfc/0002-agent-driven-policy-management/README.md index b55c7e98fb..2f9f9e6031 100644 --- a/rfc/0002-agent-driven-policy-management/README.md +++ b/rfc/0002-agent-driven-policy-management/README.md @@ -78,7 +78,7 @@ For the initial implementation of this RFC, dynamic policy management should be - L4 network policy for all outbound traffic - L7 REST enforcement for HTTP APIs where `protocol: rest` is configured -- policy prover checks that can distinguish L4-only access from L7-enforced access +- explicit policy representation that distinguishes L4-only access from L7-enforced access This matters because "allow GitHub" is not a single thing: diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index 64f6d27b9f..28658c21d1 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -34,7 +34,7 @@ The launcher: - Selects the requested harness and bakes the common runtime into the immutable sandbox payload. - For `--harness codex`, imports `providers/codex-gator.yaml`, creates or updates the `codex-gator` provider from `$HOME/.codex/auth.json`, and stores the refresh token as gateway-only refresh material. - For `--harness codex`, configures gateway-managed refresh for `CODEX_AUTH_ACCESS_TOKEN` and rotates it before launching the sandbox. -- Enables `providers_v2_enabled`, `agent_policy_proposals_enabled`, and `proposal_approval_mode=auto` at gateway scope. +- Enables `providers_v2_enabled` and `agent_policy_proposals_enabled` at gateway scope. Any accepted proposal waits for human review unless the gateway is configured with a managed maximum and the sandbox uses managed `auto` mode. - Uses the gator image policy copied to `/etc/openshell/policy.yaml`. - Installs the gator-specific `gh` wrapper from `gator/bin/gh` as `/usr/local/bin/gh` to prevent duplicate same-head-SHA gator dispositions. - Bakes `scripts/agents/gator/skills/gator-gate/SKILL.md` into `/etc/openshell/agent-payload`. diff --git a/scripts/agents/gator/agent.yaml b/scripts/agents/gator/agent.yaml index 8839e9f81d..c25f2638af 100644 --- a/scripts/agents/gator/agent.yaml +++ b/scripts/agents/gator/agent.yaml @@ -31,8 +31,6 @@ settings: value: true - key: agent_policy_proposals_enabled value: true - - key: proposal_approval_mode - value: auto providers: - id: github diff --git a/scripts/bin/openshell b/scripts/bin/openshell index 23000bc313..d4878b8097 100755 --- a/scripts/bin/openshell +++ b/scripts/bin/openshell @@ -45,8 +45,6 @@ else return 0 ;; crates/openshell-cli/*|crates/openshell-core/*|crates/openshell-bootstrap/*) return 0 ;; - crates/openshell-prover/*) - return 0 ;; crates/openshell-policy/*|crates/openshell-providers/*|crates/openshell-tui/*) return 0 ;; *) @@ -95,26 +93,6 @@ fi if [[ "$needs_build" == "1" ]]; then echo "Recompiling openshell..." >&2 build_args=(--package openshell-cli --quiet) - if ! command -v pkg-config >/dev/null 2>&1 || ! pkg-config --exists z3 >/dev/null 2>&1; then - z3_prefix="" - if command -v brew >/dev/null 2>&1; then - z3_prefix=$(brew --prefix z3 2>/dev/null || true) - fi - - for candidate in "$z3_prefix" /opt/homebrew/opt/z3 /usr/local/opt/z3; do - if [[ -n "$candidate" && -f "$candidate/include/z3.h" && -d "$candidate/lib" ]]; then - echo "Using local Z3 from ${candidate} for CLI build." >&2 - export Z3_SYS_Z3_HEADER="${candidate}/include/z3.h" - export Z3_LIBRARY_PATH_OVERRIDE="${candidate}/lib" - break - fi - done - - if [[ -z "${Z3_SYS_Z3_HEADER:-}" ]]; then - echo "Falling back to bundled Z3 for local CLI build." >&2 - build_args+=(--features bundled-z3) - fi - fi cargo build "${build_args[@]}" # Persist state after successful build mkdir -p "$(dirname "$STATE_FILE")" @@ -139,8 +117,6 @@ if [[ "$needs_build" == "1" ]]; then return 0 ;; crates/openshell-cli/*|crates/openshell-core/*|crates/openshell-bootstrap/*) return 0 ;; - crates/openshell-prover/*) - return 0 ;; crates/openshell-policy/*|crates/openshell-providers/*|crates/openshell-tui/*) return 0 ;; *) diff --git a/tasks/ci.toml b/tasks/ci.toml index 9696517af2..6b3b8b2153 100644 --- a/tasks/ci.toml +++ b/tasks/ci.toml @@ -30,7 +30,7 @@ hide = true ["build:rust:snap"] description = "Build release Rust binaries consumed by the hand-staged snap" run = [ - "cargo build --release -p openshell-cli --features bundled-z3", + "cargo build --release -p openshell-cli", "cargo build --release -p openshell-server --features bundled-z3", "cargo build --release -p openshell-sandbox", ] diff --git a/tasks/python.toml b/tasks/python.toml index b95d966714..554132aa03 100644 --- a/tasks/python.toml +++ b/tasks/python.toml @@ -76,7 +76,7 @@ fi rm -rf "$WHEEL_OUTPUT_DIR" mkdir -p "$WHEEL_OUTPUT_DIR" -uv run maturin build --release --features bundled-z3 --out "$WHEEL_OUTPUT_DIR" +uv run maturin build --release --out "$WHEEL_OUTPUT_DIR" ls -la "$WHEEL_OUTPUT_DIR"/*.whl '''