diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 60e980179..d948facf4 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1331,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, @@ -1748,30 +1736,6 @@ enum PolicyCommands { #[arg(long)] yes: bool, }, - - /// Prove properties of a sandbox policy — or find counterexamples. - #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] - Prove { - /// Path to `OpenShell` sandbox policy YAML. - #[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, - - /// Path to accepted risks YAML. - #[arg(long, value_hint = ValueHint::FilePath)] - accepted_risks: Option, - - /// One-line-per-finding output (for demos and CI). - #[arg(long)] - compact: bool, - }, } #[derive(Subcommand, Debug)] @@ -2330,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), }) => { @@ -2482,7 +2424,6 @@ async fn main() -> Result<()> { } run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &tls).await?; } - PolicyCommands::Prove { .. } => unreachable!(), } } @@ -2671,7 +2612,6 @@ async fn main() -> Result<()> { no_auto_providers, labels, envs, - approval_mode, command, } => { // Resolve --tty / --no-tty into an Option override. @@ -2758,7 +2698,6 @@ async fn main() -> Result<()> { auto_providers_override, labels: labels_map, environment: env_map, - approval_mode: &approval_mode, }, &tls, )) @@ -4559,42 +4498,6 @@ 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 { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { approval_mode, .. }), - .. - }) => { - assert_eq!(approval_mode, "manual"); - } - other => panic!("expected SandboxCommands::Create, got: {other:?}"), - } - } - - /// `--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:?}"), - } - } - #[test] fn sandbox_create_permission_mode_is_optional_and_accepts_managed_modes() { let default = Cli::try_parse_from(["openshell", "sandbox", "create"]) @@ -4634,23 +4537,10 @@ mod tests { ]) .is_err() ); - } - - /// `--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", "--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 13cbaacda..eab83b638 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1777,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>, @@ -1797,33 +1797,6 @@ pub struct SandboxCreateConfig<'a> { 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, - permission_mode: 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. @@ -1852,7 +1825,6 @@ pub async fn sandbox_create( auto_providers_override, labels, environment, - approval_mode, } = config; if editor.is_some() && !command.is_empty() { @@ -1971,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 { @@ -6231,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| { diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 156e4c384..0307d3a50 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 3143b6d68..2627a1bf2 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-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 91b6b0cd3..736961e66 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}; @@ -175,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, @@ -422,265 +406,68 @@ 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(); - - 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"); - 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() - } 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 - }; - - 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; - } - - 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(), - }) -} - -/// 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 +) -> Result, Status> { + let layers = profile_provider_policy_layers(store, provider_names).await?; + let mut hosts = layers .iter() - .flat_map(|f| { - let query = f.query.clone(); - f.paths + .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) +} + +/// 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() - .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() { + .any(|target| super::provider::host_patterns_can_overlap(&endpoint.host, target)) + { continue; } - delta.push(Finding { - paths: new_paths, - ..finding.clone() - }); + + let ports = if endpoint.ports.is_empty() { + vec![endpoint.port] + } else { + endpoint.ports.clone() + }; + for binary in &proposed_rule.binaries { + for port in &ports { + evidence.insert(format!( + "binary={} host={} port={port}", + binary.path, endpoint.host + )); + } + } } - // 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("; ") + (!evidence.is_empty()).then(|| { + format!( + "advisory: credentialed raw L4 authority is not inspectable at L7: {}", + evidence.into_iter().collect::>().join(", ") + ) + }) } /// Auto-reject any pending chunks for the same sandbox that share the @@ -810,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 @@ -844,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 @@ -939,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!( @@ -958,51 +703,13 @@ 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( - state: &ServerState, - sandbox: &Sandbox, - sandbox_id: &str, -) -> Result { - let mut policy = current_base_policy_for_sandbox(state, sandbox, sandbox_id).await?; - - 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); - } - } - - Ok(policy) -} - async fn current_base_policy_for_sandbox( state: &ServerState, sandbox: &Sandbox, @@ -2350,36 +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_base_policy = current_base_policy_for_sandbox(state, &sandbox, &sandbox_id).await?; - 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 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 @@ -2419,6 +2104,13 @@ 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(), @@ -2432,7 +2124,7 @@ pub(super) async fn handle_submit_policy_analysis( continue; } }; - let (managed_auto_approve, managed_validation) = match decide_managed_merge( + let (managed_auto_apply, managed_validation) = match decide_managed_merge( state, &sandbox, ¤t_base_policy, @@ -2442,18 +2134,15 @@ pub(super) async fn handle_submit_policy_analysis( ) .await? { - crate::managed_policy::AdmissionDecision::Unmanaged => (None, None), + crate::managed_policy::AdmissionDecision::Unmanaged => (false, None), crate::managed_policy::AdmissionDecision::Apply => ( - Some(true), + 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"); - ( - Some(false), - Some(format!("managed: ask: {}", status.message())), - ) + (false, Some(format!("managed: ask: {}", status.message()))) } decision @ crate::managed_policy::AdmissionDecision::Reject { .. } => { rejected += 1; @@ -2485,20 +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, - ); - let validation_result = managed_validation.map_or_else( - || validation_result.clone(), - |managed| format!("{managed}; security review: {validation_result}"), + &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 @@ -2584,30 +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. - let should_auto_approve = managed_auto_approve - .unwrap_or(auto_approve_enabled && validation_result == "prover: no new findings"); - let approval_source = if managed_auto_approve.is_some() { - "managed-maximum" - } else { - resolved_from - }; - if should_auto_approve - && 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, - approval_source, ) .await { @@ -2615,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" ); } @@ -3946,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 '{}'", @@ -4260,6 +3929,17 @@ network_policies: - { 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(), }), @@ -4272,6 +3952,7 @@ network_policies: state: &Arc, sandbox_id: &str, sandbox_name: &str, + providers: Vec, ) { state .store @@ -4285,7 +3966,10 @@ network_policies: )]), ..Default::default() }), - spec: Some(openshell_core::proto::SandboxSpec::default()), + spec: Some(openshell_core::proto::SandboxSpec { + providers, + ..Default::default() + }), ..Default::default() }) .await @@ -4325,7 +4009,7 @@ network_policies: 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").await; + put_managed_test_sandbox(&state, "managed-auto-id", "managed-auto", Vec::new()).await; let read = handle_submit_policy_analysis( &state, @@ -4403,6 +4087,107 @@ network_policies: 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)] @@ -6156,33 +5941,16 @@ network_policies: 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(); } @@ -6194,12 +5962,8 @@ network_policies: }; 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")) @@ -6275,10 +6039,12 @@ network_policies: .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( @@ -6500,15 +6266,11 @@ network_policies: 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, @@ -6539,11 +6301,6 @@ network_policies: }; 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 { @@ -6593,19 +6350,10 @@ network_policies: .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 ); } @@ -6623,8 +6371,8 @@ network_policies: }; 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")) @@ -6656,8 +6404,7 @@ network_policies: 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 { @@ -6689,8 +6436,8 @@ network_policies: .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 { @@ -6707,25 +6454,16 @@ network_policies: .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 { @@ -6795,14 +6533,7 @@ network_policies: 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; \ @@ -6821,12 +6552,9 @@ network_policies: ); } - /// 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, @@ -6858,10 +6586,6 @@ network_policies: }; 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 { @@ -6902,23 +6626,18 @@ network_policies: .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, @@ -6955,8 +6674,6 @@ network_policies: }; 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 { @@ -7002,33 +6719,17 @@ network_policies: .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, @@ -7053,8 +6754,6 @@ network_policies: }), ..Default::default() }), - // No approval-mode setting seeded at sandbox or gateway - // scope — the resolver must treat absence as "manual". ..Default::default() }), ..Default::default() @@ -7083,7 +6782,7 @@ network_policies: 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() @@ -7102,198 +6801,18 @@ network_policies: .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() { - use openshell_core::proto::{ - FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, - SandboxSpec, - }; - - let state = test_server_state().await; - let sandbox_name = "unknown-mode".to_string(); - let mut sandbox = Sandbox { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-unknown-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(); - // A future-CLI value the current gateway doesn't recognize. - seed_sandbox_approval_mode(&state, &sandbox_name, "auto_on_low_risk").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".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", - "unknown approval-mode strings must fall back to manual; \ - only the literal \"auto\" opts in. got: {}", - draft.chunks[0].status - ); - } - - /// Explicit `"manual"` is equivalent to the unset default — chunk lands - /// in pending even with empty delta. - #[tokio::test] - async fn empty_delta_does_not_auto_approve_when_mode_explicit_manual() { - 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 mut sandbox = Sandbox { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-explicit-manual-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(); - seed_sandbox_approval_mode(&state, &sandbox_name, "manual").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 — 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() { + async fn stale_legacy_auto_setting_is_inert() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -7324,8 +6843,7 @@ network_policies: }; 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; + seed_legacy_global_auto_setting(&state).await; let proposed_rule = NetworkPolicyRule { name: "anon_l4".to_string(), @@ -7348,98 +6866,7 @@ network_policies: 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(), + rationale: "uncredentialed unmanaged L4".to_string(), ..Default::default() }], ..Default::default() @@ -7460,8 +6887,7 @@ network_policies: .into_inner(); assert_eq!( draft.chunks[0].status, "pending", - "gateway-scope \"manual\" must win over sandbox-scope \"auto\"; \ - got: {}", + "legacy gateway auto setting must not authorize unmanaged expansion; got: {}", draft.chunks[0].status ); } @@ -7470,7 +6896,7 @@ network_policies: /// 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). + /// own rule and blur the user/provider ownership boundary. #[tokio::test] async fn submit_rejects_reserved_provider_rule_name_prefix() { use openshell_core::proto::{ @@ -7635,12 +7061,10 @@ network_policies: ); } - /// 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, @@ -7720,29 +7144,20 @@ network_policies: .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, @@ -7816,18 +7231,12 @@ network_policies: .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, @@ -7873,7 +7282,7 @@ network_policies: }], }; - handle_submit_policy_analysis( + let response = handle_submit_policy_analysis( &state, with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), @@ -7888,32 +7297,16 @@ network_policies: })), ) .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, @@ -8041,35 +7434,13 @@ network_policies: .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}" - ); + assert!(draft.chunks[0].validation_result.is_empty()); + assert_eq!(draft.chunks[0].status, "pending"); } - /// 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." + /// 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, @@ -8078,10 +7449,8 @@ network_policies: 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")) @@ -8113,9 +7482,7 @@ network_policies: }; 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 { @@ -8157,7 +7524,7 @@ network_policies: .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 { @@ -8221,44 +7588,18 @@ network_policies: .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 @@ -8759,35 +8100,31 @@ network_policies: ); } - /// 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"), @@ -8798,8 +8135,8 @@ network_policies: "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}" ); } @@ -9522,78 +8859,6 @@ network_policies: 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; @@ -9940,8 +9205,8 @@ network_policies: 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) @@ -9951,8 +9216,8 @@ network_policies: 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 47083c5a7..98cee5f9f 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -760,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('*') { diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 3cbc31502..334eb10dd 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-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs index 5fd11cb99..510f15421 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/proto/openshell.proto b/proto/openshell.proto index 32fbc8361..4a294306f 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -338,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"; } @@ -1896,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. @@ -1936,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;