From 59bebfd1228ffdb8adda700326780b18aec9e706 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Tue, 19 May 2026 09:42:16 -0700 Subject: [PATCH 1/6] feat(policy): validate agent-authored proposals Signed-off-by: Alexander Watson --- Cargo.lock | 1 + crates/openshell-cli/src/run.rs | 7 + crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/grpc/policy.rs | 613 +++++++++++++++++- .../agent-driven-policy-management/README.md | 26 +- .../agent-driven-policy-management/demo.sh | 11 +- 6 files changed, 644 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87adc5e2b..750339ec1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3710,6 +3710,7 @@ dependencies = [ "openshell-driver-podman", "openshell-ocsf", "openshell-policy", + "openshell-prover", "openshell-providers", "openshell-router", "petname", diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 198cb4b0a..78ef8f305 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -6030,6 +6030,13 @@ pub async fn sandbox_draft_get( chunk.security_notes.yellow() ); } + if !chunk.validation_result.is_empty() { + println!( + " {} {}", + "Validation:".dimmed(), + chunk.validation_result.cyan() + ); + } if let Some(ref rule) = chunk.proposed_rule { println!(" {} {}", "Endpoints:".dimmed(), format_endpoints(rule)); diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 4bbfe24fc..9c3e11eec 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -22,6 +22,7 @@ openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } openshell-driver-podman = { path = "../openshell-driver-podman" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } +openshell-prover = { path = "../openshell-prover" } openshell-providers = { path = "../openshell-providers" } openshell-router = { path = "../openshell-router" } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 315b06f3c..1f8e295a8 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -45,6 +45,11 @@ use openshell_ocsf::{ }; use openshell_policy::{ PolicyMergeOp, ProviderPolicyLayer, compose_effective_policy, merge_policy, + serialize_sandbox_policy, +}; +use openshell_prover::{ + credentials::CredentialSet, model::build_model, policy::parse_policy_str, + queries::run_all_queries, registry::load_embedded_binary_registry, }; use openshell_providers::{get_default_profile, normalize_provider_type}; use prost::Message; @@ -304,6 +309,173 @@ fn summarize_draft_chunk_rule(chunk: &DraftChunkRecord) -> Result String { + let scope_verdict = scope_verdict_for_rule(proposed_rule); + + let merge_op = PolicyMergeOp::AddRule { + rule_name: rule_name.to_string(), + rule: proposed_rule.clone(), + }; + let merged = match merge_policy(current_policy, &[merge_op]) { + Ok(result) => result.policy, + Err(error) => { + return format!("failed: policy merge rejected ({error}); {scope_verdict}"); + } + }; + + if let Err(error) = validate_policy_safety(&merged) { + return format!("failed: policy safety check rejected ({error}); {scope_verdict}"); + } + + if policy_uses_prover_unsupported_features(&merged) { + return format!( + "validation unavailable: prover does not model deny_rules yet; {scope_verdict}" + ); + } + + let yaml = match serialize_sandbox_policy(&merged) { + Ok(yaml) => yaml, + Err(error) => { + return format!("validation unavailable: serialize policy failed ({error})"); + } + }; + let prover_policy = match parse_policy_str(&yaml) { + Ok(policy) => policy, + Err(error) => { + return format!("validation unavailable: parse policy failed ({error})"); + } + }; + let registry = match load_embedded_binary_registry() { + Ok(registry) => registry, + Err(error) => { + return format!("validation unavailable: load prover registry failed ({error})"); + } + }; + + let model = build_model(prover_policy, CredentialSet::default(), registry); + let findings = run_all_queries(&model); + if findings.is_empty() { + return format!("prover passed supported checks; {scope_verdict}"); + } + + let finding_summary = findings + .iter() + .map(|finding| format!("{} {}", finding.risk, finding.query)) + .collect::>() + .join(", "); + format!( + "failed: prover found {} finding(s): {}; {}", + findings.len(), + finding_summary, + scope_verdict + ) +} + +fn policy_uses_prover_unsupported_features(policy: &ProtoSandboxPolicy) -> bool { + policy + .network_policies + .values() + .flat_map(|rule| &rule.endpoints) + .any(|endpoint| !endpoint.deny_rules.is_empty()) +} + +fn scope_verdict_for_rule(rule: &NetworkPolicyRule) -> String { + let mut needs_human = Vec::new(); + let mut saw_exact_l7_rule = false; + + for endpoint in &rule.endpoints { + if endpoint.protocol.trim().is_empty() { + needs_human.push("L4/no method-path scope"); + } + if endpoint.host.contains('*') { + needs_human.push("wildcard host"); + } + if !endpoint.protocol.trim().is_empty() && endpoint.rules.is_empty() { + needs_human.push("L7 preset/no exact method-path"); + } + + for rule in &endpoint.rules { + let Some(allow) = rule.allow.as_ref() else { + needs_human.push("unsupported L7 rule shape"); + continue; + }; + let method = allow.method.trim(); + let path = allow.path.trim(); + if method.is_empty() || method == "*" { + needs_human.push("wildcard method"); + } + if path.is_empty() || path.contains('*') { + needs_human.push("wildcard path"); + } + if !method.is_empty() && method != "*" && !path.is_empty() && !path.contains('*') { + saw_exact_l7_rule = true; + } + } + } + + needs_human.sort_unstable(); + needs_human.dedup(); + if needs_human.is_empty() && saw_exact_l7_rule { + "narrow L7 method/path scope".to_string() + } else if needs_human.is_empty() { + "needs human: no exact L7 method/path evidence".to_string() + } else { + format!("needs human: {}", needs_human.join(", ")) + } +} + +async fn current_effective_policy_for_sandbox( + state: &ServerState, + sandbox: &Sandbox, + sandbox_id: &str, +) -> Result { + let mut policy = 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); + } + } + + Ok(policy) +} + fn truncate_for_log(input: &str, max_chars: usize) -> String { let mut chars = input.chars(); let truncated: String = chars.by_ref().take(max_chars).collect(); @@ -1347,6 +1519,7 @@ pub(super) async fn handle_submit_policy_analysis( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + let current_policy = current_effective_policy_for_sandbox(state, &sandbox, &sandbox_id).await?; let current_version = state .store @@ -1389,6 +1562,16 @@ pub(super) async fn handle_submit_policy_analysis( .map(|b| b.path.clone()) .unwrap_or_default(); + let validation_result = if req.analysis_mode == "agent_authored" { + validation_result_for_agent_proposal( + current_policy.clone(), + &chunk.rule_name, + chunk.proposed_rule.as_ref().expect("checked above"), + ) + } else { + String::new() + }; + let record = DraftChunkRecord { // The handler proposes an id; the store may swap it for an // existing row's id on dedup. Always trust `effective_id` for @@ -1421,7 +1604,7 @@ pub(super) async fn handle_submit_policy_analysis( } else { now_ms }, - validation_result: String::new(), + validation_result, rejection_reason: String::new(), }; // Mechanistic mode dedups N denials targeting the same endpoint @@ -4229,10 +4412,436 @@ mod tests { rejected.rejection_reason, guidance, "reviewer's free-form reason must round-trip into the chunk for agent readback" ); - // validation_result is unpopulated until the prover runs (#1097). + // Non-agent-authored submissions keep validation_result empty; the + // gateway prover path is reserved for analysis_mode=agent_authored. assert!(rejected.validation_result.is_empty()); } + #[tokio::test] + async fn agent_authored_exact_l7_proposal_gets_prover_pass_verdict() { + use openshell_core::proto::{ + FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase, + SandboxPolicy, SandboxSpec, + }; + + let state = test_server_state().await; + let sandbox_name = "agent-l7-verdict".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-agent-l7-verdict".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + let proposed_rule = NetworkPolicyRule { + name: "github_contents_write".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "PUT".to_string(), + path: "/repos/org/repo/contents/demo/file.md".to_string(), + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + handle_submit_policy_analysis( + &state, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_contents_write".to_string(), + proposed_rule: Some(proposed_rule), + rationale: "write one demo file".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap(); + + let draft = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + let verdict = &draft.chunks[0].validation_result; + assert!( + verdict.contains("prover passed"), + "expected prover pass verdict, got: {verdict}" + ); + assert!( + verdict.contains("narrow L7 method/path scope"), + "expected narrow L7 scope verdict, got: {verdict}" + ); + } + + #[tokio::test] + async fn agent_authored_l4_proposal_gets_broad_scope_verdict() { + use openshell_core::proto::{ + FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, + SandboxSpec, + }; + + let state = test_server_state().await; + let sandbox_name = "agent-l4-verdict".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-agent-l4-verdict".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + let proposed_rule = NetworkPolicyRule { + name: "github_l4".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() + }], + }; + + handle_submit_policy_analysis( + &state, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_l4".to_string(), + proposed_rule: Some(proposed_rule), + rationale: "broad fallback".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap(); + + let draft = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + let verdict = &draft.chunks[0].validation_result; + assert!( + verdict.contains("L4/no method-path scope"), + "expected L4 scope warning, got: {verdict}" + ); + assert!( + verdict.contains("failed: prover found"), + "expected prover finding for broad L4 curl access, got: {verdict}" + ); + } + + #[tokio::test] + async fn agent_authored_policy_with_deny_rules_marks_validation_unavailable() { + use openshell_core::proto::{ + FilesystemPolicy, L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, + SandboxPhase, SandboxPolicy, SandboxSpec, + }; + + let state = test_server_state().await; + let sandbox_name = "agent-deny-unsupported".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-agent-deny-unsupported".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + network_policies: std::iter::once(( + "existing_deny_rule".to_string(), + NetworkPolicyRule { + name: "existing_deny_rule".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + deny_rules: vec![L7DenyRule { + method: "DELETE".to_string(), + path: "/repos/*".to_string(), + ..Default::default() + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }, + )) + .collect(), + ..Default::default() + }), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + let proposed_rule = NetworkPolicyRule { + name: "github_contents_write".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "PUT".to_string(), + path: "/repos/org/repo/contents/demo/file.md".to_string(), + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + handle_submit_policy_analysis( + &state, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_contents_write".to_string(), + proposed_rule: Some(proposed_rule), + rationale: "write one demo file".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap(); + + let draft = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + let verdict = &draft.chunks[0].validation_result; + assert!( + verdict.contains("validation unavailable"), + "expected unsupported-feature verdict, got: {verdict}" + ); + assert!( + verdict.contains("deny_rules"), + "expected deny_rules limitation in verdict, got: {verdict}" + ); + } + + #[tokio::test] + async fn agent_authored_validation_uses_providers_v2_effective_policy() { + use openshell_core::proto::{ + FilesystemPolicy, L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, + ProviderProfile, ProviderProfileCategory, SandboxPhase, SandboxPolicy, SandboxSpec, + StoredProviderProfile, + }; + + let state = test_server_state().await; + enable_providers_v2(&state).await; + state + .store + .put_message(&test_provider("work-custom", "custom-api")) + .await + .unwrap(); + state + .store + .put_message(&StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-custom-api".to_string(), + name: "custom-api".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + }), + profile: Some(ProviderProfile { + id: "custom-api".to_string(), + display_name: "Custom API".to_string(), + description: String::new(), + category: ProviderProfileCategory::Other as i32, + credentials: Vec::new(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + deny_rules: vec![L7DenyRule { + method: "DELETE".to_string(), + path: "/repos/*".to_string(), + ..Default::default() + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + inference_capable: false, + }), + }) + .await + .unwrap(); + + let sandbox_name = "agent-provider-effective-policy".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-agent-provider-effective-policy".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + providers: vec!["work-custom".to_string()], + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + let proposed_rule = NetworkPolicyRule { + name: "github_contents_write".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "PUT".to_string(), + path: "/repos/org/repo/contents/demo/file.md".to_string(), + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + handle_submit_policy_analysis( + &state, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_contents_write".to_string(), + proposed_rule: Some(proposed_rule), + rationale: "write one demo file".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap(); + + let draft = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + let verdict = &draft.chunks[0].validation_result; + assert!( + verdict.contains("validation unavailable"), + "expected provider-composed unsupported feature to affect validation, got: {verdict}" + ); + assert!( + verdict.contains("deny_rules"), + "expected provider-composed deny_rules limitation in verdict, got: {verdict}" + ); + } + /// Two agent-authored proposals targeting the same host/port/binary must /// each persist as a distinct chunk. The mechanistic-mode dedup /// (`host|port|binary`) is wrong for agent intent: the redraft loop diff --git a/examples/agent-driven-policy-management/README.md b/examples/agent-driven-policy-management/README.md index 190123cfe..3e6cdd9ed 100644 --- a/examples/agent-driven-policy-management/README.md +++ b/examples/agent-driven-policy-management/README.md @@ -12,12 +12,16 @@ 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 agent calls `GET /v1/proposals/{chunk_id}/wait?timeout=300` — a single +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. +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. -5. You approve the proposal from the host with one keystroke. -6. The agent's `/wait` returns within ~1 second of the approval. The sandbox +6. You approve the proposal from the host with one keystroke after seeing the + exact rule and the prover verdict 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. @@ -99,12 +103,16 @@ with three parts, each with a different trust level: | `validation_result` (prover output) | gateway-side prover | trust signal — but this surface is in progress (see [RFC 0001](../../rfc/0001-agent-driven-policy-management.md)) | The MVP today shows the structured rule plus the agent's rationale in -`openshell rule get` and the TUI inbox panel. The demo's `openshell rule -approve-all` auto-approves to keep the loop short — in a real session a -developer reviews the structured grant before pressing `a`. Prover-backed -validation badges, computed reachability deltas, and a richer "this is what -the rule actually permits" summary are the next phase. For now, **always -approve based on the structured rule, not the agent's rationale.** +`openshell rule get` and the TUI inbox panel. With prover validation wired into +the gateway, `openshell rule get` also shows `Validation:` for agent-authored +chunks, for example `prover passed supported checks; narrow L7 method/path +scope`, a prover finding plus `needs human: L4/no method-path scope`, or +`validation unavailable` when the proposed effective policy uses features the +prover does not model yet. The demo's `openshell rule approve-all` +auto-approves to keep the loop short — in a real session a developer reviews +the structured grant and the validation result before pressing `a`. For now, +**always approve based on the structured rule and control-plane validation, not +the agent's rationale.** ## Going further diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index a3e1d1836..4c6869379 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -16,7 +16,8 @@ # call that sleeps on a socket. THE AGENT BURNS ZERO LLM TOKENS WHILE # IT WAITS; this is the load-bearing UX win over polling. # 5. The developer (this script, simulating the host side) sees the pending -# proposal in `openshell rule get` and approves it. +# proposal in `openshell rule get`, including the gateway-side prover +# verdict, and approves it. # 6. The agent's /wait returns approved within ~1 second of the approval, # retries the original PUT once against the hot-reloaded policy, and # exits. @@ -382,10 +383,9 @@ start_agent_sandbox() { } # Strip the rule_get output down to the lines a developer needs to make an -# informed approve/reject decision: rationale, binary, endpoint. Filters the +# informed approve/reject decision: rationale, validation, binary, endpoint. Filters the # noisy fields (UUID, agent-generated rule_name, hardcoded confidence, -# duplicate Binaries) until `openshell rule get` learns to print L7 -# method/path itself (tracked separately). +# duplicate Binaries). # # `openshell rule get` colorizes labels with ANSI escapes; strip them before # parsing so the field-name match works in piped contexts. @@ -394,6 +394,7 @@ summarize_pending() { sed 's/\x1b\[[0-9;]*m//g' "$pending" \ | awk ' /Rationale:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } + /Validation:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } /Binary:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } /Endpoints:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } ' @@ -421,6 +422,8 @@ EOF info " • agent reads the skill, drafts a narrow ${DIM}addRule${RESET} for exactly that path" info " • agent POSTs to ${DIM}http://policy.local/v1/proposals${RESET}, saves the" info " returned ${DIM}accepted_chunk_ids[0]${RESET}" + info " • gateway merges the proposed rule with the current sandbox policy," + info " runs the prover, and stores a short validation verdict on the chunk" info " • agent calls ${DIM}GET /v1/proposals/{chunk_id}/wait?timeout=300${RESET}" info " — one HTTP call that sleeps on a socket until the developer decides." info " ${BOLD}Zero LLM tokens burn during this wait.${RESET}" From bcd469057181514aff53d2eab9d3e7fb6b4c4573 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Wed, 20 May 2026 17:22:10 -0700 Subject: [PATCH 2/6] feat(policy): agentic policy approval loop with prover-gated auto-approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the prover on every proposal regardless of analysis_mode. Auto-approve proposals whose merged-policy delta is empty (proposer-agnostic, with the global-policy gate respected). Calibrate prover findings to a single HIGH severity emitted on link-local hosts, L4+credential-in-scope, and bypass-L7-binary+credential-in-scope. Add implicit supersede on (host, port, binary): newer submissions auto-reject older pending chunks, and incoming mechanistic chunks auto-reject when an approved agent_authored chunk already covers the same endpoint. Audit auto-approvals via CONFIG:APPROVED OCSF events carrying auto=true, source=, prover_delta=empty as unmapped fields, with message text "auto-approved: no new prover findings". Build credential set from sandbox-attached providers (presence only — no scope modeling in v1). --- architecture/security-policy.md | 48 +- crates/openshell-ocsf/src/format/shorthand.rs | 36 +- crates/openshell-prover/src/credentials.rs | 17 +- crates/openshell-prover/src/lib.rs | 26 +- crates/openshell-prover/src/queries.rs | 294 ++-- crates/openshell-prover/src/report.rs | 108 ++ .../src/skills/policy_advisor.md | 53 +- crates/openshell-server/src/grpc/policy.rs | 1234 ++++++++++++++--- .../agent-driven-policy-management/README.md | 35 +- .../agent-driven-policy-management/demo.sh | 75 +- .../policy.template.yaml | 21 +- .../sandbox-agent.sh | 13 +- 12 files changed, 1574 insertions(+), 386 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index b0d56e3a2..f297eb2ad 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -68,21 +68,51 @@ because it changes the effective access model for every sandbox on the gateway. ## Policy Advisor The policy advisor pipeline turns observed denials into draft policy -recommendations: - -1. The sandbox aggregates denied network events. -2. A mechanistic mapper proposes minimal endpoint, binary, or rule additions. -3. The gateway validates and stores draft recommendations. -4. A human or admin workflow approves or rejects drafts. -5. Approved drafts merge into the target sandbox policy. +recommendations. There are two proposers (sandbox-side mechanistic mapper, +agent-authored via `policy.local`); the gateway is the single referee. + +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).** If the delta is empty + (`prover: no new findings`), 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` as unmapped fields, with message text + `"auto-approved: no new prover findings"` — never `safe`. +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. + +The v1 prover calibration emits `HIGH` findings (the only severity used) on: + +- **Link-local endpoints** (`169.254.0.0/16`, `fe80::/10`), unconditionally + — covers cloud metadata endpoints (AWS IMDS, GCP metadata) which serve + credentials and so are dangerous even with no sandbox credential present. +- **L4 grants** to a host where a sandbox credential is in scope. +- **Bypass-L7 binaries** (`git-remote-http`, `ssh`, `nc`) bound to a host + where a sandbox credential is in scope. + +"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. v1 does not model +credential scopes (read-only vs write); presence is enough. 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 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 should propose narrow additions and preserve explicit-deny behavior. -It is a workflow aid, not an automatic permission grant. +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. ## Security Logging diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 08b413429..d00319d35 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -300,22 +300,40 @@ impl OcsfEvent { }, ); let what = e.base.message.as_deref().unwrap_or("config"); - let version_ctx = e + // 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. + let suffix = e .base .unmapped .as_ref() - .and_then(|u| { - let ver = u.get("policy_version").and_then(|v| v.as_str()); - let hash = u.get("policy_hash").and_then(|v| v.as_str()); - match (ver, hash) { - (Some(v), Some(h)) => Some(format!(" [version:{v} hash:{h}]")), - (Some(v), None) => Some(format!(" [version:{v}]")), - _ => None, + .map(|u| { + let mut parts: Vec = Vec::new(); + let mut push = |key: &str| { + if let Some(value) = u.get(key).and_then(|v| v.as_str()) { + parts.push(format!("{key}:{value}")); + } + }; + push("auto"); + push("source"); + push("prover_delta"); + if let Some(ver) = u.get("policy_version").and_then(|v| v.as_str()) { + parts.push(format!("version:{ver}")); + } + if let Some(hash) = u.get("policy_hash").and_then(|v| v.as_str()) { + parts.push(format!("hash:{hash}")); + } + if parts.is_empty() { + String::new() + } else { + format!(" [{}]", parts.join(" ")) } }) .unwrap_or_default(); - format!("CONFIG:{state} {sev} {what}{version_ctx}") + format!("CONFIG:{state} {sev} {what}{suffix}") } Self::Base(e) => { diff --git a/crates/openshell-prover/src/credentials.rs b/crates/openshell-prover/src/credentials.rs index dffbc2e8b..c23387be1 100644 --- a/crates/openshell-prover/src/credentials.rs +++ b/crates/openshell-prover/src/credentials.rs @@ -135,17 +135,26 @@ pub struct CredentialSet { } impl CredentialSet { - /// Credentials that target a given host. + /// Credentials that target a given host. Comparison is case-insensitive + /// so a policy author writing `API.github.com` matches credentials + /// registered for `api.github.com`. pub fn credentials_for_host(&self, host: &str) -> Vec<&Credential> { + let needle = host.to_ascii_lowercase(); self.credentials .iter() - .filter(|c| c.target_hosts.iter().any(|h| h == host)) + .filter(|c| { + c.target_hosts + .iter() + .any(|h| h.eq_ignore_ascii_case(&needle)) + }) .collect() } - /// API capability registry for a given host. + /// API capability registry for a given host. Case-insensitive match. pub fn api_for_host(&self, host: &str) -> Option<&ApiCapability> { - self.api_registries.values().find(|api| api.host == host) + self.api_registries + .values() + .find(|api| api.host.eq_ignore_ascii_case(host)) } } diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index 82922253d..b89d0897b 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -157,9 +157,13 @@ filesystem_policy: assert_eq!(sandbox_count, 1); } - // 6. End-to-end: git push bypass findings detected (uses embedded registry). + // 6. End-to-end: testdata policy with a github credential in scope and a + // bypass-L7 binary (git) emits a calibrated data_exfiltration finding. + // Under the v1 calibration, all emissions consolidate into the + // data_exfiltration query at RiskLevel::High; the legacy write_bypass + // query is a no-op pending a future intent-aware redesign. #[test] - fn test_git_push_bypass_findings() { + fn test_calibrated_findings_for_github_policy() { let policy_path = testdata_dir().join("policy.yaml"); let creds_path = testdata_dir().join("credentials.yaml"); @@ -174,18 +178,20 @@ filesystem_policy: findings.iter().map(|f| f.query.as_str()).collect(); assert!( query_types.contains("data_exfiltration"), - "expected data_exfiltration finding" + "expected data_exfiltration finding for bypass-L7 binary with credential in scope, \ + got query types: {query_types:?}" ); + // v1 emits only data_exfiltration; write_bypass is reserved. assert!( - query_types.contains("write_bypass"), - "expected write_bypass finding" + !query_types.contains("write_bypass"), + "write_bypass is a no-op in v1; got: {findings:?}" ); + // Every v1 finding is HIGH. assert!( - findings.iter().any(|f| matches!( - f.risk, - finding::RiskLevel::Critical | finding::RiskLevel::High - )), - "expected at least one critical/high finding" + findings + .iter() + .all(|f| matches!(f.risk, finding::RiskLevel::High)), + "v1 emits only HIGH; got: {findings:?}" ); } diff --git a/crates/openshell-prover/src/queries.rs b/crates/openshell-prover/src/queries.rs index 6a0c7f6a6..dad3a4a3d 100644 --- a/crates/openshell-prover/src/queries.rs +++ b/crates/openshell-prover/src/queries.rs @@ -2,20 +2,57 @@ // SPDX-License-Identifier: Apache-2.0 //! Verification queries: `check_data_exfiltration` and `check_write_bypass`. +//! +//! v1 calibration (see `architecture/plans/agentic-policy-approval-loop.md`): +//! the prover emits a finding only when the proposal shape is genuinely +//! unbounded for our model. The three rows that fire today: +//! +//! 1. **Link-local host** (`169.254.0.0/16`, `fe80::/10`) — emits regardless +//! of credential context. Cloud metadata endpoints (AWS IMDS, GCP metadata) +//! serve credentials, so the credential-presence model is fundamentally +//! wrong for them. +//! 2. **Bypass-L7 binary** (git smart-HTTP, ssh, nc) **with a credential in +//! scope for the host** — the L7 proxy cannot meaningfully inspect the +//! wire protocol even when scope looks tight, and an authenticated +//! privileged action is available. +//! 3. **L4-only endpoint** (no `protocol: rest|graphql`) **with a credential +//! in scope for the host** — no L7 inspection at all, and authenticated +//! privileged action is available. +//! +//! All emitted findings carry `RiskLevel::High`. The `Critical` variant is +//! retained in the enum but unused in v1; we'll introduce a tier when a +//! behavioral distinction earns it. + +use std::net::IpAddr; use z3::SatResult; -use crate::finding::{ExfilPath, Finding, FindingPath, RiskLevel, WriteBypassPath}; +use crate::finding::{ExfilPath, Finding, FindingPath, RiskLevel}; use crate::model::ReachabilityModel; -use crate::policy::PolicyIntent; -/// Check for data exfiltration paths from readable filesystem to writable -/// egress channels. -pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { - if model.policy.filesystem_policy.readable_paths().is_empty() { - return Vec::new(); +/// 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, } +} +/// Check for data exfiltration / privileged-action paths against the v1 +/// calibration table above. +/// +/// We deliberately do NOT gate on `filesystem_policy.readable_paths()` being +/// non-empty: most v1 risks (link-local IMDS, L4+credential authenticated +/// writes, bypass-binary + credential) don't require *readable* filesystem +/// content to be dangerous. The credential itself is the lever, not what's +/// in `/etc/`. +pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { let mut exfil_paths: Vec = Vec::new(); for bpath in &model.binary_paths { @@ -28,28 +65,53 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { let expr = model.can_exfil_via_endpoint(bpath, eid); if model.check_sat(&expr) == SatResult::Sat { - // Determine L7 status and mechanism - let ep_is_l7 = is_endpoint_l7_enforced(&model.policy, &eid.host, eid.port); + let host_is_link_local = is_link_local(&eid.host); + let has_credential = !model.credentials.credentials_for_host(&eid.host).is_empty(); + // Check the L7 enforcement of THIS specific rule (eid.policy_name), + // not any rule for the same host:port. Two rules can coexist on + // the same endpoint — one L7-scoped, one L4-only — and each + // must be evaluated on its own terms. Otherwise iteration order + // (HashMap) leaks into the verdict. + let ep_is_l7 = is_endpoint_in_rule_l7_enforced( + &model.policy, + &eid.policy_name, + &eid.host, + eid.port, + ); let bypass = cap.bypasses_l7(); - let (l7_status, mut mechanism) = if bypass { + // v1 emission table — see module docs. + let (l7_status, mut mechanism) = if host_is_link_local { + ( + "link_local".to_owned(), + format!( + "Link-local endpoint — {bpath} can reach the host's metadata range \ + (cloud-credential exfiltration territory regardless of declared scopes)" + ), + ) + } else if bypass && has_credential { ( "l7_bypassed".to_owned(), format!( - "{} — uses non-HTTP protocol, bypasses L7 inspection", + "{} — uses non-HTTP protocol, bypasses L7 inspection, and a credential \ + is in scope for this host", cap.description ), ) - } else if !ep_is_l7 { + } else if !ep_is_l7 && has_credential { ( "l4_only".to_owned(), format!( - "L4-only endpoint — no HTTP inspection, {bpath} can send arbitrary data" + "L4-only endpoint with a credential in scope — no HTTP inspection, \ + {bpath} can send arbitrary authenticated requests" ), ) } else { - // L7 is enforced and allows write — policy is - // working as intended. Not a finding. + // v1: any other SAT path is bounded enough that it + // doesn't earn a finding. Examples that fall here: + // - L7-enforced with bounded action set (working as intended) + // - L4-only with no credential in scope (no privileged action available) + // - bypass-L7 binary with no credential in scope (no auth to exercise) continue; }; @@ -74,15 +136,21 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { } let readable = model.policy.filesystem_policy.readable_paths(); + let n_readable = readable.len(); let has_l4_only = exfil_paths.iter().any(|p| p.l7_status == "l4_only"); let has_bypass = exfil_paths.iter().any(|p| p.l7_status == "l7_bypassed"); - let risk = if has_l4_only || has_bypass { - RiskLevel::Critical - } else { - RiskLevel::High - }; + let has_link_local = exfil_paths.iter().any(|p| p.l7_status == "link_local"); let mut remediation = Vec::new(); + if has_link_local { + remediation.push( + "Endpoint host is in a link-local range (cloud-metadata territory). \ + Sandboxes should not reach these endpoints — reaching them can return \ + host credentials the sandbox should not have. If access is truly \ + intended, the policy must be approved by a human operator." + .to_owned(), + ); + } if has_l4_only { remediation.push( "Add `protocol: rest` with specific L7 rules to L4-only endpoints \ @@ -93,8 +161,7 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { if has_bypass { remediation.push( "Binaries using non-HTTP protocols (git, ssh, nc) bypass L7 inspection. \ - Remove these binaries from the policy if write access is not intended, \ - or restrict credential scopes to read-only." + Remove these binaries from the policy if write access is not intended." .to_owned(), ); } @@ -108,10 +175,9 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { query: "data_exfiltration".to_owned(), title: "Data Exfiltration Paths Detected".to_owned(), description: format!( - "{n_paths} exfiltration path(s) found from {} readable filesystem path(s) to external endpoints.", - readable.len() + "{n_paths} path(s) flagged by v1 calibration ({n_readable} readable filesystem path(s) in scope)." ), - risk, + risk: RiskLevel::High, paths, remediation, accepted: false, @@ -119,88 +185,14 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { }] } -/// Check for write capabilities that bypass read-only policy intent. -pub fn check_write_bypass(model: &ReachabilityModel) -> Vec { - let mut bypass_paths: Vec = Vec::new(); - - for (policy_name, rule) in &model.policy.network_policies { - for ep in &rule.endpoints { - // Only check endpoints where the intent is read-only or L4-only - let intent = ep.intent(); - if !matches!(intent, PolicyIntent::ReadOnly) { - continue; - } - - for port in ep.effective_ports() { - for b in &rule.binaries { - let cap = model.binary_registry.get_or_unknown(&b.path); - - // Check: binary bypasses L7 and can write - if cap.bypasses_l7() && cap.can_write() { - let cred_actions = collect_credential_actions(model, &ep.host, &cap); - if !cred_actions.is_empty() - || model.credentials.credentials_for_host(&ep.host).is_empty() - { - bypass_paths.push(WriteBypassPath { - binary: b.path.clone(), - endpoint_host: ep.host.clone(), - endpoint_port: port, - policy_name: policy_name.clone(), - policy_intent: intent.to_string(), - bypass_reason: "l7_bypass_protocol".to_owned(), - credential_actions: cred_actions, - }); - } - } - - // Check: L4-only endpoint + binary can construct HTTP + credential has write - if !ep.is_l7_enforced() && cap.can_construct_http { - let cred_actions = collect_credential_actions(model, &ep.host, &cap); - if !cred_actions.is_empty() { - bypass_paths.push(WriteBypassPath { - binary: b.path.clone(), - endpoint_host: ep.host.clone(), - endpoint_port: port, - policy_name: policy_name.clone(), - policy_intent: intent.to_string(), - bypass_reason: "l4_only".to_owned(), - credential_actions: cred_actions, - }); - } - } - } - } - } - } - - if bypass_paths.is_empty() { - return Vec::new(); - } - - let n = bypass_paths.len(); - let paths: Vec = bypass_paths - .into_iter() - .map(FindingPath::WriteBypass) - .collect(); - - vec![Finding { - query: "write_bypass".to_owned(), - title: "Write Bypass Detected — Read-Only Intent Violated".to_owned(), - description: format!("{n} path(s) allow write operations despite read-only policy intent."), - risk: RiskLevel::High, - paths, - remediation: vec![ - "For L4-only endpoints: add `protocol: rest` with `access: read-only` \ - to enable HTTP method filtering." - .to_owned(), - "For L7-bypassing binaries (git, ssh, nc): remove them from the policy's \ - binary list if write access is not intended." - .to_owned(), - "Restrict credential scopes to read-only where possible.".to_owned(), - ], - accepted: false, - accepted_reason: String::new(), - }] +/// Reserved for future intent-aware write-bypass logic. +/// +/// v1 consolidates all emission into `check_data_exfiltration` per the +/// calibration table; this function returns empty so the public API stays +/// stable while we figure out what shape an intent-aware check should take +/// in v2. +pub fn check_write_bypass(_model: &ReachabilityModel) -> Vec { + Vec::new() } /// Run both verification queries. @@ -215,39 +207,69 @@ pub fn run_all_queries(model: &ReachabilityModel) -> Vec { // Helpers // --------------------------------------------------------------------------- -/// Check whether an endpoint in the policy is L7-enforced. -fn is_endpoint_l7_enforced(policy: &crate::policy::PolicyModel, host: &str, port: u16) -> bool { - for rule in policy.network_policies.values() { - for ep in &rule.endpoints { - if ep.host == host && ep.effective_ports().contains(&port) { - return ep.is_l7_enforced(); - } +/// Check whether the specific (`policy_name`, host, port) endpoint is +/// L7-enforced. +/// +/// Importantly, this is **per-rule**, not aggregated across the whole policy. +/// Two rules can target the same `host:port` with different enforcement (one +/// L7, one L4); each is evaluated on its own terms so the prover doesn't +/// leak `HashMap` iteration order into the verdict. +fn is_endpoint_in_rule_l7_enforced( + policy: &crate::policy::PolicyModel, + policy_name: &str, + host: &str, + port: u16, +) -> bool { + let Some(rule) = policy.network_policies.get(policy_name) else { + return false; + }; + for ep in &rule.endpoints { + if ep.host.eq_ignore_ascii_case(host) && ep.effective_ports().contains(&port) { + return ep.is_l7_enforced(); } } false } -/// Collect human-readable credential action descriptions for a host. -fn collect_credential_actions( - model: &ReachabilityModel, - host: &str, - _cap: &crate::registry::BinaryCapability, -) -> Vec { - let creds = model.credentials.credentials_for_host(host); - let api = model.credentials.api_for_host(host); - let mut actions = Vec::new(); - - for cred in &creds { - if let Some(api) = api { - for wa in api.write_actions_for_scopes(&cred.scopes) { - actions.push(format!("{} {} ({})", wa.method, wa.path, wa.action)); - } - } else { - actions.push(format!( - "credential '{}' has scopes: {:?}", - cred.name, cred.scopes - )); - } +// `collect_credential_actions` removed in v1 along with the original +// `check_write_bypass` logic. When intent-aware write-bypass detection is +// reintroduced, this helper (or its successor) will live here. + +// --------------------------------------------------------------------------- +// 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() { + // We don't DNS-resolve; hostname strings always return false. + assert!(!is_link_local("api.github.com")); + assert!(!is_link_local("metadata.google.internal")); + assert!(!is_link_local("")); } - actions } diff --git a/crates/openshell-prover/src/report.rs b/crates/openshell-prover/src/report.rs index 27207a6ae..900d7ba0d 100644 --- a/crates/openshell-prover/src/report.rs +++ b/crates/openshell-prover/src/report.rs @@ -39,6 +39,18 @@ fn compact_detail(finding: &Finding) -> String { } } let mut parts = Vec::new(); + if let Some(eps) = by_status.get("link_local") { + let mut sorted: Vec<&String> = eps.iter().collect(); + sorted.sort(); + parts.push(format!( + "link-local (cloud metadata): {}", + sorted + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } if let Some(eps) = by_status.get("l4_only") { let mut sorted: Vec<&String> = eps.iter().collect(); sorted.sort(); @@ -107,6 +119,25 @@ fn compact_detail(finding: &Finding) -> String { } } +// --------------------------------------------------------------------------- +// One-line shorthand (for embedding findings in other tools' output) +// --------------------------------------------------------------------------- + +/// Format a finding as a single uncolored line for embedding in other +/// human-facing surfaces (gateway `validation_result`, demo output, logs). +/// +/// Shape: `[] : ` — e.g. +/// `[HIGH] data_exfiltration: L4-only: api.github.com:443`. Falls back to +/// `[] ` when no detail is available. +pub fn finding_shorthand(finding: &Finding) -> String { + let detail = compact_detail(finding); + if detail.is_empty() { + format!("[{}] {}", risk_label(finding.risk), finding.query) + } else { + format!("[{}] {}: {detail}", risk_label(finding.risk), finding.query) + } +} + // --------------------------------------------------------------------------- // Risk formatting // --------------------------------------------------------------------------- @@ -349,6 +380,7 @@ fn render_exfil_paths(paths: &[FindingPath]) { for path in paths { if let FindingPath::Exfil(p) = path { let l7_display = match p.l7_status.as_str() { + "link_local" => format!("{}", "link-local".bold().red()), "l4_only" => format!("{}", "L4-only".red()), "l7_bypassed" => format!("{}", "bypassed".red()), "l7_allows_write" => format!("{}", "L7 write".yellow()), @@ -391,3 +423,79 @@ fn render_write_bypass_paths(paths: &[FindingPath]) { } println!(); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::finding::{ExfilPath, WriteBypassPath}; + + fn exfil_finding(l7_status: &str, host: &str, port: u16) -> Finding { + Finding { + query: "data_exfiltration".to_owned(), + title: "Data exfiltration possible".to_owned(), + description: String::new(), + risk: RiskLevel::High, + paths: vec![FindingPath::Exfil(ExfilPath { + binary: "/usr/bin/curl".to_owned(), + endpoint_host: host.to_owned(), + endpoint_port: port, + mechanism: String::new(), + policy_name: String::new(), + l7_status: l7_status.to_owned(), + })], + remediation: vec![], + accepted: false, + accepted_reason: String::new(), + } + } + + #[test] + fn finding_shorthand_renders_exfil_l4_only() { + let f = exfil_finding("l4_only", "api.github.com", 443); + assert_eq!( + finding_shorthand(&f), + "[HIGH] data_exfiltration: L4-only: api.github.com:443" + ); + } + + #[test] + fn finding_shorthand_renders_write_bypass() { + let f = Finding { + query: "write_bypass".to_owned(), + title: String::new(), + description: String::new(), + risk: RiskLevel::High, + paths: vec![FindingPath::WriteBypass(WriteBypassPath { + binary: "/usr/bin/curl".to_owned(), + endpoint_host: "api.github.com".to_owned(), + endpoint_port: 443, + policy_name: String::new(), + policy_intent: String::new(), + bypass_reason: "l4_only".to_owned(), + credential_actions: vec![], + })], + remediation: vec![], + accepted: false, + accepted_reason: String::new(), + }; + assert_eq!( + finding_shorthand(&f), + "[HIGH] write_bypass: L4-only (no inspection): api.github.com:443" + ); + } + + #[test] + fn finding_shorthand_falls_back_when_detail_empty() { + let f = Finding { + query: "unknown_query".to_owned(), + title: String::new(), + description: String::new(), + risk: RiskLevel::Critical, + paths: vec![], + remediation: vec![], + accepted: false, + accepted_reason: String::new(), + }; + assert_eq!(finding_shorthand(&f), "[CRITICAL] unknown_query"); + } +} diff --git a/crates/openshell-sandbox/src/skills/policy_advisor.md b/crates/openshell-sandbox/src/skills/policy_advisor.md index 8ca64f977..2307d1bbb 100644 --- a/crates/openshell-sandbox/src/skills/policy_advisor.md +++ b/crates/openshell-sandbox/src/skills/policy_advisor.md @@ -46,8 +46,12 @@ 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. Use L4 only for non-REST protocols or - when the client tunnels opaque traffic that OpenShell cannot inspect. +4. Prefer L7 REST rules for REST APIs. **Narrow L7 proposals against + inspectable hosts auto-approve without human review** (see Auto-approval + below). L4 grants for the same host with a credential in scope always + require human approval, so L7 is the agent-speed path. 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, exact method, and the smallest safe path. 6. Submit the proposal, save `accepted_chunk_ids` from the response, and @@ -119,10 +123,55 @@ A complete narrow REST-inspected rule looks like this: } ``` +## Auto-approval + +The gateway runs a deterministic prover on every proposal and auto-approves +when the proposal introduces no new findings. You get agent speed for +proposals the prover can bound; everything else escalates to a human. + +What the prover flags (and therefore keeps in human review): + +- **Link-local hosts** (`169.254.0.0/16`, `fe80::/10`). Cloud metadata + endpoints like `169.254.169.254` live here. **Never** propose access to + these — the proposal will always escalate, regardless of credentials. +- **L4 grants** (no `protocol: rest`) to a host where a sandbox credential + is in scope. The L4 layer has no inspection; combined with a privileged + credential, this is unbounded reachability. +- **Bypass-L7 binaries** (`/usr/bin/git`, `/usr/lib/git-core/git-remote-http`, + `/usr/bin/ssh`, `/usr/bin/nc`) bound to any host where a credential is in + scope. Wire protocols opaque to L7 inspection are unbounded by L7 scoping. + +What auto-approves: + +- L7 (REST) rules with explicit `method` + exact `path` against + inspectable hosts. +- Any proposal that adds no path the prover can reach with a privileged + binary against a credentialed host. + +If your proposal escalates and you need it sooner, narrow it: an L7 method/path +scope often turns an "L4 with credential" finding into "no new findings." + +## Refining an earlier auto-suggested rule + +When the sandbox observes a denial it cannot scope to L7 — e.g., a binary +trying to connect to a host the proxy hasn't seen at the application layer +— it auto-drafts a broad L4 proposal so the operator has something concrete +to look at. These mechanistic drafts are visible to you alongside any other +pending proposals. + +If you see a pending mechanistic L4 draft you can do better than, just +submit a refined L7 proposal for the same `(host, port, binary)`. The +gateway will automatically reject the mechanistic draft with reason +"superseded by chunk X" — no extra cleanup or `supersedes_chunk_id` needed. +The new submission wins by structural overlap. + ## Norms - Do not propose wildcard hosts such as `**` or `*.com`. - Do not propose `access: full` to fix a single denied REST request. +- Do not propose access to link-local addresses (`169.254.0.0/16`, + `fe80::/10`). Cloud-metadata endpoints there can hand out the host's + credentials. - Do not include query strings, tokens, credentials, or secret values in paths. - Explain uncertainty in `intent_summary` instead of widening the rule. diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 1f8e295a8..b6a52f4ef 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -48,13 +48,18 @@ use openshell_policy::{ serialize_sandbox_policy, }; use openshell_prover::{ - credentials::CredentialSet, model::build_model, policy::parse_policy_str, - queries::run_all_queries, registry::load_embedded_binary_registry, + 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}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use tonic::{Request, Response, Status}; @@ -96,6 +101,40 @@ fn emit_gateway_policy_audit_log( detail, version, policy_hash, + &[], + ); + info!( + target: OCSF_TARGET, + sandbox_id = %sandbox_id, + message = %message + ); +} + +/// 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. +fn emit_gateway_policy_auto_approve_audit_log( + sandbox_id: &str, + sandbox_name: &str, + detail: impl Into, + version: i64, + policy_hash: &str, + source: &str, +) { + let extra = [ + ("auto", "true".to_string()), + ("source", source.to_string()), + ("prover_delta", "empty".to_string()), + ]; + let message = build_gateway_policy_audit_message( + sandbox_id, + sandbox_name, + "approved", + detail, + version, + policy_hash, + &extra, ); info!( target: OCSF_TARGET, @@ -111,6 +150,7 @@ fn build_gateway_policy_audit_message( detail: impl Into, version: i64, policy_hash: &str, + extra_fields: &[(&str, String)], ) -> String { let ctx = SandboxContext { sandbox_id: sandbox_id.to_string(), @@ -132,6 +172,9 @@ fn build_gateway_policy_audit_message( if !policy_hash.is_empty() { builder = builder.unmapped("policy_hash", policy_hash.to_string()); } + for (key, value) in extra_fields { + builder = builder.unmapped(key, value.clone()); + } let event: OcsfEvent = builder.build(); event.format_shorthand() } @@ -309,125 +352,480 @@ fn summarize_draft_chunk_rule(chunk: &DraftChunkRecord) -> Result: ` +/// line per finding (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 scope_verdict = scope_verdict_for_rule(proposed_rule); - let merge_op = PolicyMergeOp::AddRule { rule_name: rule_name.to_string(), rule: proposed_rule.clone(), }; - let merged = match merge_policy(current_policy, &[merge_op]) { + let merged = match merge_policy(current_policy.clone(), &[merge_op]) { Ok(result) => result.policy, - Err(error) => { - return format!("failed: policy merge rejected ({error}); {scope_verdict}"); - } + Err(error) => return format!("merge failed: {}", one_line(&error.to_string())), }; - if let Err(error) = validate_policy_safety(&merged) { - return format!("failed: policy safety check rejected ({error}); {scope_verdict}"); + return format!("policy invalid: {}", one_line(&error.to_string())); } - if policy_uses_prover_unsupported_features(&merged) { - return format!( - "validation unavailable: prover does not model deny_rules yet; {scope_verdict}" - ); - } - - let yaml = match serialize_sandbox_policy(&merged) { - Ok(yaml) => yaml, + let merged_findings = match run_prover_findings(&merged, credentials) { + Ok(findings) => findings, Err(error) => { - return format!("validation unavailable: serialize policy failed ({error})"); + warn!(error = %error, "prover validation unavailable for merged policy"); + return "validation unavailable".to_string(); } }; - let prover_policy = match parse_policy_str(&yaml) { - Ok(policy) => policy, + // 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) => { - return format!("validation unavailable: parse policy failed ({error})"); + warn!(error = %error, "prover baseline run failed; treating baseline as empty"); + Vec::new() } }; - let registry = match load_embedded_binary_registry() { - Ok(registry) => registry, - Err(error) => { - return format!("validation unavailable: load prover registry failed ({error})"); + + 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( + 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; } - }; - let model = build_model(prover_policy, CredentialSet::default(), registry); - let findings = run_all_queries(&model); - if findings.is_empty() { - return format!("prover passed supported checks; {scope_verdict}"); + 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 { + match path { + FindingPath::Exfil(p) => format!( + "exfil|{}|{}:{}|{}", + p.binary, p.endpoint_host, p.endpoint_port, p.l7_status + ), + FindingPath::WriteBypass(p) => format!( + "writebypass|{}|{}:{}|{}", + p.binary, p.endpoint_host, p.endpoint_port, p.bypass_reason + ), } +} - let finding_summary = findings +/// 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 endpoint added to an existing `data_exfiltration` +/// finding) surfaces only the new evidence paths. +fn finding_delta(base: &[Finding], merged: &[Finding]) -> Vec { + let base_keys: HashSet<(String, String)> = base .iter() - .map(|finding| format!("{} {}", finding.risk, finding.query)) - .collect::>() - .join(", "); - format!( - "failed: prover found {} finding(s): {}; {}", - findings.len(), - finding_summary, - scope_verdict - ) + .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::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() + }); + } + delta } -fn policy_uses_prover_unsupported_features(policy: &ProtoSandboxPolicy) -> bool { - policy - .network_policies - .values() - .flat_map(|rule| &rule.endpoints) - .any(|endpoint| !endpoint.deny_rules.is_empty()) +/// 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("; ") } -fn scope_verdict_for_rule(rule: &NetworkPolicyRule) -> String { - let mut needs_human = Vec::new(); - let mut saw_exact_l7_rule = false; +/// 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 +/// proposals are stale." +/// +/// In practice this implements the supersede behavior for the +/// `mechanistic`→`agent_authored` refinement loop: when the agent submits a +/// narrow L7 proposal in response to a denial, any pending mechanistic L4 +/// draft for the same key gets auto-rejected here, without the agent or the +/// proto needing an explicit `supersedes_chunk_id` field. +/// +/// Failures (DB error, scan error) are logged via `warn!` and the function +/// returns silently. The new chunk's persistence has already succeeded; +/// failing this cleanup pass should not abort the submission flow. +async fn supersede_other_pending_chunks_for_endpoint( + state: &Arc, + sandbox_id: &str, + new_chunk_id: &str, + host: &str, + port: i32, + binary: &str, +) { + // Empty host/port/binary should not supersede anything — the matcher would + // accidentally cover unrelated chunks. Defensive skip. + if host.is_empty() || port == 0 || binary.is_empty() { + return; + } - for endpoint in &rule.endpoints { - if endpoint.protocol.trim().is_empty() { - needs_human.push("L4/no method-path scope"); - } - if endpoint.host.contains('*') { - needs_human.push("wildcard host"); + let pending = match state + .store + .list_draft_chunks(sandbox_id, Some("pending")) + .await + { + Ok(records) => records, + Err(err) => { + warn!( + sandbox_id = %sandbox_id, + error = %err, + "supersede scan failed; older pending chunks (if any) remain pending" + ); + return; } - if !endpoint.protocol.trim().is_empty() && endpoint.rules.is_empty() { - needs_human.push("L7 preset/no exact method-path"); + }; + + let now_ms = current_time_ms(); + for other in pending { + if other.id == new_chunk_id + || other.host != host + || other.port != port + || other.binary != binary + { + continue; } - for rule in &endpoint.rules { - let Some(allow) = rule.allow.as_ref() else { - needs_human.push("unsupported L7 rule shape"); - continue; - }; - let method = allow.method.trim(); - let path = allow.path.trim(); - if method.is_empty() || method == "*" { - needs_human.push("wildcard method"); - } - if path.is_empty() || path.contains('*') { - needs_human.push("wildcard path"); + let reason = format!("superseded by chunk {new_chunk_id}"); + match state + .store + .update_draft_chunk_status(&other.id, "rejected", Some(now_ms), Some(&reason)) + .await + { + Ok(_) => { + info!( + sandbox_id = %sandbox_id, + superseded_chunk = %other.id, + by_chunk = %new_chunk_id, + host = %host, + port = port, + binary = %binary, + "Auto-rejected pending chunk: superseded by newer submission for same (host, port, binary)" + ); } - if !method.is_empty() && method != "*" && !path.is_empty() && !path.contains('*') { - saw_exact_l7_rule = true; + Err(err) => { + warn!( + chunk_id = %other.id, + error = %err, + "supersede auto-reject failed; chunk remains pending" + ); } } } +} - needs_human.sort_unstable(); - needs_human.dedup(); - if needs_human.is_empty() && saw_exact_l7_rule { - "narrow L7 method/path scope".to_string() - } else if needs_human.is_empty() { - "needs human: no exact L7 method/path evidence".to_string() - } else { - format!("needs human: {}", needs_human.join(", ")) +/// If the just-submitted mechanistic chunk targets a `(host, port, binary)` +/// already covered by an approved `agent_authored` chunk, auto-reject the +/// mechanistic chunk on arrival. The agent has already handled this access +/// decision; the mechanistic draft would only add approval-queue noise. +/// +/// `agent_authored` submissions are NEVER self-rejected — that path remains +/// open for refinement. Only the mechanistic side is asymmetric. +async fn self_reject_mechanistic_if_already_covered( + state: &Arc, + sandbox_id: &str, + new_chunk_id: &str, + host: &str, + port: i32, + binary: &str, +) { + if host.is_empty() || port == 0 || binary.is_empty() { + return; + } + + let approved = match state + .store + .list_draft_chunks(sandbox_id, Some("approved")) + .await + { + Ok(records) => records, + Err(err) => { + warn!( + sandbox_id = %sandbox_id, + error = %err, + "approved-chunk scan for self-reject failed; mechanistic chunk remains pending" + ); + return; + } + }; + + // If any approved chunk for this sandbox already targets the same + // (host, port, binary), the mechanistic submission is redundant. + let covered_by = approved + .iter() + .find(|c| c.host == host && c.port == port && c.binary == binary); + let Some(covering) = covered_by else { + return; + }; + + let reason = format!( + "already covered by approved chunk {} (agent_authored or prior auto-approval)", + covering.id + ); + match state + .store + .update_draft_chunk_status( + new_chunk_id, + "rejected", + Some(current_time_ms()), + Some(&reason), + ) + .await + { + Ok(_) => { + info!( + sandbox_id = %sandbox_id, + chunk_id = %new_chunk_id, + covering_chunk = %covering.id, + host = %host, + port = port, + binary = %binary, + "Auto-rejected incoming mechanistic chunk: endpoint already covered by an approved chunk" + ); + } + Err(err) => { + warn!( + chunk_id = %new_chunk_id, + error = %err, + "mechanistic self-reject failed; chunk remains pending" + ); + } + } +} + +/// 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. +async fn auto_approve_chunk( + state: &Arc, + sandbox_id: &str, + sandbox_name: &str, + chunk_id: &str, + source: &str, +) -> Result<(), Status> { + // Same gate the human-driven approve paths apply: if a global policy is + // active, sandbox-scoped chunk approvals are meaningless because + // `GetSandboxConfig` prefers the global policy. Auto-approving here + // would persist a sandbox revision that the runtime silently ignores + // and leave a misleading "approved" chunk in the table. Bail before + // touching state; the calling site logs this as `warn!` and leaves the + // chunk pending. + require_no_global_policy(state).await?; + + let chunk = state + .store + .get_draft_chunk(chunk_id) + .await + .map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))? + .ok_or_else(|| Status::not_found("chunk not found"))?; + + // The chunk may have been superseded or rejected by something else + // between persist and auto-approve. Only approve from a pending state. + if chunk.status != "pending" { + return Ok(()); } + + let (version, hash) = merge_chunk_into_policy(state.store.as_ref(), sandbox_id, &chunk).await?; + let chunk_summary = summarize_draft_chunk_rule(&chunk)?; + + let now_ms = current_time_ms(); + state + .store + .update_draft_chunk_status(chunk_id, "approved", Some(now_ms), None) + .await + .map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?; + + state.sandbox_watch_bus.notify(sandbox_id); + + let source_label = if source.is_empty() { + "unspecified" + } else { + source + }; + emit_gateway_policy_auto_approve_audit_log( + sandbox_id, + sandbox_name, + format!( + "auto-approved: no new prover findings (source={source_label}) — chunk {chunk_id}: {chunk_summary}" + ), + version, + &hash, + source_label, + ); + + info!( + sandbox_id = %sandbox_id, + chunk_id = %chunk_id, + rule_name = %chunk.rule_name, + version = version, + policy_hash = %hash, + source = %source_label, + "Auto-approved chunk: no new prover findings" + ); + + 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, @@ -1519,8 +1917,28 @@ pub(super) async fn handle_submit_policy_analysis( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + // `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?; + // 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 + .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 current_version = state .store .get_draft_version(&sandbox_id) @@ -1562,15 +1980,16 @@ pub(super) async fn handle_submit_policy_analysis( .map(|b| b.path.clone()) .unwrap_or_default(); - let validation_result = if req.analysis_mode == "agent_authored" { - validation_result_for_agent_proposal( - current_policy.clone(), - &chunk.rule_name, - chunk.proposed_rule.as_ref().expect("checked above"), - ) - } else { - String::new() - }; + // 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, + chunk.proposed_rule.as_ref().expect("checked above"), + &credential_set, + ); let record = DraftChunkRecord { // The handler proposes an id; the store may swap it for an @@ -1604,7 +2023,7 @@ pub(super) async fn handle_submit_policy_analysis( } else { now_ms }, - validation_result, + validation_result: validation_result.clone(), rejection_reason: String::new(), }; // Mechanistic mode dedups N denials targeting the same endpoint @@ -1620,6 +2039,67 @@ pub(super) async fn handle_submit_policy_analysis( .await .map_err(|e| Status::internal(format!("persist draft chunk failed: {e}")))?; accepted += 1; + + // Implicit supersede: any other pending chunk for the same + // (host, port, binary) in this sandbox is now stale because this + // newer submission covers the same access decision. Auto-reject the + // older chunks with a clear reason. This is what lets the agent + // refine a mechanistic L4 draft into an L7 narrow proposal without + // any explicit `supersedes_chunk_id` plumbing — the gateway figures + // out the relationship by structural overlap. + supersede_other_pending_chunks_for_endpoint( + state, + &sandbox_id, + &effective_id, + &record.host, + record.port, + &record.binary, + ) + .await; + + // Asymmetric self-reject: if this is a mechanistic proposal that + // arrived AFTER an already-approved agent_authored chunk covered the + // same (host, port, binary), the mechanistic submission is + // redundant — the agent already handled it. Auto-reject so it + // doesn't pile up as approval-queue noise. Agent_authored + // submissions never self-reject; refinement is always allowed. + if req.analysis_mode == "mechanistic" { + self_reject_mechanistic_if_already_covered( + state, + &sandbox_id, + &effective_id, + &record.host, + record.port, + &record.binary, + ) + .await; + } + + // Auto-approval gate (proposer-agnostic): if the prover found nothing + // new in this proposal's delta, internally invoke the approve path. + // 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 validation_result == "prover: no new findings" + && let Err(err) = auto_approve_chunk( + state, + &sandbox_id, + sandbox.object_name(), + &effective_id, + &req.analysis_mode, + ) + .await + { + warn!( + chunk_id = %effective_id, + sandbox_id = %sandbox_id, + error = %err, + "auto-approval failed; chunk remains pending for human review" + ); + } + accepted_chunk_ids.push(effective_id); } @@ -4123,6 +4603,17 @@ 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. + state + .store + .put_message(&test_provider("github-pat", "github")) + .await + .unwrap(); let sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-draft-flow".to_string(), @@ -4132,6 +4623,7 @@ mod tests { }), spec: Some(SandboxSpec { policy: None, + providers: vec!["github-pat".to_string()], ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -4141,9 +4633,9 @@ mod tests { let sandbox_name = sandbox.object_name().to_string(); let proposed_rule = NetworkPolicyRule { - name: "allow_example".to_string(), + name: "allow_github".to_string(), endpoints: vec![NetworkEndpoint { - host: "api.example.com".to_string(), + host: "api.github.com".to_string(), port: 443, ..Default::default() }], @@ -4158,7 +4650,7 @@ mod tests { Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), proposed_chunks: vec![PolicyChunk { - rule_name: "allow_example".to_string(), + rule_name: "allow_github".to_string(), proposed_rule: Some(proposed_rule.clone()), rationale: "observed denied request".to_string(), confidence: 0.85, @@ -4191,6 +4683,9 @@ 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"); let chunk_id = draft_policy.chunks[0].id.clone(); @@ -4412,9 +4907,11 @@ mod tests { rejected.rejection_reason, guidance, "reviewer's free-form reason must round-trip into the chunk for agent readback" ); - // Non-agent-authored submissions keep validation_result empty; the - // gateway prover path is reserved for analysis_mode=agent_authored. - assert!(rejected.validation_result.is_empty()); + // 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"); } #[tokio::test] @@ -4499,28 +4996,44 @@ mod tests { .unwrap() .into_inner(); let verdict = &draft.chunks[0].validation_result; - assert!( - verdict.contains("prover passed"), - "expected prover pass verdict, got: {verdict}" + assert_eq!( + verdict, "prover: no new findings", + "exact L7 PUT against an inspected endpoint should not introduce \ + any new findings over baseline; got: {verdict}" ); - assert!( - verdict.contains("narrow L7 method/path scope"), - "expected narrow L7 scope verdict, got: {verdict}" + // Auto-approval gate: empty delta → status flips to approved without + // human action. This is the canonical happy path for agent speed. + assert_eq!( + draft.chunks[0].status, "approved", + "empty-delta agent-authored proposal must auto-approve; got status: {}", + draft.chunks[0].status ); } + /// Implicit supersede: when a refined agent-authored proposal lands for + /// the same `(host, port, binary)` as a pending mechanistic chunk, the + /// older mechanistic chunk is auto-rejected with a "superseded by + /// chunk X" reason. This is the refinement loop without a + /// `supersedes_chunk_id` field — structural overlap is enough. #[tokio::test] - async fn agent_authored_l4_proposal_gets_broad_scope_verdict() { + async fn agent_authored_submission_supersedes_pending_mechanistic_for_same_endpoint() { use openshell_core::proto::{ - FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, - SandboxSpec, + FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase, + SandboxPolicy, SandboxSpec, }; let state = test_server_state().await; - let sandbox_name = "agent-l4-verdict".to_string(); + // github provider attached so the mechanistic L4 lands a HIGH + // finding and stays pending. + state + .store + .put_message(&test_provider("github-pat", "github")) + .await + .unwrap(); + let sandbox_name = "supersede-flow".to_string(); let sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-agent-l4-verdict".to_string(), + id: "sb-supersede-flow".to_string(), name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), @@ -4534,6 +5047,7 @@ mod tests { }), ..Default::default() }), + providers: vec!["github-pat".to_string()], ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -4541,8 +5055,10 @@ mod tests { }; state.store.put_message(&sandbox).await.unwrap(); - let proposed_rule = NetworkPolicyRule { - name: "github_l4".to_string(), + // Step 1: mechanistic submits a broad L4 grant; the prover flags it + // HIGH, so it lands in pending. + let mechanistic_rule = NetworkPolicyRule { + name: "allow_api_github_com_443".to_string(), endpoints: vec![NetworkEndpoint { host: "api.github.com".to_string(), port: 443, @@ -4553,8 +5069,277 @@ mod tests { ..Default::default() }], }; - - handle_submit_policy_analysis( + let mechanistic_submit = handle_submit_policy_analysis( + &state, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "mechanistic".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "allow_api_github_com_443".to_string(), + proposed_rule: Some(mechanistic_rule), + rationale: "Allow /usr/bin/curl to connect to api.github.com:443.".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap() + .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. + let draft = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name.clone(), + status_filter: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + let mech = draft + .chunks + .iter() + .find(|c| c.id == mechanistic_chunk_id) + .expect("mechanistic chunk present"); + assert_eq!(mech.status, "pending"); + assert!(mech.validation_result.contains("[HIGH]")); + + // Step 2: the agent refines into a narrow L7 proposal for the SAME + // (host, port, binary). The new chunk auto-approves (empty delta) + // AND the older mechanistic one gets auto-rejected as superseded. + let agent_rule = NetworkPolicyRule { + name: "github_contents_put".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "PUT".to_string(), + path: "/repos/owner/name/contents/path/file.md".to_string(), + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + let agent_submit = handle_submit_policy_analysis( + &state, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_contents_put".to_string(), + proposed_rule: Some(agent_rule), + rationale: "refined L7 scope for the demo write".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + let agent_chunk_id = agent_submit.accepted_chunk_ids[0].clone(); + + let draft_after = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + + let agent = draft_after + .chunks + .iter() + .find(|c| c.id == agent_chunk_id) + .expect("agent chunk present"); + let mech_after = draft_after + .chunks + .iter() + .find(|c| c.id == mechanistic_chunk_id) + .expect("mechanistic chunk should still be visible (with new status)"); + + assert_eq!( + agent.status, "approved", + "agent-authored narrow L7 should auto-approve; got: {}", + agent.status + ); + assert_eq!( + mech_after.status, "rejected", + "older mechanistic chunk for same (host, port, binary) should be superseded; \ + got: {}", + mech_after.status + ); + assert!( + mech_after.rejection_reason.contains(&agent_chunk_id), + "rejection reason should cite the superseding chunk id; got: {}", + mech_after.rejection_reason + ); + assert!( + mech_after.rejection_reason.contains("superseded"), + "rejection reason should explain the supersede; got: {}", + mech_after.rejection_reason + ); + } + + /// 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. + #[tokio::test] + async fn mechanistic_proposal_with_empty_delta_also_auto_approves() { + use openshell_core::proto::{ + FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, + SandboxSpec, + }; + + let state = test_server_state().await; + let sandbox_name = "mechanistic-clean".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-mechanistic-clean".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + // No providers → no credential in scope for the proposed host. + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + 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, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "mechanistic".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "anon_l4".to_string(), + proposed_rule: Some(proposed_rule), + rationale: "Allow /usr/bin/curl to connect to example.com:443.".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap(); + + let draft = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + let verdict = &draft.chunks[0].validation_result; + assert_eq!(verdict, "prover: no new findings"); + assert_eq!( + draft.chunks[0].status, "approved", + "empty-delta mechanistic proposal must auto-approve (proposer-agnostic); \ + got status: {}", + draft.chunks[0].status + ); + } + + /// 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. + #[tokio::test] + async fn agent_authored_l4_proposal_with_credential_records_high_finding() { + use openshell_core::proto::{ + FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, + SandboxSpec, + }; + + let state = test_server_state().await; + // Attach a github provider so a credential is in scope for api.github.com. + state + .store + .put_message(&test_provider("github-pat", "github")) + .await + .unwrap(); + let sandbox_name = "agent-l4-with-cred".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-agent-l4-with-cred".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + providers: vec!["github-pat".to_string()], + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + let proposed_rule = NetworkPolicyRule { + name: "github_l4".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() + }], + }; + + handle_submit_policy_analysis( &state, Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), @@ -4582,28 +5367,38 @@ mod tests { .unwrap() .into_inner(); let verdict = &draft.chunks[0].validation_result; + let first_line = verdict.lines().next().unwrap_or(""); assert!( - verdict.contains("L4/no method-path scope"), - "expected L4 scope warning, got: {verdict}" + first_line.starts_with("prover: ") && first_line.contains("new finding"), + "expected first line like `prover: N new finding(s)`, got: {verdict}" ); assert!( - verdict.contains("failed: prover found"), - "expected prover finding for broad L4 curl access, got: {verdict}" + verdict.contains("[HIGH]"), + "v1 emits HIGH for L4 + credential in scope; got: {verdict}" + ); + assert!( + verdict.contains("api.github.com: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. #[tokio::test] - async fn agent_authored_policy_with_deny_rules_marks_validation_unavailable() { + async fn agent_authored_l4_proposal_without_credential_emits_no_finding() { use openshell_core::proto::{ - FilesystemPolicy, L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, - SandboxPhase, SandboxPolicy, SandboxSpec, + FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, + SandboxSpec, }; let state = test_server_state().await; - let sandbox_name = "agent-deny-unsupported".to_string(); + let sandbox_name = "agent-l4-no-cred".to_string(); let sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-agent-deny-unsupported".to_string(), + id: "sb-agent-l4-no-cred".to_string(), name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), @@ -4615,30 +5410,9 @@ mod tests { read_write: vec!["/sandbox".to_string()], ..Default::default() }), - network_policies: std::iter::once(( - "existing_deny_rule".to_string(), - NetworkPolicyRule { - name: "existing_deny_rule".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - protocol: "rest".to_string(), - deny_rules: vec![L7DenyRule { - method: "DELETE".to_string(), - path: "/repos/*".to_string(), - ..Default::default() - }], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }, - )) - .collect(), ..Default::default() }), + // No providers — credential set will be empty. ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -4647,19 +5421,94 @@ mod tests { state.store.put_message(&sandbox).await.unwrap(); let proposed_rule = NetworkPolicyRule { - name: "github_contents_write".to_string(), + name: "anon_l4".to_string(), endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), + host: "example.com".to_string(), port: 443, - protocol: "rest".to_string(), - enforcement: "enforce".to_string(), - rules: vec![L7Rule { - allow: Some(L7Allow { - method: "PUT".to_string(), - path: "/repos/org/repo/contents/demo/file.md".to_string(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + handle_submit_policy_analysis( + &state, + 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: "no privileged access available".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap(); + + let draft = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .await + .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}" + ); + } + + /// 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. + #[tokio::test] + async fn agent_authored_link_local_proposal_records_high_finding() { + use openshell_core::proto::{ + FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, + SandboxSpec, + }; + + let state = test_server_state().await; + let sandbox_name = "agent-link-local".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-agent-link-local".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], ..Default::default() }), - }], + ..Default::default() + }), + // Deliberately no provider — link-local should still fire. + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + let proposed_rule = NetworkPolicyRule { + name: "metadata_endpoint".to_string(), + endpoints: vec![NetworkEndpoint { + host: "169.254.169.254".to_string(), + port: 80, ..Default::default() }], binaries: vec![NetworkBinary { @@ -4674,9 +5523,9 @@ mod tests { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { - rule_name: "github_contents_write".to_string(), + rule_name: "metadata_endpoint".to_string(), proposed_rule: Some(proposed_rule), - rationale: "write one demo file".to_string(), + rationale: "agent is curious about IMDS".to_string(), ..Default::default() }], ..Default::default() @@ -4697,12 +5546,12 @@ mod tests { .into_inner(); let verdict = &draft.chunks[0].validation_result; assert!( - verdict.contains("validation unavailable"), - "expected unsupported-feature verdict, got: {verdict}" + verdict.contains("[HIGH]"), + "link-local proposal must emit HIGH regardless of credentials; got: {verdict}" ); assert!( - verdict.contains("deny_rules"), - "expected deny_rules limitation in verdict, got: {verdict}" + verdict.contains("169.254.169.254"), + "finding line must cite the link-local host; got: {verdict}" ); } @@ -4832,13 +5681,16 @@ mod tests { .unwrap() .into_inner(); let verdict = &draft.chunks[0].validation_result; + let first_line = verdict.lines().next().unwrap_or(""); assert!( - verdict.contains("validation unavailable"), - "expected provider-composed unsupported feature to affect validation, got: {verdict}" + 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("deny_rules"), - "expected provider-composed deny_rules limitation in verdict, got: {verdict}" + !verdict.contains("validation unavailable"), + "providers-v2 composition must not break the prover pipeline; \ + got: {verdict}" ); } @@ -5173,6 +6025,14 @@ mod tests { use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec}; let state = test_server_state().await; + // Attach a github provider so the L4 proposal below has a credential + // in scope and the prover emits a HIGH finding — keeps the chunk + // pending so this cross-sandbox approve check is reachable. + state + .store + .put_message(&test_provider("github-pat", "github")) + .await + .unwrap(); let sandbox_a = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-draft-owner".to_string(), @@ -5182,6 +6042,7 @@ mod tests { }), spec: Some(SandboxSpec { policy: None, + providers: vec!["github-pat".to_string()], ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -5205,9 +6066,9 @@ mod tests { state.store.put_message(&sandbox_b).await.unwrap(); let proposed_rule = NetworkPolicyRule { - name: "allow_example".to_string(), + name: "allow_github".to_string(), endpoints: vec![NetworkEndpoint { - host: "api.example.com".to_string(), + host: "api.github.com".to_string(), port: 443, ..Default::default() }], @@ -5317,6 +6178,7 @@ mod tests { "gateway merged incremental policy op: add-allow api.github.com:443 [POST /repos/*/issues]", 7, "sha256:testhash", + &[], ); assert_eq!( @@ -5325,6 +6187,50 @@ 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. + #[test] + fn build_gateway_policy_audit_message_carries_auto_approve_provenance() { + let extra = [ + ("auto", "true".to_string()), + ("source", "agent_authored".to_string()), + ("prover_delta", "empty".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", + 12, + "sha256:autohash", + &extra, + ); + assert!( + message.contains("CONFIG:APPROVED"), + "auto-approval reuses 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}" + ); + assert!( + message.contains("auto:true"), + "missing auto field: {message}" + ); + assert!( + message.contains("source:agent_authored"), + "missing source field: {message}" + ); + assert!( + message.contains("prover_delta:empty"), + "missing prover_delta field: {message}" + ); + } + #[test] fn summarize_cli_policy_merge_op_formats_rest_allow_rules() { let operation = PolicyMergeOp::AddAllowRules { diff --git a/examples/agent-driven-policy-management/README.md b/examples/agent-driven-policy-management/README.md index 3e6cdd9ed..ad55b4df8 100644 --- a/examples/agent-driven-policy-management/README.md +++ b/examples/agent-driven-policy-management/README.md @@ -82,6 +82,8 @@ reject with `--reason "scope to docs/ paths only"` and the agent reads | `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_CODEX_MODEL` | `gpt-5` (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` | ## What the agent sees @@ -103,16 +105,29 @@ with three parts, each with a different trust level: | `validation_result` (prover output) | gateway-side prover | trust signal — but this surface is in progress (see [RFC 0001](../../rfc/0001-agent-driven-policy-management.md)) | 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 `Validation:` for agent-authored -chunks, for example `prover passed supported checks; narrow L7 method/path -scope`, a prover finding plus `needs human: L4/no method-path scope`, or -`validation unavailable` when the proposed effective policy uses features the -prover does not model yet. The demo's `openshell rule approve-all` -auto-approves to keep the loop short — in a real session a developer reviews -the structured grant and the validation result before pressing `a`. For now, -**always approve based on the structured rule and control-plane validation, not -the agent's rationale.** +`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: + +```text +Validation: prover: no new findings +``` + +```text +Validation: prover: 1 new finding + [HIGH] data_exfiltration: L4-only: api.github.com:443 +``` + +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). + +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`. ## Going further diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index 4c6869379..7e8846afb 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -52,6 +52,8 @@ DEMO_FILE_PATH="${DEMO_FILE_DIR}/${DEMO_RUN_ID}.md" DEMO_SANDBOX_NAME="${DEMO_SANDBOX_NAME:-policy-demo-${DEMO_RUN_ID}}" DEMO_CODEX_PROVIDER_NAME="${DEMO_CODEX_PROVIDER_NAME:-codex-policy-demo-${DEMO_RUN_ID}}" DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-policy-demo-${DEMO_RUN_ID}}" +DEMO_CODEX_MODEL="${DEMO_CODEX_MODEL:-gpt-5}" +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 # reads the proposal, thinks, and decides. Bump the default to 30 min when @@ -220,7 +222,7 @@ resolve_github_token() { resolve_codex_auth() { [[ -f "${HOME}/.codex/auth.json" ]] || fail "missing local Codex sign-in; run: codex login" - export CODEX_AUTH_ACCESS_TOKEN CODEX_AUTH_REFRESH_TOKEN CODEX_AUTH_ACCOUNT_ID + export CODEX_AUTH_ACCESS_TOKEN CODEX_AUTH_REFRESH_TOKEN CODEX_AUTH_ACCOUNT_ID DEMO_CODEX_MODEL CODEX_AUTH_ACCESS_TOKEN="$(jq -r '.tokens.access_token // empty' "${HOME}/.codex/auth.json")" CODEX_AUTH_REFRESH_TOKEN="$(jq -r '.tokens.refresh_token // empty' "${HOME}/.codex/auth.json")" CODEX_AUTH_ACCOUNT_ID="$(jq -r '.tokens.account_id // empty' "${HOME}/.codex/auth.json")" @@ -331,7 +333,13 @@ render_payload() { -e "s|{{FILE_PATH}}|${DEMO_FILE_PATH}|g" \ -e "s|{{RUN_ID}}|${DEMO_RUN_ID}|g" \ "$TASK_TEMPLATE" > "${PAYLOAD_DIR}/agent-task.md" - cp "$SANDBOX_AGENT" "${PAYLOAD_DIR}/sandbox-agent.sh" + sed "s|DEMO_CODEX_MODEL=\"\${DEMO_CODEX_MODEL:-gpt-5}\"|DEMO_CODEX_MODEL=\"\${DEMO_CODEX_MODEL:-${DEMO_CODEX_MODEL}}\"|" \ + "$SANDBOX_AGENT" > "${PAYLOAD_DIR}/sandbox-agent.sh" + if [[ -n "$DEMO_CODEX_LOCAL_BIN" ]]; then + [[ -x "$DEMO_CODEX_LOCAL_BIN" ]] || fail "DEMO_CODEX_LOCAL_BIN is not executable: $DEMO_CODEX_LOCAL_BIN" + cp "$DEMO_CODEX_LOCAL_BIN" "${PAYLOAD_DIR}/codex" + chmod +x "${PAYLOAD_DIR}/codex" + fi cp "$POLICY_TEMPLATE" "$POLICY_FILE" } @@ -383,9 +391,14 @@ start_agent_sandbox() { } # Strip the rule_get output down to the lines a developer needs to make an -# informed approve/reject decision: rationale, validation, binary, endpoint. Filters the -# noisy fields (UUID, agent-generated rule_name, hardcoded confidence, -# duplicate Binaries). +# informed approve/reject decision: rationale, validation, binary, endpoint. +# Filters the noisy fields (UUID, agent-generated rule_name, hardcoded +# confidence, duplicate Binaries). +# +# `validation_result` can span multiple lines (`prover: N findings` followed +# by one indented finding line per detected risk), so when a `Validation:` +# label appears we also print any subsequent indented lines until we hit the +# next labeled field. # # `openshell rule get` colorizes labels with ANSI escapes; strip them before # parsing so the field-name match works in piped contexts. @@ -393,10 +406,13 @@ summarize_pending() { local pending="$1" sed 's/\x1b\[[0-9;]*m//g' "$pending" \ | awk ' - /Rationale:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } - /Validation:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } - /Binary:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } - /Endpoints:/ { sub(/^[[:space:]]*/, ""); print " " $0; next } + BEGIN { in_validation = 0 } + /Rationale:/ { in_validation = 0; sub(/^[[:space:]]*/, ""); print " " $0; next } + /Validation:/ { in_validation = 1; sub(/^[[:space:]]*/, ""); print " " $0; next } + /Binary:/ { in_validation = 0; sub(/^[[:space:]]*/, ""); print " " $0; next } + /Endpoints:/ { in_validation = 0; sub(/^[[:space:]]*/, ""); print " " $0; next } + in_validation && /^[[:space:]]{2,}\[/ { sub(/^[[:space:]]*/, ""); print " " $0; next } + { in_validation = 0 } ' } @@ -422,13 +438,16 @@ EOF info " • agent reads the skill, drafts a narrow ${DIM}addRule${RESET} for exactly that path" info " • agent POSTs to ${DIM}http://policy.local/v1/proposals${RESET}, saves the" info " returned ${DIM}accepted_chunk_ids[0]${RESET}" - info " • gateway merges the proposed rule with the current sandbox policy," - info " runs the prover, and stores a short validation verdict on the chunk" + info " • gateway runs the prover. ${BOLD}If the proposal introduces no new" + info " findings, the gateway auto-approves it without human action${RESET}" + info " — the audit trail records ${DIM}auto-approved: no new prover findings${RESET}" + info " (source = mechanistic or agent_authored). Proposals that flag a" + info " HIGH finding land in pending for human review instead." info " • agent calls ${DIM}GET /v1/proposals/{chunk_id}/wait?timeout=300${RESET}" - info " — one HTTP call that sleeps on a socket until the developer decides." - info " ${BOLD}Zero LLM tokens burn during this wait.${RESET}" + info " — auto-approvals return in ~1s. Human review pauses on a socket;" + info " ${BOLD}zero LLM tokens burn during the wait${RESET}." info "" - info "${DIM}Watching for the pending draft on the gateway...${RESET}" + info "${DIM}Watching for any proposal that didn't auto-approve...${RESET}" } # In DEMO_MANUAL_APPROVE mode, swap auto-approve for a human-in-the-loop pause. @@ -478,7 +497,10 @@ approve_pending_until_agent_exits() { approval_count=0 while true; do - # Agent finished? Drain its exit status and we're done. + # 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. if ! kill -0 "$AGENT_PID" >/dev/null 2>&1; then spin_clear if ! wait "$AGENT_PID"; then @@ -487,25 +509,36 @@ approve_pending_until_agent_exits() { fi AGENT_PID="" if (( approval_count == 0 )); then - fail "agent exited before any pending proposal appeared" + info "agent exited cleanly with zero escalations (all proposals auto-approved)" + else + info "agent exited after ${approval_count} escalation(s) approved on the demo's behalf" fi - info "agent exited after ${approval_count} approval(s)" return fi - # Anything pending? Approve and keep watching — the agent may - # redraft if a previous proposal didn't yield the access it needed. + # Anything pending? That means the gateway prover declined to + # auto-approve — a HIGH finding flagged the proposal. The demo + # approves it anyway (acting as a friendly reviewer) so the script + # can run end-to-end, but the same proposal in production would wait + # for a real human decision. if "$OPENSHELL_BIN" rule get "$DEMO_SANDBOX_NAME" --status pending >"$pending" 2>/dev/null \ && grep -q "Chunk:" "$pending" && grep -q "pending" "$pending"; then spin_clear info "" - info "${GREEN}proposal received:${RESET}" + info "${GREEN}escalation: human review required (proposal did not auto-approve)${RESET}" summarize_pending "$pending" if [[ "$DEMO_MANUAL_APPROVE" == "1" ]]; then approve_manually "$pending" else - step "Approving — the agent's /wait will return within ~1s" + info "" + info " ${BOLD}↑ this is what you're approving:${RESET}" + info " • the structured rule above (Endpoints + Binary) is the contract" + info " • the Validation line carries the prover's verdict — read it before approving" + info "" + spin_wait "letting the proposal land before approving" 2 + spin_clear + step "Approving on behalf of the demo — the agent's /wait will return within ~1s" "$OPENSHELL_BIN" rule approve-all "$DEMO_SANDBOX_NAME" \ | awk '/approved/ { print " " $0 }' fi diff --git a/examples/agent-driven-policy-management/policy.template.yaml b/examples/agent-driven-policy-management/policy.template.yaml index e920277b5..de0d27abb 100644 --- a/examples/agent-driven-policy-management/policy.template.yaml +++ b/examples/agent-driven-policy-management/policy.template.yaml @@ -5,7 +5,6 @@ # # The agent inside the sandbox can: # - reach Codex's model and auth endpoints (codex) -# - clone Codex plugin repos read-only (codex_plugins) # - read api.github.com via curl (github_api_readonly) # # The agent CANNOT write to GitHub yet. That's the proposal it has to draft @@ -35,28 +34,10 @@ network_policies: - { host: ab.chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } binaries: - { path: /usr/bin/codex } + - { path: /sandbox/payload/codex } - { path: /usr/bin/node } - { path: "/usr/lib/node_modules/@openai/**" } - codex_plugins: - name: codex-plugins - endpoints: - - host: github.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: - method: GET - path: "/openai/plugins.git/info/refs*" - - allow: - method: POST - path: "/openai/plugins.git/git-upload-pack" - binaries: - - { path: /usr/bin/git } - - { path: /usr/lib/git-core/git-remote-http } - - { path: "/usr/lib/node_modules/@openai/**" } - github_api_readonly: name: github-api-readonly endpoints: diff --git a/examples/agent-driven-policy-management/sandbox-agent.sh b/examples/agent-driven-policy-management/sandbox-agent.sh index 052535c35..83fad813e 100755 --- a/examples/agent-driven-policy-management/sandbox-agent.sh +++ b/examples/agent-driven-policy-management/sandbox-agent.sh @@ -74,9 +74,20 @@ cd "$WORK" # compare runs. DEMO_CODEX_REASONING="${DEMO_CODEX_REASONING:-low}" -exec codex exec \ +# Pin the model to one that ChatGPT-account Codex users can reach. Codex's +# default (`gpt-5.2-codex`) is API-account-only and fails ChatGPT-auth with +# `400 invalid_request_error: model not supported`. Override with +# DEMO_CODEX_MODEL if your account supports something better. +DEMO_CODEX_MODEL="${DEMO_CODEX_MODEL:-gpt-5}" +CODEX_BIN="${CODEX_BIN:-codex}" +if [[ -x /sandbox/payload/codex ]]; then + CODEX_BIN="/sandbox/payload/codex" +fi + +exec "$CODEX_BIN" exec \ --skip-git-repo-check \ --sandbox danger-full-access \ --ephemeral \ + -c "model=\"${DEMO_CODEX_MODEL}\"" \ -c "model_reasoning_effort=\"${DEMO_CODEX_REASONING}\"" \ "$(cat /sandbox/payload/agent-task.md)" From 216bb35757318f199ed041102ce7be711f578bd6 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Fri, 22 May 2026 08:25:53 -0700 Subject: [PATCH 3/6] feat(policy): refine agentic approval demo Signed-off-by: Alexander Watson --- architecture/security-policy.md | 35 +- crates/openshell-cli/src/main.rs | 73 +++ crates/openshell-cli/src/run.rs | 2 + .../sandbox_create_lifecycle_integration.rs | 9 + crates/openshell-policy/src/merge.rs | 213 +++++- crates/openshell-prover/src/finding.rs | 8 +- crates/openshell-prover/src/lib.rs | 11 +- crates/openshell-prover/src/queries.rs | 266 +++++++- crates/openshell-prover/src/report.rs | 36 +- .../src/skills/policy_advisor.md | 87 ++- crates/openshell-server/src/grpc/policy.rs | 611 +++++++++++++++++- .../agent-driven-policy-management/README.md | 2 +- .../agent-task.md | 98 ++- .../agent-driven-policy-management/demo.sh | 269 ++++---- .../policy.template.yaml | 42 +- .../sandbox-agent.sh | 27 +- proto/openshell.proto | 14 + 17 files changed, 1551 insertions(+), 252 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index f297eb2ad..0446d3fac 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -77,12 +77,17 @@ agent-authored via `policy.local`); the gateway is the single referee. 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).** If the delta is empty - (`prover: no new findings`), 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` as unmapped fields, with message text - `"auto-approved: no new prover findings"` — never `safe`. +3. **Auto-approval gate (proposer-agnostic, opt-in per sandbox).** Auto-approval + fires when *both* (a) the prover delta is empty (`prover: no new findings`) + AND (b) the sandbox sets `spec.proposal_approval_mode = "auto"`. 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` as unmapped fields, with message text + `"auto-approved: no new prover findings"` — never `safe`. The opt-in gate + preserves OpenShell's default-deny posture: sandboxes that leave + `proposal_approval_mode` unset (proto3 default of `""`, treated as + `"manual"`) keep every proposal in `pending` for human review, even when + the prover sees no findings. 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 @@ -90,7 +95,9 @@ agent-authored via `policy.local`); the gateway is the single referee. L7) without an explicit `supersedes_chunk_id` field. 5. **Escalation.** Anything else lands in `pending` for human review. -The v1 prover calibration emits `HIGH` findings (the only severity used) on: +The v1 prover calibration emits two severities, both blocking auto-approval: + +**`HIGH`** (cases the prover cannot bound): - **Link-local endpoints** (`169.254.0.0/16`, `fe80::/10`), unconditionally — covers cloud metadata endpoints (AWS IMDS, GCP metadata) which serve @@ -99,6 +106,20 @@ The v1 prover calibration emits `HIGH` findings (the only severity used) on: - **Bypass-L7 binaries** (`git-remote-http`, `ssh`, `nc`) bound to a host where a sandbox credential is in scope. +**`MEDIUM`** (bounded but authenticated; deserves human eyes for the +*action*, not the *reach*): + +- **Narrow L7 rule** (`protocol: rest`, allow list with specific + method/path) bound to a host where a sandbox credential is in scope. + The L7 proxy bounds *what* the binary can do, but the bounded action + is still authenticated and potentially destructive (PUT, DELETE, + POST that mutates). v1 defers semantic judgment to the human + reviewer; future calibration may distinguish read methods from + mutating ones. + +Severity does not change the auto-approval gate — any finding blocks +auto-approval. MEDIUM exists for audit/UI triage signal. + "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. v1 does not model diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index ca242be32..fccf069ac 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1030,6 +1030,11 @@ enum DoctorCommands { } #[derive(Subcommand, Debug)] +// `Create` carries enough optional fields to be ~3x larger than the next +// variant; boxing it would obscure the clap derive ergonomics for one +// (rare) enum allocation per parse, which isn't worth the readability +// cost. +#[allow(clippy::large_enum_variant)] enum SandboxCommands { /// Create a sandbox. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] @@ -1138,6 +1143,18 @@ enum SandboxCommands { #[arg(long = "label")] labels: 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, @@ -2383,6 +2400,7 @@ async fn main() -> Result<()> { auto_providers, no_auto_providers, labels, + approval_mode, command, } => { // Resolve --tty / --no-tty into an Option override. @@ -2451,6 +2469,7 @@ async fn main() -> Result<()> { tty_override, auto_providers_override, &labels_map, + &approval_mode, &tls, )) .await?; @@ -3653,6 +3672,60 @@ 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:?}"), + } + } + + /// `--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" + ); + } + #[test] fn sandbox_create_resource_flags_parse() { let cli = Cli::try_parse_from([ diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 78ef8f305..561aa6557 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1622,6 +1622,7 @@ pub async fn sandbox_create( tty_override: Option, auto_providers_override: Option, labels: &HashMap, + approval_mode: &str, tls: &TlsOptions, ) -> Result<()> { if editor.is_some() && !command.is_empty() { @@ -1695,6 +1696,7 @@ pub async fn sandbox_create( policy, providers: configured_providers, template, + proposal_approval_mode: approval_mode.to_string(), ..SandboxSpec::default() }), name: name.unwrap_or_default().to_string(), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 1ad00dd6e..52f58fe13 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -739,6 +739,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -780,6 +781,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -865,6 +867,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -917,6 +920,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -961,6 +965,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1001,6 +1006,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1045,6 +1051,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { Some(true), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1089,6 +1096,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1133,6 +1141,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await diff --git a/crates/openshell-policy/src/merge.rs b/crates/openshell-policy/src/merge.rs index c01445b11..60da5e4f1 100644 --- a/crates/openshell-policy/src/merge.rs +++ b/crates/openshell-policy/src/merge.rs @@ -392,17 +392,36 @@ fn add_rule( incoming_rule.name = rule_name.to_string(); } + // Endpoint-overlap fallback: when a chunk arrives with a new rule_name + // that doesn't already exist, fold it into a same-host/port rule if one + // is present. This is intentional for user-authored policies (incremental + // refinements live under one rule name). + // + // Provider-injected rules (`_provider_*` — see `compose.rs::provider_rule_name`) + // are deliberately EXCLUDED from this fallback. Provider profiles supply a + // baseline layer that should stay separate from agent/user contributions; + // merging an agent's narrow proposal into a provider's broad rule would + // (a) expand the provider rule's `access` shorthand into wildcard + // `path: "**"` rules at the prover's input, masking the agent's narrow + // scope behind the existing broad coverage, and (b) silently widen the + // provider rule's binary list. The agent's contribution is kept on its + // own rule key, the prover sees the actual narrow proposal, and the + // reviewer gets honest signal about what's being added. let target_key = if policy.network_policies.contains_key(rule_name) { Some(rule_name.to_string()) } else { let mut keys: Vec<_> = policy.network_policies.keys().cloned().collect(); keys.sort(); - keys.into_iter().find(|key| { - policy - .network_policies - .get(key) - .is_some_and(|existing_rule| rules_share_endpoint(existing_rule, &incoming_rule)) - }) + keys.into_iter() + .filter(|k| !k.starts_with("_provider_")) + .find(|key| { + policy + .network_policies + .get(key) + .is_some_and(|existing_rule| { + rules_share_endpoint(existing_rule, &incoming_rule) + }) + }) }; if let Some(key) = target_key { @@ -619,15 +638,28 @@ fn find_endpoint_mut<'a>( host: &str, port: u32, ) -> Option<&'a mut NetworkEndpoint> { + // `_provider_*` rules are excluded from this lookup for the same reason + // they're excluded from `add_rule`'s endpoint-overlap fallback: callers + // (`AddAllowRules`, `AddDenyRules`) must not mutate provider-injected + // rules in place. If the operation should target a provider rule, the + // caller should reference it by its exact name through the merge ops + // that take a `rule_name`. Defense-in-depth: even if a future caller + // accidentally passes a composed policy here, `AddAllowRules` would no + // longer be able to expand a provider rule's `access` shorthand into + // wildcard `path: "**"` rules (which would mask the prover's narrowness + // verdict on agent contributions). let mut keys: Vec<_> = policy.network_policies.keys().cloned().collect(); keys.sort(); - let target_key = keys.into_iter().find(|key| { - policy.network_policies.get(key).is_some_and(|rule| { - rule.endpoints - .iter() - .any(|endpoint| endpoint_matches_host_port(endpoint, host, port)) - }) - })?; + let target_key = keys + .into_iter() + .filter(|k| !k.starts_with("_provider_")) + .find(|key| { + policy.network_policies.get(key).is_some_and(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint_matches_host_port(endpoint, host, port)) + }) + })?; policy .network_policies @@ -1571,4 +1603,159 @@ mod tests { .contains_key("allow_api_example_com_443") ); } + + /// Provider-injected rules (`_provider_*`) are excluded from the + /// endpoint-overlap fallback: an agent chunk for the same `(host, port)` + /// as a provider rule lands as its own key instead of being merged into + /// the provider's rule. This keeps agent contributions honestly narrow + /// (no silent expansion via the provider rule's `access` shorthand) and + /// preserves binary-list separation. + #[test] + fn add_rule_does_not_merge_agent_chunk_into_provider_rule() { + use crate::compose::{ProviderPolicyLayer, compose_effective_policy}; + use openshell_core::proto::SandboxPolicy; + + // Compose a policy where the github provider profile contributes a + // `_provider_*` rule for api.github.com with `access: read-write` + // and gh/git binaries. + let provider_rule = NetworkPolicyRule { + name: "_provider_work_github".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "read-write".to_string(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/gh".to_string(), + ..Default::default() + }], + }; + let composed = compose_effective_policy( + &SandboxPolicy::default(), + &[ProviderPolicyLayer { + rule_name: "_provider_work_github".to_string(), + rule: provider_rule, + }], + ); + assert!( + composed + .network_policies + .contains_key("_provider_work_github"), + "precondition: provider rule must be present in baseline" + ); + + // Agent submits a narrow PUT rule targeting the same host/port via + // curl. Without the filter, this would merge into the provider rule. + let agent_rule = NetworkPolicyRule { + name: "github_contents_put".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![rest_rule("PUT", "/repos/owner/repo/contents/file.md")], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + let result = merge_policy( + composed, + &[PolicyMergeOp::AddRule { + rule_name: "github_contents_put".to_string(), + rule: agent_rule, + }], + ) + .expect("merge should succeed"); + + // The agent's chunk lands as its own rule key. + assert!( + result + .policy + .network_policies + .contains_key("github_contents_put"), + "agent chunk must land as a separate rule (not merged into the provider rule); \ + got keys: {:?}", + result.policy.network_policies.keys().collect::>() + ); + + // The provider rule is unchanged: still has only gh as a binary + // (no silent broadening), still has the read-write shorthand + // intact (no preset expansion into wildcard paths). + let provider_rule_after = result + .policy + .network_policies + .get("_provider_work_github") + .expect("provider rule must still be present"); + assert_eq!( + provider_rule_after.binaries.len(), + 1, + "provider rule's binary list must NOT have been merged with the agent's binaries" + ); + assert_eq!(provider_rule_after.binaries[0].path, "/usr/bin/gh"); + assert_eq!( + provider_rule_after.endpoints[0].access, "read-write", + "provider rule's `access` shorthand must remain intact" + ); + assert!( + provider_rule_after.endpoints[0].rules.is_empty(), + "provider rule must NOT have had its access expanded into explicit wildcard rules" + ); + + // The agent's rule retains its narrow scope. + let agent_rule_after = &result.policy.network_policies["github_contents_put"]; + assert_eq!(agent_rule_after.binaries[0].path, "/usr/bin/curl"); + assert_eq!(agent_rule_after.endpoints[0].rules.len(), 1); + } + + /// Non-provider rules still merge by endpoint overlap when the incoming + /// `rule_name` doesn't match an existing key. This preserves the + /// long-standing behavior for user-authored and mechanistic chunks. + #[test] + fn add_rule_still_merges_user_chunk_into_user_rule_by_endpoint_overlap() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "custom_github".to_string(), + rule_with_endpoint("custom_github", "api.github.com", 443), + ); + + let incoming = NetworkPolicyRule { + name: "ignored_when_merging".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + let result = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "different_name".to_string(), + rule: incoming, + }], + ) + .expect("merge should succeed"); + + // No new rule entry was created — the chunk merged into the + // existing user rule via endpoint overlap. + assert!( + !result + .policy + .network_policies + .contains_key("different_name"), + "user-authored rule overlap should still merge (no new key); \ + got keys: {:?}", + result.policy.network_policies.keys().collect::>() + ); + let merged = &result.policy.network_policies["custom_github"]; + assert!( + merged.binaries.iter().any(|b| b.path == "/usr/bin/curl"), + "user rule should have absorbed the incoming curl binary" + ); + } } diff --git a/crates/openshell-prover/src/finding.rs b/crates/openshell-prover/src/finding.rs index ab4d4f47f..28e0209df 100644 --- a/crates/openshell-prover/src/finding.rs +++ b/crates/openshell-prover/src/finding.rs @@ -6,8 +6,13 @@ use std::fmt; /// Severity level for a finding. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// +/// Ordering reflects risk magnitude: `Critical > High > Medium`. v1 emits +/// `High` and `Medium`; `Critical` is retained for future use without a +/// behavioral distinction yet attached to it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum RiskLevel { + Medium, High, Critical, } @@ -15,6 +20,7 @@ pub enum RiskLevel { impl fmt::Display for RiskLevel { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Medium => write!(f, "MEDIUM"), Self::High => write!(f, "HIGH"), Self::Critical => write!(f, "CRITICAL"), } diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index b89d0897b..19b06d716 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -186,12 +186,13 @@ filesystem_policy: !query_types.contains("write_bypass"), "write_bypass is a no-op in v1; got: {findings:?}" ); - // Every v1 finding is HIGH. + // v1 emits HIGH and MEDIUM; Critical is reserved for future use. assert!( - findings - .iter() - .all(|f| matches!(f.risk, finding::RiskLevel::High)), - "v1 emits only HIGH; got: {findings:?}" + findings.iter().all(|f| matches!( + f.risk, + finding::RiskLevel::High | finding::RiskLevel::Medium + )), + "v1 emits HIGH and MEDIUM only; got: {findings:?}" ); } diff --git a/crates/openshell-prover/src/queries.rs b/crates/openshell-prover/src/queries.rs index dad3a4a3d..9c0a8f57e 100644 --- a/crates/openshell-prover/src/queries.rs +++ b/crates/openshell-prover/src/queries.rs @@ -4,8 +4,9 @@ //! Verification queries: `check_data_exfiltration` and `check_write_bypass`. //! //! v1 calibration (see `architecture/plans/agentic-policy-approval-loop.md`): -//! the prover emits a finding only when the proposal shape is genuinely -//! unbounded for our model. The three rows that fire today: +//! the prover emits a finding any time a credential is in scope for the +//! proposed endpoint, plus the categorical link-local floor. The four rows +//! that fire today: //! //! 1. **Link-local host** (`169.254.0.0/16`, `fe80::/10`) — emits regardless //! of credential context. Cloud metadata endpoints (AWS IMDS, GCP metadata) @@ -18,10 +19,25 @@ //! 3. **L4-only endpoint** (no `protocol: rest|graphql`) **with a credential //! in scope for the host** — no L7 inspection at all, and authenticated //! privileged action is available. +//! 4. **L7-enforced endpoint with a credential in scope for the host** — +//! even bounded actions can be destructive when authenticated +//! (e.g., `PUT /repos/.../contents/...` overwrites arbitrary files). +//! v1 defers to human judgment for any credentialed action because the +//! prover models *credential exposure surface*, not *action semantics*. +//! A future calibration may distinguish read methods from mutating ones +//! once we have real-workload signal; until then, credential in scope = +//! human review. //! -//! All emitted findings carry `RiskLevel::High`. The `Critical` variant is -//! retained in the enum but unused in v1; we'll introduce a tier when a -//! behavioral distinction earns it. +//! Severity: +//! +//! - Rows 1–3 (link-local, bypass+credential, L4+credential) emit +//! `RiskLevel::High`. These are cases the prover cannot bound. +//! - Row 4 (L7-narrow+credential) emits `RiskLevel::Medium`. The reach is +//! bounded; the *action* (authenticated mutation) is what needs eyes. +//! +//! Severity does not change the auto-approval gate — any finding blocks +//! auto-approval. MEDIUM exists for audit/UI triage signal. The +//! `RiskLevel::Critical` variant is retained for future use; v1 never emits it. use std::net::IpAddr; @@ -78,6 +94,12 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { &eid.host, eid.port, ); + let ep_is_narrow = is_endpoint_in_rule_narrowly_bounded( + &model.policy, + &eid.policy_name, + &eid.host, + eid.port, + ); let bypass = cap.bypasses_l7(); // v1 emission table — see module docs. @@ -98,20 +120,36 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { cap.description ), ) - } else if !ep_is_l7 && has_credential { + } else if has_credential && (!ep_is_l7 || !ep_is_narrow) { + // L4-only OR L7-but-effectively-unbounded (access: full, + // wildcard method, wildcard path) — both collapse to + // "credentialed reach the prover cannot narrow." HIGH. ( "l4_only".to_owned(), format!( - "L4-only endpoint with a credential in scope — no HTTP inspection, \ - {bpath} can send arbitrary authenticated requests" + "Endpoint with a credential in scope and no effective method/path bound \ + ({bpath} can send arbitrary authenticated requests)" + ), + ) + } else if ep_is_l7 && has_credential { + // ep_is_l7 && ep_is_narrow — narrow L7 method/path with + // a credential in scope. MEDIUM: bounded reach, but + // authenticated action that may be destructive. + ( + "l7_credentialed".to_owned(), + format!( + "L7-enforced endpoint with narrow method/path bounds and a credential in \ + scope — the bounded action set is authenticated, and {bpath} can execute \ + potentially destructive mutations against the host's API" ), ) } else { - // v1: any other SAT path is bounded enough that it - // doesn't earn a finding. Examples that fall here: - // - L7-enforced with bounded action set (working as intended) - // - L4-only with no credential in scope (no privileged action available) - // - bypass-L7 binary with no credential in scope (no auth to exercise) + // v1: any other SAT path has no credential in scope, so + // no privileged action is available. Examples that fall + // here: + // - L4-only with no credential in scope + // - L7-enforced with no credential in scope + // - bypass-L7 binary with no credential in scope continue; }; @@ -140,6 +178,7 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { let has_l4_only = exfil_paths.iter().any(|p| p.l7_status == "l4_only"); let has_bypass = exfil_paths.iter().any(|p| p.l7_status == "l7_bypassed"); let has_link_local = exfil_paths.iter().any(|p| p.l7_status == "link_local"); + let has_l7_credentialed = exfil_paths.iter().any(|p| p.l7_status == "l7_credentialed"); let mut remediation = Vec::new(); if has_link_local { @@ -165,24 +204,64 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { .to_owned(), ); } + if has_l7_credentialed { + remediation.push( + "Endpoint has a credential in scope. Even with narrow L7 method/path \ + bounds, authenticated actions can be destructive (writes, deletes, \ + config changes). A human reviewer should confirm the intent." + .to_owned(), + ); + } remediation .push("Restrict filesystem read access to only the paths the agent needs.".to_owned()); - let paths: Vec = exfil_paths.into_iter().map(FindingPath::Exfil).collect(); - - let n_paths = paths.len(); - vec![Finding { - query: "data_exfiltration".to_owned(), - title: "Data Exfiltration Paths Detected".to_owned(), - description: format!( - "{n_paths} path(s) flagged by v1 calibration ({n_readable} readable filesystem path(s) in scope)." - ), - risk: RiskLevel::High, - paths, - remediation, - accepted: false, - accepted_reason: String::new(), - }] + // Split paths by severity tier. Two tiers in v1: HIGH for paths the + // model cannot bound (link-local, L4+credential, bypass-L7+credential), + // MEDIUM for L7-enforced+credential (bounded but authenticated, deserves + // human eyes but not the same kind of red flag). Splitting into separate + // Findings keeps the audit honest — a reviewer sees the worst tier on + // its own line, can't be misled by a roll-up. + let (l7_cred_paths, high_paths): (Vec<_>, Vec<_>) = exfil_paths + .into_iter() + .partition(|p| p.l7_status == "l7_credentialed"); + + let mut findings = Vec::new(); + + if !high_paths.is_empty() { + let paths: Vec = high_paths.into_iter().map(FindingPath::Exfil).collect(); + let n_paths = paths.len(); + findings.push(Finding { + query: "data_exfiltration".to_owned(), + title: "Data Exfiltration Paths Detected".to_owned(), + description: format!( + "{n_paths} path(s) flagged by v1 calibration ({n_readable} readable filesystem path(s) in scope)." + ), + risk: RiskLevel::High, + paths, + remediation: remediation.clone(), + accepted: false, + accepted_reason: String::new(), + }); + } + + if !l7_cred_paths.is_empty() { + let paths: Vec = l7_cred_paths.into_iter().map(FindingPath::Exfil).collect(); + let n_paths = paths.len(); + findings.push(Finding { + query: "data_exfiltration".to_owned(), + title: "Credentialed L7 Access — Human Review Recommended".to_owned(), + description: format!( + "{n_paths} L7-bounded path(s) with a credential in scope. The action set is narrow but authenticated." + ), + risk: RiskLevel::Medium, + paths, + remediation, + accepted: false, + accepted_reason: String::new(), + }); + } + + findings } /// Reserved for future intent-aware write-bypass logic. @@ -231,6 +310,57 @@ fn is_endpoint_in_rule_l7_enforced( false } +/// Whether the specific (`policy_name`, host, port) endpoint is L7-enforced +/// AND its allow set is **actually narrow** in both method and path axes. +/// +/// L7 enforcement with `access: full` (or rules containing `method: "*"` / +/// `path: "**"`) is L4-equivalent in reachability — the L7 protocol annotation +/// doesn't bound what the binary can do, so a credentialed L7+full proposal +/// should be flagged the same way as L4+credential (HIGH), not as a narrow +/// L7+credential bounded action (MEDIUM). This helper draws that line. +fn is_endpoint_in_rule_narrowly_bounded( + policy: &crate::policy::PolicyModel, + policy_name: &str, + host: &str, + port: u16, +) -> bool { + let Some(rule) = policy.network_policies.get(policy_name) else { + return false; + }; + for ep in &rule.endpoints { + if ep.host.eq_ignore_ascii_case(host) && ep.effective_ports().contains(&port) { + return endpoint_is_narrowly_bounded(ep); + } + } + false +} + +fn endpoint_is_narrowly_bounded(ep: &crate::policy::Endpoint) -> bool { + if !ep.is_l7_enforced() { + return false; + } + match ep.access.as_str() { + // `access: full` is L4-equivalent reach despite the L7 protocol + // annotation — not narrow. + "full" => false, + // Method-bounded shorthands ("read-only" = GET/HEAD/OPTIONS; + // "read-write" = adds POST/PUT/PATCH). Path-unrestricted but + // method-bounded — narrow enough to stay MEDIUM. + "read-only" | "read-write" => true, + // Rules-based: need at least one rule, all with bounded method + // (not `*`) AND bounded path (not empty / `**` / `/**`). Any + // wildcard in either axis collapses the L7 narrowing. + _ => { + !ep.rules.is_empty() + && ep.rules.iter().all(|r| { + let m = r.method.to_uppercase(); + let p = r.path.as_str(); + m != "*" && !p.is_empty() && p != "**" && p != "/**" + }) + } + } +} + // `collect_credential_actions` removed in v1 along with the original // `check_write_bypass` logic. When intent-aware write-bypass detection is // reintroduced, this helper (or its successor) will live here. @@ -272,4 +402,84 @@ mod tests { assert!(!is_link_local("metadata.google.internal")); assert!(!is_link_local("")); } + + // ── narrowness classifier ── + + fn make_endpoint(access: &str, rules: Vec<(&str, &str)>) -> crate::policy::Endpoint { + crate::policy::Endpoint { + host: "api.example.com".to_owned(), + port: 443, + ports: vec![], + protocol: "rest".to_owned(), + tls: String::new(), + enforcement: "enforce".to_owned(), + access: access.to_owned(), + rules: rules + .into_iter() + .map(|(m, p)| crate::policy::L7Rule { + method: m.to_owned(), + path: p.to_owned(), + command: String::new(), + }) + .collect(), + allowed_ips: vec![], + } + } + + #[test] + fn endpoint_narrow_classifier_access_full_is_not_narrow() { + let ep = make_endpoint("full", vec![]); + assert!( + !endpoint_is_narrowly_bounded(&ep), + "`access: full` is L4-equivalent and must NOT be considered narrow", + ); + } + + #[test] + fn endpoint_narrow_classifier_read_only_and_read_write_are_narrow() { + // Bounded method set; treated as narrow (MEDIUM under the credential + // calibration). Reviewer suggested keeping the read-* shorthands in + // the narrow bucket — they bound destructiveness. + assert!(endpoint_is_narrowly_bounded(&make_endpoint( + "read-only", + vec![] + ))); + assert!(endpoint_is_narrowly_bounded(&make_endpoint( + "read-write", + vec![] + ))); + } + + #[test] + fn endpoint_narrow_classifier_wildcard_method_is_not_narrow() { + let ep = make_endpoint("", vec![("*", "/repos/owner/repo")]); + assert!( + !endpoint_is_narrowly_bounded(&ep), + "rules with `method: \"*\"` are L4-equivalent reach in the method axis", + ); + } + + #[test] + fn endpoint_narrow_classifier_wildcard_path_is_not_narrow() { + for path in ["**", "/**", ""] { + let ep = make_endpoint("", vec![("PUT", path)]); + assert!( + !endpoint_is_narrowly_bounded(&ep), + "path {path:?} is unbounded; the rule must NOT be considered narrow", + ); + } + } + + #[test] + fn endpoint_narrow_classifier_explicit_method_and_path_is_narrow() { + let ep = make_endpoint("", vec![("PUT", "/repos/owner/repo/contents/file.md")]); + assert!(endpoint_is_narrowly_bounded(&ep)); + } + + #[test] + fn endpoint_narrow_classifier_l4_only_is_not_narrow() { + let mut ep = make_endpoint("", vec![("GET", "/path")]); + ep.protocol = String::new(); // L4-only — fails the L7-enforced precondition + assert!(!endpoint_is_narrowly_bounded(&ep)); + } } diff --git a/crates/openshell-prover/src/report.rs b/crates/openshell-prover/src/report.rs index 900d7ba0d..620742d44 100644 --- a/crates/openshell-prover/src/report.rs +++ b/crates/openshell-prover/src/report.rs @@ -87,6 +87,18 @@ fn compact_detail(finding: &Finding) -> String { .join(", ") )); } + if let Some(eps) = by_status.get("l7_credentialed") { + let mut sorted: Vec<&String> = eps.iter().collect(); + sorted.sort(); + parts.push(format!( + "L7 + credential in scope: {}", + sorted + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } parts.join("; ") } "write_bypass" => { @@ -146,6 +158,7 @@ fn risk_label(risk: RiskLevel) -> String { match risk { RiskLevel::Critical => "CRITICAL".to_owned(), RiskLevel::High => "HIGH".to_owned(), + RiskLevel::Medium => "MEDIUM".to_owned(), } } @@ -153,6 +166,7 @@ fn print_risk_label(risk: RiskLevel) { match risk { RiskLevel::Critical => print!("{}", "CRITICAL".bold().red()), RiskLevel::High => print!("{}", " HIGH".red()), + RiskLevel::Medium => print!("{}", " MEDIUM".yellow()), } } @@ -195,6 +209,7 @@ pub fn render_compact(findings: &[Finding], _policy_path: &str, _credentials_pat } let has_critical = counts.contains_key(&RiskLevel::Critical); let has_high = counts.contains_key(&RiskLevel::High); + let has_medium = counts.contains_key(&RiskLevel::Medium); let accepted_note = if accepted.is_empty() { String::new() } else { @@ -209,6 +224,13 @@ pub fn render_compact(findings: &[Finding], _policy_path: &str, _credentials_pat " FAIL ".white().bold().on_red() ); 1 + } else if has_medium { + let n = counts.get(&RiskLevel::Medium).unwrap_or(&0); + println!( + " {} {n} medium-risk gap(s){accepted_note}", + " REVIEW ".black().bold().on_yellow() + ); + 1 } else if !active.is_empty() { println!( " {} advisories only{accepted_note}", @@ -259,13 +281,14 @@ pub fn render_report(findings: &[Finding], policy_path: &str, credentials_path: } println!("{}", "Finding Summary".bold().underline()); - for level in [RiskLevel::Critical, RiskLevel::High] { + for level in [RiskLevel::Critical, RiskLevel::High, RiskLevel::Medium] { if let Some(&count) = counts.get(&level) { match level { RiskLevel::Critical => { println!(" {:>10} {count}", "CRITICAL".bold().red()); } RiskLevel::High => println!(" {:>10} {count}", "HIGH".red()), + RiskLevel::Medium => println!(" {:>10} {count}", "MEDIUM".yellow()), } } } @@ -285,6 +308,7 @@ pub fn render_report(findings: &[Finding], policy_path: &str, credentials_path: let border = match finding.risk { RiskLevel::Critical => format!("{}", format!("[{label}]").bold().red()), RiskLevel::High => format!("{}", format!("[{label}]").red()), + RiskLevel::Medium => format!("{}", format!("[{label}]").yellow()), }; println!("--- Finding #{} {border} ---", i + 1); @@ -325,6 +349,7 @@ pub fn render_report(findings: &[Finding], policy_path: &str, credentials_path: // Verdict let has_critical = counts.contains_key(&RiskLevel::Critical); let has_high = counts.contains_key(&RiskLevel::High); + let has_medium = counts.contains_key(&RiskLevel::Medium); let accepted_note = if accepted.is_empty() { String::new() } else { @@ -343,6 +368,14 @@ pub fn render_report(findings: &[Finding], policy_path: &str, credentials_path: "FAIL \u{2014} High-risk gaps found.".bold().red() ); 1 + } else if has_medium { + println!( + "{}{accepted_note}", + "REVIEW \u{2014} Medium-risk gaps require human attention." + .bold() + .yellow() + ); + 1 } else if !active.is_empty() { println!( "{}{accepted_note}", @@ -384,6 +417,7 @@ fn render_exfil_paths(paths: &[FindingPath]) { "l4_only" => format!("{}", "L4-only".red()), "l7_bypassed" => format!("{}", "bypassed".red()), "l7_allows_write" => format!("{}", "L7 write".yellow()), + "l7_credentialed" => format!("{}", "L7+cred".yellow()), _ => p.l7_status.clone(), }; let ep = format!("{}:{}", p.endpoint_host, p.endpoint_port); diff --git a/crates/openshell-sandbox/src/skills/policy_advisor.md b/crates/openshell-sandbox/src/skills/policy_advisor.md index 2307d1bbb..1fcc123ba 100644 --- a/crates/openshell-sandbox/src/skills/policy_advisor.md +++ b/crates/openshell-sandbox/src/skills/policy_advisor.md @@ -46,12 +46,14 @@ 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. **Narrow L7 proposals against - inspectable hosts auto-approve without human review** (see Auto-approval - below). L4 grants for the same host with a credential in scope always - require human approval, so L7 is the agent-speed path. 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. +4. Prefer L7 REST rules for REST APIs. **Narrow L7 proposals against hosts + with no credential in scope auto-approve without human review** (see + Auto-approval below). L7 to a host where a credential is in scope flags + MEDIUM and still goes to human review. L4 grants with a credential in + scope always require human approval, so L7 is the agent-speed path + wherever L7 inspection is possible. 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, exact method, and the smallest safe path. 6. Submit the proposal, save `accepted_chunk_ids` from the response, and @@ -125,31 +127,54 @@ A complete narrow REST-inspected rule looks like this: ## Auto-approval -The gateway runs a deterministic prover on every proposal and auto-approves -when the proposal introduces no new findings. You get agent speed for -proposals the prover can bound; everything else escalates to a human. - -What the prover flags (and therefore keeps in human review): - -- **Link-local hosts** (`169.254.0.0/16`, `fe80::/10`). Cloud metadata - endpoints like `169.254.169.254` live here. **Never** propose access to - these — the proposal will always escalate, regardless of credentials. -- **L4 grants** (no `protocol: rest`) to a host where a sandbox credential - is in scope. The L4 layer has no inspection; combined with a privileged - credential, this is unbounded reachability. -- **Bypass-L7 binaries** (`/usr/bin/git`, `/usr/lib/git-core/git-remote-http`, - `/usr/bin/ssh`, `/usr/bin/nc`) bound to any host where a credential is in - scope. Wire protocols opaque to L7 inspection are unbounded by L7 scoping. - -What auto-approves: - -- L7 (REST) rules with explicit `method` + exact `path` against - inspectable hosts. -- Any proposal that adds no path the prover can reach with a privileged - binary against a credentialed host. - -If your proposal escalates and you need it sooner, narrow it: an L7 method/path -scope often turns an "L4 with credential" finding into "no new findings." +Auto-approval is opt-in per sandbox. A sandbox set to +`proposal_approval_mode = "auto"` will auto-approve any proposal the +prover sees as empty-delta; sandboxes left in `"manual"` (the default) +route every proposal to human review regardless of the prover verdict. + +When the sandbox is in `"auto"` mode 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=`, and +`prover_delta=empty`. The agent's `/wait` returns approved in ~1 +second. When the prover does find something — or the sandbox is in +`"manual"` mode — the chunk lands in `pending` for human review. + +What the prover flags: + +- **`HIGH` — Link-local hosts** (`169.254.0.0/16`, `fe80::/10`). Cloud + metadata endpoints like `169.254.169.254` live here. **Never** + propose access to these — the proposal will always require human + review, regardless of credential state. +- **`HIGH` — L4 grants** (no `protocol: rest`) to a host where a + sandbox credential is in scope. The L4 layer has no inspection; + combined with a privileged credential, this is unbounded + reachability. +- **`HIGH` — Bypass-L7 binaries** (`/usr/bin/git`, + `/usr/lib/git-core/git-remote-http`, `/usr/bin/ssh`, `/usr/bin/nc`) + bound to any host where a credential is in scope. Wire protocols + opaque to L7 inspection are unbounded by L7 scoping. +- **`MEDIUM` — Narrow L7 rules to a host where a credential is in + scope.** The L7 proxy bounds *what* you can do, but the bounded + action is still authenticated. PUT, POST, PATCH, DELETE can mutate + state. v1 defers to a human reviewer for any credentialed action; + there's no way to "narrow" further to make this auto-approve. The + L7 + credential row is the smallest amount of escalation v1 demands + — one human approval per credentialed action, and you're done. + +What auto-approves (under `auto` mode): + +- L7 (REST) rules against hosts where **no credential is in scope** + (no attached provider declares the host). Public-content fetches + from CDNs, schema URLs, public API discovery — these go through. +- Any proposal that adds no path the prover can reach with a + privileged binary against a credentialed host. + +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 changing the binary or scope can +turn a MEDIUM into "no new findings." Credentialed mutations are +*supposed* to escalate; don't try to bypass that — propose the narrow +rule and wait for review. ## Refining an earlier auto-suggested rule diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index b6a52f4ef..bf6be9812 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1927,6 +1927,16 @@ pub(super) async fn handle_submit_policy_analysis( // 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 per-sandbox behavior. Default (empty or + // explicit "manual") preserves OpenShell's default-deny posture: every + // proposal lands in `pending` for a human reviewer. Only sandboxes that + // explicitly set `proposal_approval_mode = "auto"` get prover-gated + // auto-approval for empty-delta proposals. + let auto_approve_enabled = sandbox + .spec + .as_ref() + .is_some_and(|spec| spec.proposal_approval_mode == "auto"); + // 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 @@ -2075,14 +2085,16 @@ pub(super) async fn handle_submit_policy_analysis( .await; } - // Auto-approval gate (proposer-agnostic): if the prover found nothing - // new in this proposal's delta, internally invoke the approve path. + // Auto-approval gate (proposer-agnostic, opt-in): only fire when + // BOTH the prover found nothing new in this proposal's delta AND + // the sandbox owner opted in via `proposal_approval_mode = "auto"`. // 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 validation_result == "prover: no new findings" + if auto_approve_enabled + && validation_result == "prover: no new findings" && let Err(err) = auto_approve_chunk( state, &sandbox_id, @@ -4939,6 +4951,9 @@ mod tests { }), ..Default::default() }), + // Opt this sandbox into auto-approval to exercise the + // empty-delta → approved path. + proposal_approval_mode: "auto".to_string(), ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -5001,11 +5016,13 @@ mod tests { "exact L7 PUT against an inspected endpoint should not introduce \ any new findings over baseline; got: {verdict}" ); - // Auto-approval gate: empty delta → status flips to approved without - // human action. This is the canonical happy path for agent speed. + // 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 must auto-approve; got status: {}", + "empty-delta agent-authored proposal under auto mode must auto-approve; \ + got status: {}", draft.chunks[0].status ); } @@ -5109,8 +5126,12 @@ mod tests { assert!(mech.validation_result.contains("[HIGH]")); // Step 2: the agent refines into a narrow L7 proposal for the SAME - // (host, port, binary). The new chunk auto-approves (empty delta) - // AND the older mechanistic one gets auto-rejected as superseded. + // (host, port, binary). Under v1 calibration, L7 with a credential + // in scope flags MEDIUM (bounded but authenticated), so 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. let agent_rule = NetworkPolicyRule { name: "github_contents_put".to_string(), endpoints: vec![NetworkEndpoint { @@ -5174,10 +5195,17 @@ mod tests { .expect("mechanistic chunk should still be visible (with new status)"); assert_eq!( - agent.status, "approved", - "agent-authored narrow L7 should auto-approve; got: {}", + agent.status, "pending", + "agent-authored narrow L7 with credential in scope flags MEDIUM under v1 \ + calibration; it should land in pending for human review, not auto-approve; \ + got: {}", agent.status ); + assert!( + agent.validation_result.contains("[MEDIUM]"), + "agent chunk should carry the MEDIUM L7+credential verdict; got: {}", + agent.validation_result + ); assert_eq!( mech_after.status, "rejected", "older mechanistic chunk for same (host, port, binary) should be superseded; \ @@ -5226,6 +5254,8 @@ mod tests { ..Default::default() }), // No providers → no credential in scope for the proposed host. + // Opt into auto mode to test the proposer-agnostic gate. + proposal_approval_mode: "auto".to_string(), ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -5277,8 +5307,378 @@ mod tests { assert_eq!(verdict, "prover: no new findings"); assert_eq!( draft.chunks[0].status, "approved", - "empty-delta mechanistic proposal must auto-approve (proposer-agnostic); \ - got status: {}", + "empty-delta mechanistic proposal under auto mode must auto-approve \ + (proposer-agnostic); got status: {}", + draft.chunks[0].status + ); + } + + /// `protocol: rest, access: full` is L7-annotated but L4-equivalent in + /// reach — the L7 protocol doesn't actually bound what the binary can + /// do. With a credential in scope, this must emit HIGH (not MEDIUM), + /// because the agent has done no meaningful narrowing despite the L7 + /// dressing. Regression test for the narrowness classifier in + /// `openshell-prover::queries::endpoint_is_narrowly_bounded`. + #[tokio::test] + async fn agent_authored_l7_full_with_credential_records_high_finding() { + use openshell_core::proto::{ + FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, + SandboxSpec, + }; + + let state = test_server_state().await; + state + .store + .put_message(&test_provider("github-pat", "github")) + .await + .unwrap(); + let sandbox_name = "l7-full-with-cred".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-l7-full-with-cred".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + providers: vec!["github-pat".to_string()], + proposal_approval_mode: "auto".to_string(), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + // L7-annotated (protocol: rest, enforce) but access: full — no + // method/path bound. Credential in scope. + let proposed_rule = NetworkPolicyRule { + name: "github_l7_full".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "full".to_string(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + handle_submit_policy_analysis( + &state, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_l7_full".to_string(), + proposed_rule: Some(proposed_rule), + rationale: "broad L7 dressing".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap(); + + let draft = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + let verdict = &draft.chunks[0].validation_result; + assert!( + verdict.contains("[HIGH]"), + "L7 `access: full` with credential in scope must emit HIGH (not MEDIUM) — \ + the L7 annotation doesn't actually narrow reach. got: {verdict}" + ); + assert!( + !verdict.contains("[MEDIUM]"), + "MEDIUM must NOT fire when the L7 scope is effectively all-methods; got: {verdict}" + ); + assert_eq!( + draft.chunks[0].status, "pending", + "HIGH finding must keep the chunk in pending despite auto mode; got: {}", + draft.chunks[0].status + ); + } + + /// Acceptance criterion #7: default approval mode is manual. A sandbox + /// with `proposal_approval_mode` unset (the proto3 default of `""`) + /// 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 per-sandbox opt-in, not a global + /// behavior change shipped under a feature. + #[tokio::test] + async fn empty_delta_does_not_auto_approve_when_mode_unset() { + use openshell_core::proto::{ + FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, + SandboxSpec, + }; + + let state = test_server_state().await; + let sandbox_name = "default-manual-mode".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-default-manual-mode".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + // proposal_approval_mode left as proto3 default ("") — must + // be treated as "manual". + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + 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, + 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, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .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_eq!( + draft.chunks[0].status, "pending", + "default (unset) proposal_approval_mode must not auto-approve; \ + chunk should 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." + #[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 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(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + // A future-CLI value the current gateway doesn't recognize. + proposal_approval_mode: "auto_on_low_risk".to_string(), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + 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, + 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, + 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 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(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + proposal_approval_mode: "manual".to_string(), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + 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, + 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, + 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 ); } @@ -5694,6 +6094,193 @@ mod tests { ); } + /// 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." + #[tokio::test] + async fn full_loop_under_v2_auto_mode_splits_credentialed_and_uncredentialed() { + use openshell_core::proto::{ + FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase, + SandboxPolicy, SandboxSpec, + }; + + 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. + state + .store + .put_message(&test_provider("github-pat", "github")) + .await + .unwrap(); + + let sandbox_name = "full-loop-v2".to_string(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-full-loop-v2".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + providers: vec!["github-pat".to_string()], + proposal_approval_mode: "auto".to_string(), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + // ── Step 1: un-credentialed GET → expected auto-approve ── + let uncredentialed_rule = NetworkPolicyRule { + name: "github_raw_openapi_get".to_string(), + endpoints: vec![NetworkEndpoint { + host: "raw.githubusercontent.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".to_string(), + path: "/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json" + .to_string(), + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + let step1 = handle_submit_policy_analysis( + &state, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_raw_openapi_get".to_string(), + proposed_rule: Some(uncredentialed_rule), + rationale: "fetch the public github openapi description".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + let step1_chunk_id = step1.accepted_chunk_ids[0].clone(); + + // ── Step 2: credentialed PUT → expected MEDIUM, pending ── + let credentialed_rule = NetworkPolicyRule { + name: "github_contents_put".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "PUT".to_string(), + path: "/repos/owner/name/contents/path/file.md".to_string(), + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + let step2 = handle_submit_policy_analysis( + &state, + Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_contents_put".to_string(), + proposed_rule: Some(credentialed_rule), + rationale: "write the demo file via the GitHub Contents API".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + let step2_chunk_id = step2.accepted_chunk_ids[0].clone(); + + let draft = handle_get_draft_policy( + &state, + Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + + let step1_chunk = draft + .chunks + .iter() + .find(|c| c.id == step1_chunk_id) + .expect("step1 chunk present"); + let step2_chunk = draft + .chunks + .iter() + .find(|c| c.id == step2_chunk_id) + .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 + ); + 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_eq!( + step2_chunk.status, "pending", + "credentialed L7 proposal under v2 + auto mode must stay pending (MEDIUM); got: {}", + step2_chunk.status + ); + assert!( + step2_chunk.validation_result.contains("[MEDIUM]"), + "credentialed L7 must carry MEDIUM verdict; got: {}", + step2_chunk.validation_result + ); + } + /// Two agent-authored proposals targeting the same host/port/binary must /// each persist as a distinct chunk. The mechanistic-mode dedup /// (`host|port|binary`) is wrong for agent intent: the redraft loop diff --git a/examples/agent-driven-policy-management/README.md b/examples/agent-driven-policy-management/README.md index ad55b4df8..0a014589e 100644 --- a/examples/agent-driven-policy-management/README.md +++ b/examples/agent-driven-policy-management/README.md @@ -82,7 +82,7 @@ reject with `--reason "scope to docs/ paths only"` and the agent reads | `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_CODEX_MODEL` | `gpt-5` (pinned for ChatGPT-account compatibility; override if your account supports a different model) | +| `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` | diff --git a/examples/agent-driven-policy-management/agent-task.md b/examples/agent-driven-policy-management/agent-task.md index e83a653bd..e2e9c4bdb 100644 --- a/examples/agent-driven-policy-management/agent-task.md +++ b/examples/agent-driven-policy-management/agent-task.md @@ -3,8 +3,11 @@ # Agent Task -You are running inside an OpenShell sandbox. Your job is to write one -markdown file to GitHub via the GitHub Contents API. +You are running inside an OpenShell sandbox. Your job has two steps. Each +step starts with a `curl` call that the L7 proxy will deny; for each +denial, read `/etc/openshell/skills/policy_advisor.md`, draft a narrow +proposal, submit it to `http://policy.local/v1/proposals`, wait on +`GET /v1/proposals/{chunk_id}/wait?timeout=300`, then retry. ## Target @@ -15,50 +18,89 @@ markdown file to GitHub via the GitHub Contents API. ## What to do +### Step 1 — Fetch a public schema (un-credentialed action) + +1. Fetch the well-known GitHub OpenAPI description from + `https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json` + with `curl`. Do not send any authentication header — this resource is + public. Save the response to `/tmp/github-openapi.json`. + + The first attempt will fail with HTTP 403 and a JSON body containing + `"error": "policy_denied"`. Read the body — it tells you which layer + denied you, which method/path was missing, and where to learn more. + +2. Read `/etc/openshell/skills/policy_advisor.md` and follow it. Submit + the narrowest possible proposal to `http://policy.local/v1/proposals` + — exact host (`raw.githubusercontent.com`), exact port (443), exact + method (`GET`), exact path (`/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json`), + binary `/usr/bin/curl`. Do not propose wildcard hosts. Save + `accepted_chunk_ids[0]`. + +3. Block on the gateway's decision by calling + `GET http://policy.local/v1/proposals/{chunk_id}/wait?timeout=300`. + + - `status: "approved"` — retry the fetch once. Because the host has + no credentials in scope (it's a public-content endpoint), the + prover sees no findings and the gateway auto-approves the + proposal without human review. The whole step typically completes + in ~1 second after submission. + - `status: "rejected"` — read `rejection_reason`. If it has text, + address the specific feedback and submit a revised proposal; + otherwise stop and tell the developer you can't proceed. + - `status: "pending"` with `timed_out: true` — re-issue the same + `/wait` request once. Each `/wait` is one long-lived HTTP call. + +4. After the retry, verify the file exists at `/tmp/github-openapi.json` + and is non-empty. + +### Step 2 — Write to GitHub (credentialed action) + 1. PUT the file with `curl` against `https://api.github.com/repos/{{OWNER}}/{{REPO}}/contents/{{FILE_PATH}}`. - Authenticate with the `GITHUB_TOKEN` environment variable. Do not echo the - token. Body content (base64-encoded into the JSON `content` field): + Authenticate with the `GITHUB_TOKEN` environment variable. Do not + echo the token. Body content (base64-encoded into the JSON `content` + field): ``` # OpenShell policy advisor demo Run id: {{RUN_ID}} - Written from inside an OpenShell sandbox after a narrowly-scoped policy - proposal was approved by the developer. + Written from inside an OpenShell sandbox after a narrowly-scoped + policy proposal was reviewed by the developer. ``` 2. The first attempt will fail with HTTP 403 and a JSON body containing - `"error": "policy_denied"`. Read the body — it tells you which layer denied - you (`l7`/`rest`), which method/path was missing, and where to learn more. + `"error": "policy_denied"`. Read the body — it tells you which layer + denied you (`l7`/`rest`), which method/path was missing, and where to + learn more. -3. Read `/etc/openshell/skills/policy_advisor.md` and follow it. Submit the - narrowest possible proposal to `http://policy.local/v1/proposals` — exact - host, exact port, exact method, exact path, binary `/usr/bin/curl`. Do not - include query strings. Do not propose wildcard hosts. The 202 response - carries `accepted_chunk_ids`; this demo submits one rule per proposal, so - the list always has exactly one element. Save `accepted_chunk_ids[0]`, - you need it for step 4. +3. Submit the narrowest possible proposal to + `http://policy.local/v1/proposals` — exact host (`api.github.com`), + exact port (443), exact method (`PUT`), exact path + (`/repos/{{OWNER}}/{{REPO}}/contents/{{FILE_PATH}}`), binary + `/usr/bin/curl`. Do not include query strings. Do not propose + wildcard hosts. Save `accepted_chunk_ids[0]`. 4. Block on the developer's decision by calling - `GET http://policy.local/v1/proposals/{chunk_id}/wait?timeout=300`. This is - a single HTTP request that the supervisor holds open until the developer - approves or rejects; do not run a polling loop yourself. + `GET http://policy.local/v1/proposals/{chunk_id}/wait?timeout=300`. + - This time the prover flags MEDIUM: the proposal is narrow L7 but + the github credential is in scope, 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. - `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 to step 3); - otherwise stop and tell the developer you can't proceed. - - `status: "pending"` with `timed_out: true` — the supervisor returned - without a decision after the full timeout window elapsed. Immediately - re-issue the same `/wait` request once. Each `/wait` is one long-lived - HTTP call; do not sleep, do not loop with a short timeout, do not - decrease `timeout=300`. + - `status: "rejected"` — read `rejection_reason`. If it has text, + address the specific feedback and submit a revised proposal (back + to step 3); otherwise stop and tell the developer you can't + proceed. + - `status: "pending"` with `timed_out: true` — re-issue the same + `/wait` request once. 5. On a successful PUT (HTTP 200 or 201), print a short summary showing - `content.path` and `content.html_url` from the GitHub response. Do not - print the full response body. + `content.path` and `content.html_url` from the GitHub response. Do + not print the full response body. If anything is unclear, prefer making a narrower proposal and asking for approval again over widening the rule. diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index 7e8846afb..492d73a63 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -5,26 +5,11 @@ # Agent-driven policy management demo. # -# Runs the full loop end-to-end: -# -# 1. A Codex agent inside an OpenShell sandbox attempts a PUT that the L7 -# proxy denies with a structured policy_denied 403. -# 2. The agent reads /etc/openshell/skills/policy_advisor.md. -# 3. The agent submits a narrow proposal (exact host, port, method, path) -# to policy.local and saves the returned chunk_id. -# 4. The agent blocks on `GET /v1/proposals/{chunk_id}/wait` — one HTTP -# call that sleeps on a socket. THE AGENT BURNS ZERO LLM TOKENS WHILE -# IT WAITS; this is the load-bearing UX win over polling. -# 5. The developer (this script, simulating the host side) sees the pending -# proposal in `openshell rule get`, including the gateway-side prover -# verdict, and approves it. -# 6. The agent's /wait returns approved within ~1 second of the approval, -# retries the original PUT once against the hot-reloaded policy, and -# exits. -# -# The whole loop is feature-flagged behind agent_policy_proposals_enabled and -# requires no GitHub credentials beyond the repo write token already used by -# the existing demo flow. +# 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. set -euo pipefail @@ -52,7 +37,7 @@ DEMO_FILE_PATH="${DEMO_FILE_DIR}/${DEMO_RUN_ID}.md" DEMO_SANDBOX_NAME="${DEMO_SANDBOX_NAME:-policy-demo-${DEMO_RUN_ID}}" DEMO_CODEX_PROVIDER_NAME="${DEMO_CODEX_PROVIDER_NAME:-codex-policy-demo-${DEMO_RUN_ID}}" DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-policy-demo-${DEMO_RUN_ID}}" -DEMO_CODEX_MODEL="${DEMO_CODEX_MODEL:-gpt-5}" +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 @@ -137,19 +122,18 @@ spin_clear() { # — a sed delimiter collision in one of the substitutions blanks the entire # log tail, hiding the very failure context we're trying to surface. redact_log() { - python3 - \ - "${DEMO_GITHUB_TOKEN:-}" \ - "${CODEX_AUTH_ACCESS_TOKEN:-}" \ - "${CODEX_AUTH_REFRESH_TOKEN:-}" \ - "${CODEX_AUTH_ACCOUNT_ID:-}" \ - <<'PY' + python3 -c ' import sys tokens = [t for t in sys.argv[1:] if t] for line in sys.stdin: for t in tokens: line = line.replace(t, "[redacted]") sys.stdout.write(line) -PY +' \ + "${DEMO_GITHUB_TOKEN:-}" \ + "${CODEX_AUTH_ACCESS_TOKEN:-}" \ + "${CODEX_AUTH_REFRESH_TOKEN:-}" \ + "${CODEX_AUTH_ACCOUNT_ID:-}" } fail() { @@ -189,6 +173,20 @@ cleanup() { fi fi + # Restore the providers_v2_enabled setting to what it was before this + # run. The demo opts in to v2 composition so provider profiles + # contribute to the effective policy; restore so the host's broader + # workflow isn't affected. + if [[ -n "${PRIOR_PROVIDERS_V2_FLAG:-}" ]]; then + if [[ "$PRIOR_PROVIDERS_V2_FLAG" == "(unset)" ]]; then + "$OPENSHELL_BIN" settings delete --global --key providers_v2_enabled --yes \ + >/dev/null 2>&1 || true + else + "$OPENSHELL_BIN" settings set --global --key providers_v2_enabled \ + --value "$PRIOR_PROVIDERS_V2_FLAG" --yes >/dev/null 2>&1 || true + fi + fi + if [[ $status -eq 0 ]]; then rm -rf "$TMP_DIR" else @@ -333,7 +331,7 @@ render_payload() { -e "s|{{FILE_PATH}}|${DEMO_FILE_PATH}|g" \ -e "s|{{RUN_ID}}|${DEMO_RUN_ID}|g" \ "$TASK_TEMPLATE" > "${PAYLOAD_DIR}/agent-task.md" - sed "s|DEMO_CODEX_MODEL=\"\${DEMO_CODEX_MODEL:-gpt-5}\"|DEMO_CODEX_MODEL=\"\${DEMO_CODEX_MODEL:-${DEMO_CODEX_MODEL}}\"|" \ + sed "s|DEMO_CODEX_MODEL=\"\${DEMO_CODEX_MODEL:-gpt-5.4-mini}\"|DEMO_CODEX_MODEL=\"\${DEMO_CODEX_MODEL:-${DEMO_CODEX_MODEL}}\"|" \ "$SANDBOX_AGENT" > "${PAYLOAD_DIR}/sandbox-agent.sh" if [[ -n "$DEMO_CODEX_LOCAL_BIN" ]]; then [[ -x "$DEMO_CODEX_LOCAL_BIN" ]] || fail "DEMO_CODEX_LOCAL_BIN is not executable: $DEMO_CODEX_LOCAL_BIN" @@ -356,7 +354,7 @@ create_providers() { "$OPENSHELL_BIN" provider create \ --name "$DEMO_GITHUB_PROVIDER_NAME" \ - --type generic \ + --type github \ --credential DEMO_GITHUB_TOKEN >/dev/null info "providers created (codex, github) — credentials injected as env vars only" @@ -366,9 +364,10 @@ start_agent_sandbox() { step "Launching sandbox; agent will hit a policy block and draft a proposal" "$OPENSHELL_BIN" sandbox delete "$DEMO_SANDBOX_NAME" >/dev/null 2>&1 || true - info "initial policy: read-only access to api.github.com (no PUT)" - info "agent task: PUT /repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/${DEMO_FILE_PATH}" - info "live log: ${AGENT_LOG}" + info "policy: raw GitHub schema path denied; GitHub writes denied" + info "approval: auto for no new findings; review for credential risk" + info "target: PUT /repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/${DEMO_FILE_PATH}" + info "log: ${AGENT_LOG}" # `--upload :/sandbox` preserves the source directory basename # (matches `scp -r`/`cp -r`, see PRs #952 / #1028), so `${PAYLOAD_DIR}` @@ -381,6 +380,7 @@ 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 \ @@ -390,64 +390,100 @@ start_agent_sandbox() { AGENT_PID="$!" } -# Strip the rule_get output down to the lines a developer needs to make an -# informed approve/reject decision: rationale, validation, binary, endpoint. -# Filters the noisy fields (UUID, agent-generated rule_name, hardcoded -# confidence, duplicate Binaries). -# -# `validation_result` can span multiple lines (`prover: N findings` followed -# by one indented finding line per detected risk), so when a `Validation:` -# label appears we also print any subsequent indented lines until we hit the -# next labeled field. -# -# `openshell rule get` colorizes labels with ANSI escapes; strip them before -# parsing so the field-name match works in piped contexts. +# Strip `rule get` down to the approval contract: chunk, binary, access, risk. summarize_pending() { local pending="$1" sed 's/\x1b\[[0-9;]*m//g' "$pending" \ | awk ' - BEGIN { in_validation = 0 } - /Rationale:/ { in_validation = 0; sub(/^[[:space:]]*/, ""); print " " $0; next } - /Validation:/ { in_validation = 1; sub(/^[[:space:]]*/, ""); print " " $0; next } - /Binary:/ { in_validation = 0; sub(/^[[:space:]]*/, ""); print " " $0; next } - /Endpoints:/ { in_validation = 0; sub(/^[[:space:]]*/, ""); print " " $0; next } - in_validation && /^[[:space:]]{2,}\[/ { sub(/^[[:space:]]*/, ""); print " " $0; next } + BEGIN { + in_validation = 0 + chunk_count = 0 + validation_printed = 0 + severity_printed = 0 + } + /^[[:space:]]*Chunk:/ { + in_validation = 0 + chunk_count++ + validation_printed = 0 + severity_printed = 0 + if (chunk_count > 1) print "" + sub(/^[[:space:]]*/, "") + chunk_id = $2 + short_id = substr(chunk_id, 1, 8) + print " Request " chunk_count ": chunk " short_id + 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" + validation_printed = 1 + } + sub(/^Endpoints:/, "Access: ") + print " " $0 + next + } + /Validation:/ { + in_validation = 1 + validation_printed = 1 + sub(/^[[:space:]]*/, "") + sub(/^Validation:[[:space:]]*(prover:[[:space:]]*)?/, "Prover: ") + print " " $0 + next + } + /Rationale:/ { + in_validation = 0 + sub(/^[[:space:]]*/, "") + sub(/^Rationale:/, "Reason: ") + print " " $0 + next + } + in_validation && /\[(LOW|MEDIUM|HIGH|CRITICAL)\]/ { + if (!severity_printed) { + severity = "UNKNOWN" + if ($0 ~ /\[LOW\]/) severity = "LOW" + if ($0 ~ /\[MEDIUM\]/) severity = "MEDIUM" + if ($0 ~ /\[HIGH\]/) severity = "HIGH" + if ($0 ~ /\[CRITICAL\]/) severity = "CRITICAL" + print " Severity: " severity + severity_printed = 1 + } + 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)|\[(LOW|MEDIUM|HIGH|CRITICAL)\]' <<<"$clean"; then + return 0 + fi + if grep -q 'Validation:' <<<"$clean"; then + return 1 + fi + return 0 +} + narrate_sandbox_workflow() { - info "Inside the sandbox right now:" - info "" - info " • agent: ${DIM}curl -X PUT https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/...${RESET}" - info " • L7 proxy denies the write and returns a structured 403 the" - info " agent can parse and act on:" - cat <"$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 "${GREEN}escalation: human review required (proposal did not auto-approve)${RESET}" + info "${YELLOW}approval requested${RESET}" summarize_pending "$pending" if [[ "$DEMO_MANUAL_APPROVE" == "1" ]]; then approve_manually "$pending" else - info "" - info " ${BOLD}↑ this is what you're approving:${RESET}" - info " • the structured rule above (Endpoints + Binary) is the contract" - info " • the Validation line carries the prover's verdict — read it before approving" info "" spin_wait "letting the proposal land before approving" 2 spin_clear - step "Approving on behalf of the demo — the agent's /wait will return within ~1s" - "$OPENSHELL_BIN" rule approve-all "$DEMO_SANDBOX_NAME" \ - | awk '/approved/ { print " " $0 }' + step "Approving for demo" + local approve_output + if ! approve_output="$("$OPENSHELL_BIN" rule approve-all "$DEMO_SANDBOX_NAME" 2>&1)"; then + if grep -q "no pending chunks to approve" <<<"$approve_output"; then + info " decision already recorded" + else + printf "%s\n" "$approve_output" >&2 + fail "could not approve pending proposal" + fi + else + awk '/approved/ { print " " $0 }' <<<"$approve_output" + fi fi approval_count=$((approval_count + 1)) fi @@ -568,21 +610,13 @@ verify_github_write() { jq -r '" file: \(.path)", " url: \(.html_url)"' "$body" } -# Print the OCSF JSONL trace, filtered to the three events that *are* the -# demo's story: the L7 PUT deny, the policy hot-reload, and the L7 PUT allow. -# The native OCSF shorthand is informative and consistent with the rest of -# OpenShell's logging — keep it as-is rather than re-formatting. +# Print the concise OCSF trace that shows deny, proposal, decision, reload, +# and successful retry. show_logs() { - step "Policy decision trace (OCSF)" - # Filter to the events that tell the loop's story end-to-end, ordered by - # the trace's own timestamps: - # HTTP:PUT DENIED — initial proxy enforcement - # CONFIG:PROPOSED — agent submitted a chunk to the gateway - # CONFIG:APPROVED/REJECTED — developer decided; agent's /wait woke up - # CONFIG:LOADED — supervisor hot-reloaded the merged policy - # HTTP:PUT ALLOWED — agent's retry succeeded + step "Decision trace" "$OPENSHELL_BIN" logs "$DEMO_SANDBOX_NAME" --since 10m -n 200 2>&1 \ - | grep -E 'HTTP:PUT.*(DENIED|ALLOWED)|CONFIG:(PROPOSED|APPROVED|REJECTED|LOADED)' \ + | 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 -v 'source=mechanistic' \ | sed 's/^/ /' || true } @@ -593,14 +627,26 @@ enable_agent_proposals() { # delete` rather than a value write. local prior prior="$("$OPENSHELL_BIN" settings get --global --json 2>/dev/null \ - | grep -o '"agent_policy_proposals_enabled"[^,}]*' \ - | grep -o 'true\|false' | head -1)" + | jq -r '.settings.agent_policy_proposals_enabled // empty | tostring | select(. == "true" or . == "false")')" PRIOR_PROPOSALS_FLAG="${prior:-(unset)}" "$OPENSHELL_BIN" settings set --global \ --key agent_policy_proposals_enabled --value true --yes >/dev/null \ || fail "could not enable agent_policy_proposals_enabled globally" } +enable_providers_v2() { + # Providers-v2 composition is behind a global flag. The demo opts in + # so provider profiles (codex, github) contribute to the effective + # policy via composition. Cleanup restores the prior value. + local prior + prior="$("$OPENSHELL_BIN" settings get --global --json 2>/dev/null \ + | jq -r '.settings.providers_v2_enabled // empty | tostring | select(. == "true" or . == "false")')" + PRIOR_PROVIDERS_V2_FLAG="${prior:-(unset)}" + "$OPENSHELL_BIN" settings set --global \ + --key providers_v2_enabled --value true --yes >/dev/null \ + || fail "could not enable providers_v2_enabled globally" +} + main() { validate_env @@ -610,6 +656,7 @@ main() { render_payload create_providers enable_agent_proposals + enable_providers_v2 show_run_summary diff --git a/examples/agent-driven-policy-management/policy.template.yaml b/examples/agent-driven-policy-management/policy.template.yaml index de0d27abb..8121cb507 100644 --- a/examples/agent-driven-policy-management/policy.template.yaml +++ b/examples/agent-driven-policy-management/policy.template.yaml @@ -3,12 +3,21 @@ # Initial sandbox policy for the agent-driven policy demo. # -# The agent inside the sandbox can: -# - reach Codex's model and auth endpoints (codex) -# - read api.github.com via curl (github_api_readonly) +# The demo exercises two flavors of denial-→-propose-→-decision: # -# The agent CANNOT write to GitHub yet. That's the proposal it has to draft -# and ask the developer to approve. +# - 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. +# +# - 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 +# 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. version: 1 @@ -39,6 +48,9 @@ network_policies: - { path: "/usr/lib/node_modules/@openai/**" } 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). name: github-api-readonly endpoints: - host: api.github.com @@ -48,3 +60,23 @@ network_policies: access: read-only binaries: - { path: /usr/bin/curl } + + 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 + # under sandboxes opted into `proposal_approval_mode: auto`. + name: github-raw-scoped + endpoints: + - host: raw.githubusercontent.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /github/rest-api-description/main/README.md + binaries: + - { path: /usr/bin/curl } diff --git a/examples/agent-driven-policy-management/sandbox-agent.sh b/examples/agent-driven-policy-management/sandbox-agent.sh index 83fad813e..45449dd92 100755 --- a/examples/agent-driven-policy-management/sandbox-agent.sh +++ b/examples/agent-driven-policy-management/sandbox-agent.sh @@ -74,20 +74,29 @@ cd "$WORK" # compare runs. DEMO_CODEX_REASONING="${DEMO_CODEX_REASONING:-low}" -# Pin the model to one that ChatGPT-account Codex users can reach. Codex's -# default (`gpt-5.2-codex`) is API-account-only and fails ChatGPT-auth with -# `400 invalid_request_error: model not supported`. Override with -# DEMO_CODEX_MODEL if your account supports something better. -DEMO_CODEX_MODEL="${DEMO_CODEX_MODEL:-gpt-5}" +# Pin the model to one that ChatGPT-account Codex users can reach and that is +# quick enough for the mechanical proposal loop. Override with DEMO_CODEX_MODEL +# if your account supports something different. +DEMO_CODEX_MODEL="${DEMO_CODEX_MODEL:-gpt-5.4-mini}" CODEX_BIN="${CODEX_BIN:-codex}" if [[ -x /sandbox/payload/codex ]]; then CODEX_BIN="/sandbox/payload/codex" fi -exec "$CODEX_BIN" exec \ - --skip-git-repo-check \ - --sandbox danger-full-access \ - --ephemeral \ +CODEX_EXEC_ARGS=( + exec + --skip-git-repo-check + --sandbox danger-full-access + --ephemeral +) +if "$CODEX_BIN" exec --help 2>/dev/null | grep -q -- "--ignore-user-config"; then + CODEX_EXEC_ARGS+=(--ignore-user-config) +fi +if "$CODEX_BIN" exec --help 2>/dev/null | grep -q -- "--ignore-rules"; then + CODEX_EXEC_ARGS+=(--ignore-rules) +fi + +exec "$CODEX_BIN" "${CODEX_EXEC_ARGS[@]}" \ -c "model=\"${DEMO_CODEX_MODEL}\"" \ -c "model_reasoning_effort=\"${DEMO_CODEX_REASONING}\"" \ "$(cat /sandbox/payload/agent-task.md)" diff --git a/proto/openshell.proto b/proto/openshell.proto index e4a1b0673..1db27e5e1 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -261,6 +261,20 @@ message SandboxSpec { // (e.g. "0", "1"). When empty with gpu=true, the driver assigns the // first available GPU. string gpu_device = 10; + // Approval mode for agent-authored policy proposals. + // + // When unset or "manual" (the default), every proposal lands in the + // draft inbox for human review, regardless of the prover verdict. + // + // When "auto", proposals whose prover delta is empty are approved + // automatically without human action; proposals with findings still + // require human approval. The opt-in preserves OpenShell's + // default-deny posture: auto-approval is a deliberate per-sandbox + // choice, not a global behavior change. + // + // Empty string defaults to "manual". String (not enum) so future + // modes ("auto_on_low_risk", etc.) extend without a proto migration. + string proposal_approval_mode = 11; } // Public sandbox template mapped onto compute-driver template inputs. From a084ea203b6926900395e38410bc3bb81a94d8d8 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Thu, 21 May 2026 05:49:38 -0700 Subject: [PATCH 4/6] refactor(prover): emit categorical findings; drop severity tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prover now answers four formal questions about a proposed policy change and emits one finding per "yes" answer: - link_local_reach - l7_bypass_credentialed - credential_reach_expansion - capability_expansion There is no severity grade. The category name is the signal; the per-path evidence carries the structured detail. The auto-approval gate is binary — empty delta or not. This removes the previous HIGH/MEDIUM/CRITICAL severity tiers and the narrowness classifier that was inconsistent across the access-shorthand / explicit-rules boundary. Gateway-side finding_delta gains category suppression: capability_expansion paths whose (binary, host, port) appears in the credential_reach_expansion delta are suppressed, so a brand-new credentialed reach surfaces as one finding rather than one reach plus N method findings. The github provider profile now defaults api.github.com to read-only (was: read-write). Writes flow through the agentic loop — the prover audits each capability change rather than treating broad write access as the default. Demo, sandbox skill, and architecture docs updated to describe the four-category model. Prover gains a README.md documenting the formal queries, evidence shape, and how to add a new category. --- architecture/security-policy.md | 41 +- crates/openshell-prover/README.md | 136 ++++ crates/openshell-prover/src/accepted_risks.rs | 21 +- crates/openshell-prover/src/finding.rs | 82 +-- crates/openshell-prover/src/lib.rs | 46 +- crates/openshell-prover/src/queries.rs | 586 +++++++----------- crates/openshell-prover/src/report.rs | 522 ++++++---------- .../src/skills/policy_advisor.md | 77 +-- crates/openshell-server/src/grpc/policy.rs | 152 +++-- .../agent-driven-policy-management/README.md | 2 +- .../agent-task.md | 11 +- .../agent-driven-policy-management/demo.sh | 25 +- providers/github.yaml | 6 +- 13 files changed, 785 insertions(+), 922 deletions(-) create mode 100644 crates/openshell-prover/README.md diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 0446d3fac..fad9933cd 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -95,30 +95,23 @@ agent-authored via `policy.local`); the gateway is the single referee. L7) without an explicit `supersedes_chunk_id` field. 5. **Escalation.** Anything else lands in `pending` for human review. -The v1 prover calibration emits two severities, both blocking auto-approval: - -**`HIGH`** (cases the prover cannot bound): - -- **Link-local endpoints** (`169.254.0.0/16`, `fe80::/10`), unconditionally - — covers cloud metadata endpoints (AWS IMDS, GCP metadata) which serve - credentials and so are dangerous even with no sandbox credential present. -- **L4 grants** to a host where a sandbox credential is in scope. -- **Bypass-L7 binaries** (`git-remote-http`, `ssh`, `nc`) bound to a host - where a sandbox credential is in scope. - -**`MEDIUM`** (bounded but authenticated; deserves human eyes for the -*action*, not the *reach*): - -- **Narrow L7 rule** (`protocol: rest`, allow list with specific - method/path) bound to a host where a sandbox credential is in scope. - The L7 proxy bounds *what* the binary can do, but the bounded action - is still authenticated and potentially destructive (PUT, DELETE, - POST that mutates). v1 defers semantic judgment to the human - reviewer; future calibration may distinguish read methods from - mutating ones. - -Severity does not change the auto-approval gate — any finding blocks -auto-approval. MEDIUM exists for audit/UI triage signal. +## What the prover decides + +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. + +| Category | The prover detects… | +|---|---| +| `link_local_reach` | The proposal grants reach to a host in `169.254.0.0/16` or `fe80::/10`. 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 diff --git a/crates/openshell-prover/README.md b/crates/openshell-prover/README.md new file mode 100644 index 000000000..4291f0b24 --- /dev/null +++ b/crates/openshell-prover/README.md @@ -0,0 +1,136 @@ + + + +# 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 sandbox opts in via +`spec.proposal_approval_mode = "auto"`). + +## 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: + +```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 +} +``` + +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 + +`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. + +## Adding a new category + +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. + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. diff --git a/crates/openshell-prover/src/accepted_risks.rs b/crates/openshell-prover/src/accepted_risks.rs index 61aa025be..8c28a4418 100644 --- a/crates/openshell-prover/src/accepted_risks.rs +++ b/crates/openshell-prover/src/accepted_risks.rs @@ -80,23 +80,12 @@ pub fn load_accepted_risks(path: &Path) -> Result> { /// Check if a single finding path matches an accepted risk. fn path_matches_risk(path: &FindingPath, risk: &AcceptedRisk) -> bool { - if !risk.binary.is_empty() { - let path_binary = match path { - FindingPath::Exfil(p) => &p.binary, - FindingPath::WriteBypass(p) => &p.binary, - }; - if path_binary != &risk.binary { - return false; - } + let FindingPath::Exfil(p) = path; + if !risk.binary.is_empty() && p.binary != risk.binary { + return false; } - if !risk.endpoint.is_empty() { - let endpoint_host = match path { - FindingPath::Exfil(p) => &p.endpoint_host, - FindingPath::WriteBypass(p) => &p.endpoint_host, - }; - if endpoint_host != &risk.endpoint { - return false; - } + if !risk.endpoint.is_empty() && p.endpoint_host != risk.endpoint { + return false; } true } diff --git a/crates/openshell-prover/src/finding.rs b/crates/openshell-prover/src/finding.rs index 28e0209df..4e06d1b4e 100644 --- a/crates/openshell-prover/src/finding.rs +++ b/crates/openshell-prover/src/finding.rs @@ -2,32 +2,41 @@ // 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. -use std::fmt; - -/// Severity level for a finding. -/// -/// Ordering reflects risk magnitude: `Critical > High > Medium`. v1 emits -/// `High` and `Medium`; `Critical` is retained for future use without a -/// behavioral distinction yet attached to it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum RiskLevel { - Medium, - High, - Critical, -} - -impl fmt::Display for RiskLevel { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Medium => write!(f, "MEDIUM"), - Self::High => write!(f, "HIGH"), - Self::Critical => write!(f, "CRITICAL"), - } - } +/// 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 data can be exfiltrated. +/// 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, @@ -35,37 +44,30 @@ pub struct ExfilPath { pub endpoint_port: u16, pub mechanism: String, pub policy_name: String, - /// One of `"l4_only"`, `"l7_allows_write"`, `"l7_bypassed"`. - pub l7_status: String, -} - -/// A path that allows writing despite read-only intent. -#[derive(Debug, Clone)] -pub struct WriteBypassPath { - pub binary: String, - pub endpoint_host: String, - pub endpoint_port: u16, - pub policy_name: String, - pub policy_intent: String, - /// One of `"l4_only"`, `"l7_bypass_protocol"`, `"credential_write_scope"`. - pub bypass_reason: String, - pub credential_actions: Vec, + /// 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), - WriteBypass(WriteBypassPath), } /// 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 risk: RiskLevel, pub paths: Vec, pub remediation: Vec, pub accepted: bool, diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index 19b06d716..892e79cba 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -158,12 +158,12 @@ filesystem_policy: } // 6. End-to-end: testdata policy with a github credential in scope and a - // bypass-L7 binary (git) emits a calibrated data_exfiltration finding. - // Under the v1 calibration, all emissions consolidate into the - // data_exfiltration query at RiskLevel::High; the legacy write_bypass - // query is a no-op pending a future intent-aware redesign. + // bypass-L7 binary (git) emits an `l7_bypass_credentialed` finding. + // The prover output is categorical, not severity-graded. #[test] - fn test_calibrated_findings_for_github_policy() { + 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"); @@ -174,26 +174,28 @@ filesystem_policy: let z3_model = build_model(pol, cred_set, bin_reg); let findings = run_all_queries(&z3_model); - let query_types: std::collections::HashSet<&str> = + let categories: std::collections::HashSet<&str> = findings.iter().map(|f| f.query.as_str()).collect(); assert!( - query_types.contains("data_exfiltration"), - "expected data_exfiltration finding for bypass-L7 binary with credential in scope, \ - got query types: {query_types:?}" - ); - // v1 emits only data_exfiltration; write_bypass is reserved. - assert!( - !query_types.contains("write_bypass"), - "write_bypass is a no-op in v1; got: {findings:?}" - ); - // v1 emits HIGH and MEDIUM; Critical is reserved for future use. - assert!( - findings.iter().all(|f| matches!( - f.risk, - finding::RiskLevel::High | finding::RiskLevel::Medium - )), - "v1 emits HIGH and MEDIUM only; got: {findings:?}" + 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" + ); + } } // 7. Empty policy produces no findings. diff --git a/crates/openshell-prover/src/queries.rs b/crates/openshell-prover/src/queries.rs index 9c0a8f57e..6aae4b184 100644 --- a/crates/openshell-prover/src/queries.rs +++ b/crates/openshell-prover/src/queries.rs @@ -1,57 +1,53 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Verification queries: `check_data_exfiltration` and `check_write_bypass`. +//! Verification queries. //! -//! v1 calibration (see `architecture/plans/agentic-policy-approval-loop.md`): -//! the prover emits a finding any time a credential is in scope for the -//! proposed endpoint, plus the categorical link-local floor. The four rows -//! that fire today: +//! 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. //! -//! 1. **Link-local host** (`169.254.0.0/16`, `fe80::/10`) — emits regardless -//! of credential context. Cloud metadata endpoints (AWS IMDS, GCP metadata) -//! serve credentials, so the credential-presence model is fundamentally -//! wrong for them. -//! 2. **Bypass-L7 binary** (git smart-HTTP, ssh, nc) **with a credential in -//! scope for the host** — the L7 proxy cannot meaningfully inspect the -//! wire protocol even when scope looks tight, and an authenticated -//! privileged action is available. -//! 3. **L4-only endpoint** (no `protocol: rest|graphql`) **with a credential -//! in scope for the host** — no L7 inspection at all, and authenticated -//! privileged action is available. -//! 4. **L7-enforced endpoint with a credential in scope for the host** — -//! even bounded actions can be destructive when authenticated -//! (e.g., `PUT /repos/.../contents/...` overwrites arbitrary files). -//! v1 defers to human judgment for any credentialed action because the -//! prover models *credential exposure surface*, not *action semantics*. -//! A future calibration may distinguish read methods from mutating ones -//! once we have real-workload signal; until then, credential in scope = -//! human review. +//! Categories: //! -//! Severity: +//! 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. //! -//! - Rows 1–3 (link-local, bypass+credential, L4+credential) emit -//! `RiskLevel::High`. These are cases the prover cannot bound. -//! - Row 4 (L7-narrow+credential) emits `RiskLevel::Medium`. The reach is -//! bounded; the *action* (authenticated mutation) is what needs eyes. -//! -//! Severity does not change the auto-approval gate — any finding blocks -//! auto-approval. MEDIUM exists for audit/UI triage signal. The -//! `RiskLevel::Critical` variant is retained for future use; v1 never emits it. - +//! 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, RiskLevel}; +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`). +/// 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. +/// 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(), @@ -60,16 +56,17 @@ pub(crate) fn is_link_local(host: &str) -> bool { } } -/// Check for data exfiltration / privileged-action paths against the v1 -/// calibration table above. +/// 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: most v1 risks (link-local IMDS, L4+credential authenticated -/// writes, bypass-binary + credential) don't require *readable* filesystem -/// content to be dangerous. The credential itself is the lever, not what's -/// in `/etc/`. -pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { - let mut exfil_paths: Vec = Vec::new(); +/// 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); @@ -79,292 +76,214 @@ pub fn check_data_exfiltration(model: &ReachabilityModel) -> Vec { for eid in &model.endpoints { let expr = model.can_exfil_via_endpoint(bpath, eid); + if model.check_sat(&expr) != SatResult::Sat { + continue; + } - if model.check_sat(&expr) == SatResult::Sat { - let host_is_link_local = is_link_local(&eid.host); - let has_credential = !model.credentials.credentials_for_host(&eid.host).is_empty(); - // Check the L7 enforcement of THIS specific rule (eid.policy_name), - // not any rule for the same host:port. Two rules can coexist on - // the same endpoint — one L7-scoped, one L4-only — and each - // must be evaluated on its own terms. Otherwise iteration order - // (HashMap) leaks into the verdict. - let ep_is_l7 = is_endpoint_in_rule_l7_enforced( - &model.policy, - &eid.policy_name, - &eid.host, - eid.port, - ); - let ep_is_narrow = is_endpoint_in_rule_narrowly_bounded( - &model.policy, - &eid.policy_name, - &eid.host, - eid.port, - ); - let bypass = cap.bypasses_l7(); + let host_is_link_local = is_link_local(&eid.host); + let has_credential = !model.credentials.credentials_for_host(&eid.host).is_empty(); + + // Tier 1: link-local. Unconditional. Other categories not + // emitted on link-local hosts — the link-local 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; + } - // v1 emission table — see module docs. - let (l7_status, mut mechanism) = if host_is_link_local { - ( - "link_local".to_owned(), - format!( - "Link-local endpoint — {bpath} can reach the host's metadata range \ - (cloud-credential exfiltration territory regardless of declared scopes)" - ), - ) - } else if bypass && has_credential { - ( - "l7_bypassed".to_owned(), - format!( - "{} — uses non-HTTP protocol, bypasses L7 inspection, and a credential \ - is in scope for this host", - cap.description - ), - ) - } else if has_credential && (!ep_is_l7 || !ep_is_narrow) { - // L4-only OR L7-but-effectively-unbounded (access: full, - // wildcard method, wildcard path) — both collapse to - // "credentialed reach the prover cannot narrow." HIGH. - ( - "l4_only".to_owned(), - format!( - "Endpoint with a credential in scope and no effective method/path bound \ - ({bpath} can send arbitrary authenticated requests)" - ), - ) - } else if ep_is_l7 && has_credential { - // ep_is_l7 && ep_is_narrow — narrow L7 method/path with - // a credential in scope. MEDIUM: bounded reach, but - // authenticated action that may be destructive. - ( - "l7_credentialed".to_owned(), - format!( - "L7-enforced endpoint with narrow method/path bounds and a credential in \ - scope — the bounded action set is authenticated, and {bpath} can execute \ - potentially destructive mutations against the host's API" - ), - ) - } else { - // v1: any other SAT path has no credential in scope, so - // no privileged action is available. Examples that fall - // here: - // - L4-only with no credential in scope - // - L7-enforced with no credential in scope - // - bypass-L7 binary with no credential in scope - continue; - }; + // Un-credentialed reach is not a tracked risk. + if !has_credential { + continue; + } - if !cap.exfil_mechanism.is_empty() { - mechanism = format!("{}. Exfil via: {}", mechanism, cap.exfil_mechanism); - } + // 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; + } - exfil_paths.push(ExfilPath { + // 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, + mechanism: format!( + "Method {method} allowed for {bpath} on {host}:{port}", + host = eid.host, + port = eid.port, + ), policy_name: eid.policy_name.clone(), - l7_status, + category: category::CAPABILITY_EXPANSION.to_string(), + method, }); } } } - if exfil_paths.is_empty() { - return Vec::new(); - } - - let readable = model.policy.filesystem_policy.readable_paths(); - let n_readable = readable.len(); - let has_l4_only = exfil_paths.iter().any(|p| p.l7_status == "l4_only"); - let has_bypass = exfil_paths.iter().any(|p| p.l7_status == "l7_bypassed"); - let has_link_local = exfil_paths.iter().any(|p| p.l7_status == "link_local"); - let has_l7_credentialed = exfil_paths.iter().any(|p| p.l7_status == "l7_credentialed"); - - let mut remediation = Vec::new(); - if has_link_local { - remediation.push( - "Endpoint host is in a link-local range (cloud-metadata territory). \ - Sandboxes should not reach these endpoints — reaching them can return \ - host credentials the sandbox should not have. If access is truly \ - intended, the policy must be approved by a human operator." - .to_owned(), - ); - } - if has_l4_only { - remediation.push( - "Add `protocol: rest` with specific L7 rules to L4-only endpoints \ - to enable HTTP inspection and restrict to safe methods/paths." - .to_owned(), - ); - } - if has_bypass { - remediation.push( - "Binaries using non-HTTP protocols (git, ssh, nc) bypass L7 inspection. \ - Remove these binaries from the policy if write access is not intended." - .to_owned(), - ); + let mut findings = Vec::new(); + if !link_local_paths.is_empty() { + findings.push(build_finding( + category::LINK_LOCAL_REACH, + "Link-Local Reach", + "Reach to a host in a link-local range — cloud-metadata territory.", + link_local_paths, + vec![ + "Endpoint host is in a link-local range (cloud-metadata territory). \ + Sandboxes should not reach these endpoints — reaching them can return \ + host credentials the sandbox should not have." + .to_owned(), + ], + )); } - if has_l7_credentialed { - remediation.push( - "Endpoint has a credential in scope. Even with narrow L7 method/path \ - bounds, authenticated actions can be destructive (writes, deletes, \ - config changes). A human reviewer should confirm the intent." - .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(), + ], + )); } - remediation - .push("Restrict filesystem read access to only the paths the agent needs.".to_owned()); - - // Split paths by severity tier. Two tiers in v1: HIGH for paths the - // model cannot bound (link-local, L4+credential, bypass-L7+credential), - // MEDIUM for L7-enforced+credential (bounded but authenticated, deserves - // human eyes but not the same kind of red flag). Splitting into separate - // Findings keeps the audit honest — a reviewer sees the worst tier on - // its own line, can't be misled by a roll-up. - let (l7_cred_paths, high_paths): (Vec<_>, Vec<_>) = exfil_paths - .into_iter() - .partition(|p| p.l7_status == "l7_credentialed"); - - let mut findings = Vec::new(); - - if !high_paths.is_empty() { - let paths: Vec = high_paths.into_iter().map(FindingPath::Exfil).collect(); - let n_paths = paths.len(); - findings.push(Finding { - query: "data_exfiltration".to_owned(), - title: "Data Exfiltration Paths Detected".to_owned(), - description: format!( - "{n_paths} path(s) flagged by v1 calibration ({n_readable} readable filesystem path(s) in scope)." - ), - risk: RiskLevel::High, - paths, - remediation: remediation.clone(), - accepted: false, - accepted_reason: String::new(), - }); + 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 !l7_cred_paths.is_empty() { - let paths: Vec = l7_cred_paths.into_iter().map(FindingPath::Exfil).collect(); - let n_paths = paths.len(); - findings.push(Finding { - query: "data_exfiltration".to_owned(), - title: "Credentialed L7 Access — Human Review Recommended".to_owned(), - description: format!( - "{n_paths} L7-bounded path(s) with a credential in scope. The action set is narrow but authenticated." - ), - risk: RiskLevel::Medium, - paths, - remediation, - accepted: false, - accepted_reason: String::new(), - }); + 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 } -/// Reserved for future intent-aware write-bypass logic. -/// -/// v1 consolidates all emission into `check_data_exfiltration` per the -/// calibration table; this function returns empty so the public API stays -/// stable while we figure out what shape an intent-aware check should take -/// in v2. -pub fn check_write_bypass(_model: &ReachabilityModel) -> Vec { - Vec::new() +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 both verification queries. +/// Run all queries (single entry point for end-to-end callers). pub fn run_all_queries(model: &ReachabilityModel) -> Vec { - let mut findings = Vec::new(); - findings.extend(check_data_exfiltration(model)); - findings.extend(check_write_bypass(model)); - findings + check_credential_safety(model) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/// Check whether the specific (`policy_name`, host, port) endpoint is -/// L7-enforced. -/// -/// Importantly, this is **per-rule**, not aggregated across the whole policy. -/// Two rules can target the same `host:port` with different enforcement (one -/// L7, one L4); each is evaluated on its own terms so the prover doesn't -/// leak `HashMap` iteration order into the verdict. -fn is_endpoint_in_rule_l7_enforced( - policy: &crate::policy::PolicyModel, - policy_name: &str, - host: &str, - port: u16, -) -> bool { - let Some(rule) = policy.network_policies.get(policy_name) else { - return false; - }; - for ep in &rule.endpoints { - if ep.host.eq_ignore_ascii_case(host) && ep.effective_ports().contains(&port) { - return ep.is_l7_enforced(); - } - } - false -} - -/// Whether the specific (`policy_name`, host, port) endpoint is L7-enforced -/// AND its allow set is **actually narrow** in both method and path axes. -/// -/// L7 enforcement with `access: full` (or rules containing `method: "*"` / -/// `path: "**"`) is L4-equivalent in reachability — the L7 protocol annotation -/// doesn't bound what the binary can do, so a credentialed L7+full proposal -/// should be flagged the same way as L4+credential (HIGH), not as a narrow -/// L7+credential bounded action (MEDIUM). This helper draws that line. -fn is_endpoint_in_rule_narrowly_bounded( +/// 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, -) -> bool { +) -> HashSet { let Some(rule) = policy.network_policies.get(policy_name) else { - return false; + return HashSet::new(); }; for ep in &rule.endpoints { if ep.host.eq_ignore_ascii_case(host) && ep.effective_ports().contains(&port) { - return endpoint_is_narrowly_bounded(ep); - } - } - false -} - -fn endpoint_is_narrowly_bounded(ep: &crate::policy::Endpoint) -> bool { - if !ep.is_l7_enforced() { - return false; - } - match ep.access.as_str() { - // `access: full` is L4-equivalent reach despite the L7 protocol - // annotation — not narrow. - "full" => false, - // Method-bounded shorthands ("read-only" = GET/HEAD/OPTIONS; - // "read-write" = adds POST/PUT/PATCH). Path-unrestricted but - // method-bounded — narrow enough to stay MEDIUM. - "read-only" | "read-write" => true, - // Rules-based: need at least one rule, all with bounded method - // (not `*`) AND bounded path (not empty / `**` / `/**`). Any - // wildcard in either axis collapses the L7 narrowing. - _ => { - !ep.rules.is_empty() - && ep.rules.iter().all(|r| { - let m = r.method.to_uppercase(); - let p = r.path.as_str(); - m != "*" && !p.is_empty() && p != "**" && p != "/**" - }) + return ep.allowed_methods(); } } + HashSet::new() } -// `collect_credential_actions` removed in v1 along with the original -// `check_write_bypass` logic. When intent-aware write-bypass detection is -// reintroduced, this helper (or its successor) will live here. - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -397,89 +316,8 @@ mod tests { #[test] fn is_link_local_rejects_hostnames() { - // We don't DNS-resolve; hostname strings always return false. assert!(!is_link_local("api.github.com")); assert!(!is_link_local("metadata.google.internal")); assert!(!is_link_local("")); } - - // ── narrowness classifier ── - - fn make_endpoint(access: &str, rules: Vec<(&str, &str)>) -> crate::policy::Endpoint { - crate::policy::Endpoint { - host: "api.example.com".to_owned(), - port: 443, - ports: vec![], - protocol: "rest".to_owned(), - tls: String::new(), - enforcement: "enforce".to_owned(), - access: access.to_owned(), - rules: rules - .into_iter() - .map(|(m, p)| crate::policy::L7Rule { - method: m.to_owned(), - path: p.to_owned(), - command: String::new(), - }) - .collect(), - allowed_ips: vec![], - } - } - - #[test] - fn endpoint_narrow_classifier_access_full_is_not_narrow() { - let ep = make_endpoint("full", vec![]); - assert!( - !endpoint_is_narrowly_bounded(&ep), - "`access: full` is L4-equivalent and must NOT be considered narrow", - ); - } - - #[test] - fn endpoint_narrow_classifier_read_only_and_read_write_are_narrow() { - // Bounded method set; treated as narrow (MEDIUM under the credential - // calibration). Reviewer suggested keeping the read-* shorthands in - // the narrow bucket — they bound destructiveness. - assert!(endpoint_is_narrowly_bounded(&make_endpoint( - "read-only", - vec![] - ))); - assert!(endpoint_is_narrowly_bounded(&make_endpoint( - "read-write", - vec![] - ))); - } - - #[test] - fn endpoint_narrow_classifier_wildcard_method_is_not_narrow() { - let ep = make_endpoint("", vec![("*", "/repos/owner/repo")]); - assert!( - !endpoint_is_narrowly_bounded(&ep), - "rules with `method: \"*\"` are L4-equivalent reach in the method axis", - ); - } - - #[test] - fn endpoint_narrow_classifier_wildcard_path_is_not_narrow() { - for path in ["**", "/**", ""] { - let ep = make_endpoint("", vec![("PUT", path)]); - assert!( - !endpoint_is_narrowly_bounded(&ep), - "path {path:?} is unbounded; the rule must NOT be considered narrow", - ); - } - } - - #[test] - fn endpoint_narrow_classifier_explicit_method_and_path_is_narrow() { - let ep = make_endpoint("", vec![("PUT", "/repos/owner/repo/contents/file.md")]); - assert!(endpoint_is_narrowly_bounded(&ep)); - } - - #[test] - fn endpoint_narrow_classifier_l4_only_is_not_narrow() { - let mut ep = make_endpoint("", vec![("GET", "/path")]); - ep.protocol = String::new(); // L4-only — fails the L7-enforced precondition - assert!(!endpoint_is_narrowly_bounded(&ep)); - } } diff --git a/crates/openshell-prover/src/report.rs b/crates/openshell-prover/src/report.rs index 620742d44..f250eb1cd 100644 --- a/crates/openshell-prover/src/report.rs +++ b/crates/openshell-prover/src/report.rs @@ -2,244 +2,122 @@ // 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::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use owo_colors::OwoColorize; -use crate::finding::{Finding, FindingPath, RiskLevel}; +use crate::finding::{Finding, FindingPath, category}; // --------------------------------------------------------------------------- -// Compact titles (short labels for each query type) +// Category labels (display strings keyed off `Finding.query`) // --------------------------------------------------------------------------- -fn compact_title(query: &str) -> &str { +fn category_label(query: &str) -> &str { match query { - "data_exfiltration" => "Data exfiltration possible", - "write_bypass" => "Write bypass \u{2014} read-only intent violated", - _ => "Unknown finding", + 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", } } // --------------------------------------------------------------------------- -// Compact detail line +// One-line shorthand (used by the gateway's `validation_result`) // --------------------------------------------------------------------------- -fn compact_detail(finding: &Finding) -> String { - match finding.query.as_str() { - "data_exfiltration" => { - let mut by_status: HashMap<&str, HashSet> = HashMap::new(); - for path in &finding.paths { - if let FindingPath::Exfil(p) = path { - by_status - .entry(&p.l7_status) - .or_default() - .insert(format!("{}:{}", p.endpoint_host, p.endpoint_port)); - } - } - let mut parts = Vec::new(); - if let Some(eps) = by_status.get("link_local") { - let mut sorted: Vec<&String> = eps.iter().collect(); - sorted.sort(); - parts.push(format!( - "link-local (cloud metadata): {}", - sorted - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - )); - } - if let Some(eps) = by_status.get("l4_only") { - let mut sorted: Vec<&String> = eps.iter().collect(); - sorted.sort(); - parts.push(format!( - "L4-only: {}", - sorted - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - )); - } - if let Some(eps) = by_status.get("l7_bypassed") { - let mut sorted: Vec<&String> = eps.iter().collect(); - sorted.sort(); - parts.push(format!( - "wire protocol bypass: {}", - sorted - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - )); - } - if let Some(eps) = by_status.get("l7_allows_write") { - let mut sorted: Vec<&String> = eps.iter().collect(); - sorted.sort(); - parts.push(format!( - "L7 write: {}", - sorted - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - )); - } - if let Some(eps) = by_status.get("l7_credentialed") { - let mut sorted: Vec<&String> = eps.iter().collect(); - sorted.sort(); - parts.push(format!( - "L7 + credential in scope: {}", - sorted - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - )); - } - parts.join("; ") - } - "write_bypass" => { - let mut reasons = HashSet::new(); - let mut endpoints = HashSet::new(); - for path in &finding.paths { - if let FindingPath::WriteBypass(p) = path { - reasons.insert(p.bypass_reason.as_str()); - endpoints.insert(format!("{}:{}", p.endpoint_host, p.endpoint_port)); - } - } - let mut sorted_eps: Vec<&String> = endpoints.iter().collect(); - sorted_eps.sort(); - let ep_list = sorted_eps - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", "); - if reasons.contains("l4_only") && reasons.contains("l7_bypass_protocol") { - format!("L4-only + wire protocol: {ep_list}") - } else if reasons.contains("l4_only") { - format!("L4-only (no inspection): {ep_list}") - } else if reasons.contains("l7_bypass_protocol") { - format!("wire protocol bypasses L7: {ep_list}") - } else { - String::new() - } - } - _ => String::new(), - } -} - -// --------------------------------------------------------------------------- -// One-line shorthand (for embedding findings in other tools' output) -// --------------------------------------------------------------------------- - -/// Format a finding as a single uncolored line for embedding in other -/// human-facing surfaces (gateway `validation_result`, demo output, logs). +/// Render a finding as one or more single-line strings, suitable for +/// embedding in the gateway `validation_result`, demo output, and logs. /// -/// Shape: `[] : ` — e.g. -/// `[HIGH] data_exfiltration: L4-only: api.github.com:443`. Falls back to -/// `[] ` when no detail is available. +/// 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 detail = compact_detail(finding); - if detail.is_empty() { - format!("[{}] {}", risk_label(finding.risk), finding.query) - } else { - format!("[{}] {}: {detail}", risk_label(finding.risk), finding.query) - } -} - -// --------------------------------------------------------------------------- -// Risk formatting -// --------------------------------------------------------------------------- - -fn risk_label(risk: RiskLevel) -> String { - match risk { - RiskLevel::Critical => "CRITICAL".to_owned(), - RiskLevel::High => "HIGH".to_owned(), - RiskLevel::Medium => "MEDIUM".to_owned(), + 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 print_risk_label(risk: RiskLevel) { - match risk { - RiskLevel::Critical => print!("{}", "CRITICAL".bold().red()), - RiskLevel::High => print!("{}", " HIGH".red()), - RiskLevel::Medium => print!("{}", " MEDIUM".yellow()), +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 +// Compact output (CLI lint mode) // --------------------------------------------------------------------------- -/// Render compact output (one-line-per-finding for demos and CI). -/// Returns exit code: 0 = pass, 1 = critical/high found. +/// 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 { - print!(" "); - print_risk_label(finding.risk); - println!(" {}", compact_title(&finding.query)); - let detail = compact_detail(finding); - if !detail.is_empty() { - println!(" {detail}"); + for path in &finding.paths { + let FindingPath::Exfil(p) = path; + println!(" {} {}", "•".yellow(), format_path_line(&finding.query, p)); + } + if !finding.paths.is_empty() { + println!(); } - println!(); } for finding in &accepted { println!( - " {} {}", + " {} {}", "ACCEPTED".dimmed(), - compact_title(&finding.query).dimmed() + category_label(&finding.query).dimmed() ); } if !accepted.is_empty() { println!(); } - // Verdict - let mut counts: HashMap = HashMap::new(); - for f in &active { - *counts.entry(f.risk).or_default() += 1; - } - let has_critical = counts.contains_key(&RiskLevel::Critical); - let has_high = counts.contains_key(&RiskLevel::High); - let has_medium = counts.contains_key(&RiskLevel::Medium); let accepted_note = if accepted.is_empty() { String::new() } else { format!(", {} accepted", accepted.len()) }; - if has_critical || has_high { - let n = counts.get(&RiskLevel::Critical).unwrap_or(&0) - + counts.get(&RiskLevel::High).unwrap_or(&0); - println!( - " {} {n} critical/high gaps{accepted_note}", - " FAIL ".white().bold().on_red() - ); - 1 - } else if has_medium { - let n = counts.get(&RiskLevel::Medium).unwrap_or(&0); + let path_count: usize = active.iter().map(|f| f.paths.len()).sum(); + if path_count > 0 { println!( - " {} {n} medium-risk gap(s){accepted_note}", + " {} {path_count} finding path(s) require review{accepted_note}", " REVIEW ".black().bold().on_yellow() ); 1 - } else if !active.is_empty() { - println!( - " {} advisories only{accepted_note}", - " PASS ".black().bold().on_yellow() - ); - 0 } else { println!( - " {} all findings accepted{accepted_note}", + " {} no findings{accepted_note}", " PASS ".white().bold().on_green() ); 0 @@ -251,7 +129,7 @@ pub fn render_compact(findings: &[Finding], _policy_path: &str, _credentials_pat // --------------------------------------------------------------------------- /// Render a full terminal report with finding panels. -/// Returns exit code: 0 = pass, 1 = critical/high found. +/// 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() @@ -274,52 +152,36 @@ pub fn render_report(findings: &[Finding], policy_path: &str, credentials_path: let active: Vec<&Finding> = findings.iter().filter(|f| !f.accepted).collect(); let accepted: Vec<&Finding> = findings.iter().filter(|f| f.accepted).collect(); - // Summary - let mut counts: HashMap = HashMap::new(); + // Per-category summary + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); for f in &active { - *counts.entry(f.risk).or_default() += 1; + *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 level in [RiskLevel::Critical, RiskLevel::High, RiskLevel::Medium] { - if let Some(&count) = counts.get(&level) { - match level { - RiskLevel::Critical => { - println!(" {:>10} {count}", "CRITICAL".bold().red()); - } - RiskLevel::High => println!(" {:>10} {count}", "HIGH".red()), - RiskLevel::Medium => println!(" {:>10} {count}", "MEDIUM".yellow()), - } - } + for (query, count) in &counts { + println!(" {:>40} {count} path(s)", category_label(query).yellow()); } if !accepted.is_empty() { - println!(" {:>10} {}", "ACCEPTED".dimmed(), accepted.len()); + println!(" {:>40} {}", "ACCEPTED".dimmed(), accepted.len()); } println!(); - if active.is_empty() && accepted.is_empty() { - println!("{}", "No findings. Policy posture is clean.".green().bold()); - return 0; - } - - // Per-finding details for (i, finding) in active.iter().enumerate() { - let label = risk_label(finding.risk); - let border = match finding.risk { - RiskLevel::Critical => format!("{}", format!("[{label}]").bold().red()), - RiskLevel::High => format!("{}", format!("[{label}]").red()), - RiskLevel::Medium => format!("{}", format!("[{label}]").yellow()), - }; - - println!("--- Finding #{} {border} ---", i + 1); + println!( + "--- Finding #{} [{}] ---", + i + 1, + category_label(&finding.query) + ); println!(" {}", finding.title.bold()); println!(" {}", finding.description); println!(); - - // Render paths render_paths(&finding.paths); - - // Remediation if !finding.remediation.is_empty() { println!(" {}", "Remediation:".bold()); for r in &finding.remediation { @@ -329,13 +191,12 @@ pub fn render_report(findings: &[Finding], policy_path: &str, credentials_path: } } - // Accepted findings if !accepted.is_empty() { - println!("{}", "--- Accepted Risks ---".dimmed()); + println!("{}", "--- Accepted Findings ---".dimmed()); for finding in &accepted { println!( " {} {}", - risk_label(finding.risk).dimmed(), + category_label(&finding.query).dimmed(), finding.title.dimmed() ); println!( @@ -346,42 +207,20 @@ pub fn render_report(findings: &[Finding], policy_path: &str, credentials_path: } } - // Verdict - let has_critical = counts.contains_key(&RiskLevel::Critical); - let has_high = counts.contains_key(&RiskLevel::High); - let has_medium = counts.contains_key(&RiskLevel::Medium); + 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 has_critical { + if path_count > 0 { println!( "{}{accepted_note}", - "FAIL \u{2014} Critical gaps found.".bold().red() - ); - 1 - } else if has_high { - println!( - "{}{accepted_note}", - "FAIL \u{2014} High-risk gaps found.".bold().red() - ); - 1 - } else if has_medium { - println!( - "{}{accepted_note}", - "REVIEW \u{2014} Medium-risk gaps require human attention." + "REVIEW \u{2014} prover findings require human attention." .bold() .yellow() ); 1 - } else if !active.is_empty() { - println!( - "{}{accepted_note}", - "PASS \u{2014} Advisories only.".bold().yellow() - ); - 0 } else { println!( "{}{accepted_note}", @@ -395,88 +234,63 @@ fn render_paths(paths: &[FindingPath]) { if paths.is_empty() { return; } - - match &paths[0] { - FindingPath::Exfil(_) => render_exfil_paths(paths), - FindingPath::WriteBypass(_) => render_write_bypass_paths(paths), - } -} - -fn render_exfil_paths(paths: &[FindingPath]) { - println!( - " {:<30} {:<25} {:<15} {}", - "Binary".bold(), - "Endpoint".bold(), - "L7 Status".bold(), - "Mechanism".bold(), - ); + // Group paths by binary for compact display. + let mut by_binary: BTreeMap<&str, Vec<&crate::finding::ExfilPath>> = BTreeMap::new(); for path in paths { - if let FindingPath::Exfil(p) = path { - let l7_display = match p.l7_status.as_str() { - "link_local" => format!("{}", "link-local".bold().red()), - "l4_only" => format!("{}", "L4-only".red()), - "l7_bypassed" => format!("{}", "bypassed".red()), - "l7_allows_write" => format!("{}", "L7 write".yellow()), - "l7_credentialed" => format!("{}", "L7+cred".yellow()), - _ => p.l7_status.clone(), - }; - let ep = format!("{}:{}", p.endpoint_host, p.endpoint_port); - // Truncate mechanism for display - let mech = if p.mechanism.len() > 50 { - format!("{}...", &p.mechanism[..47]) - } else { - p.mechanism.clone() - }; - println!(" {:<30} {:<25} {:<15} {}", p.binary, ep, l7_display, mech); - } + let FindingPath::Exfil(p) = path; + by_binary.entry(&p.binary).or_default().push(p); } - println!(); -} - -fn render_write_bypass_paths(paths: &[FindingPath]) { - println!( - " {:<30} {:<25} {:<15} {}", - "Binary".bold(), - "Endpoint".bold(), - "Bypass".bold(), - "Intent".bold(), - ); - for path in paths { - if let FindingPath::WriteBypass(p) = path { - let ep = format!("{}:{}", p.endpoint_host, p.endpoint_port); - let bypass_display = match p.bypass_reason.as_str() { - "l4_only" => format!("{}", "L4-only".red()), - "l7_bypass_protocol" => format!("{}", "wire proto".red()), - _ => p.bypass_reason.clone(), - }; + 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!( - " {:<30} {:<25} {:<15} {}", - p.binary, ep, bypass_display, p.policy_intent + " Methods: {}", + methods.iter().cloned().collect::>().join(", ") ); } } println!(); } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; - use crate::finding::{ExfilPath, WriteBypassPath}; + 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 exfil_finding(l7_status: &str, host: &str, port: u16) -> Finding { + fn finding_with(category_name: &str, paths: Vec) -> Finding { Finding { - query: "data_exfiltration".to_owned(), - title: "Data exfiltration possible".to_owned(), + query: category_name.to_owned(), + title: "test".to_owned(), description: String::new(), - risk: RiskLevel::High, - paths: vec![FindingPath::Exfil(ExfilPath { - binary: "/usr/bin/curl".to_owned(), - endpoint_host: host.to_owned(), - endpoint_port: port, - mechanism: String::new(), - policy_name: String::new(), - l7_status: l7_status.to_owned(), - })], + paths: paths.into_iter().map(FindingPath::Exfil).collect(), remediation: vec![], accepted: false, accepted_reason: String::new(), @@ -484,52 +298,70 @@ mod tests { } #[test] - fn finding_shorthand_renders_exfil_l4_only() { - let f = exfil_finding("l4_only", "api.github.com", 443); + 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), - "[HIGH] data_exfiltration: L4-only: api.github.com:443" + "capability_expansion: PUT on api.github.com:443 via /usr/bin/curl" ); } #[test] - fn finding_shorthand_renders_write_bypass() { - let f = Finding { - query: "write_bypass".to_owned(), - title: String::new(), - description: String::new(), - risk: RiskLevel::High, - paths: vec![FindingPath::WriteBypass(WriteBypassPath { - binary: "/usr/bin/curl".to_owned(), - endpoint_host: "api.github.com".to_owned(), - endpoint_port: 443, - policy_name: String::new(), - policy_intent: String::new(), - bypass_reason: "l4_only".to_owned(), - credential_actions: vec![], - })], - remediation: vec![], - accepted: false, - accepted_reason: String::new(), - }; + 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), - "[HIGH] write_bypass: L4-only (no inspection): api.github.com:443" + "credential_reach_expansion: uploads.github.com:443 via /usr/bin/curl" ); } #[test] - fn finding_shorthand_falls_back_when_detail_empty() { - let f = Finding { - query: "unknown_query".to_owned(), - title: String::new(), - description: String::new(), - risk: RiskLevel::Critical, - paths: vec![], - remediation: vec![], - accepted: false, - accepted_reason: String::new(), - }; - assert_eq!(finding_shorthand(&f), "[CRITICAL] unknown_query"); + 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-sandbox/src/skills/policy_advisor.md b/crates/openshell-sandbox/src/skills/policy_advisor.md index 1fcc123ba..f456b42c9 100644 --- a/crates/openshell-sandbox/src/skills/policy_advisor.md +++ b/crates/openshell-sandbox/src/skills/policy_advisor.md @@ -46,14 +46,14 @@ 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. **Narrow L7 proposals against hosts - with no credential in scope auto-approve without human review** (see - Auto-approval below). L7 to a host where a credential is in scope flags - MEDIUM and still goes to human review. L4 grants with a credential in - scope always require human approval, so L7 is the agent-speed path - wherever L7 inspection is possible. 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. +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 + 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, exact method, and the smallest safe path. 6. Submit the proposal, save `accepted_chunk_ids` from the response, and @@ -139,42 +139,45 @@ the gateway approves the chunk with actor `system:auto` and the second. When the prover does find something — or the sandbox is in `"manual"` mode — the chunk lands in `pending` for human review. -What the prover flags: - -- **`HIGH` — Link-local hosts** (`169.254.0.0/16`, `fe80::/10`). Cloud - metadata endpoints like `169.254.169.254` live here. **Never** - propose access to these — the proposal will always require human - review, regardless of credential state. -- **`HIGH` — L4 grants** (no `protocol: rest`) to a host where a - sandbox credential is in scope. The L4 layer has no inspection; - combined with a privileged credential, this is unbounded - reachability. -- **`HIGH` — Bypass-L7 binaries** (`/usr/bin/git`, +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`). 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`) - bound to any host where a credential is in scope. Wire protocols - opaque to L7 inspection are unbounded by L7 scoping. -- **`MEDIUM` — Narrow L7 rules to a host where a credential is in - scope.** The L7 proxy bounds *what* you can do, but the bounded - action is still authenticated. PUT, POST, PATCH, DELETE can mutate - state. v1 defers to a human reviewer for any credentialed action; - there's no way to "narrow" further to make this auto-approve. The - L7 + credential row is the smallest amount of escalation v1 demands - — one human approval per credentialed action, and you're done. + 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): -- L7 (REST) rules against hosts where **no credential is in scope** - (no attached provider declares the host). Public-content fetches - from CDNs, schema URLs, public API discovery — these go through. -- Any proposal that adds no path the prover can reach with a - privileged binary against a credentialed host. +- 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 changing the binary or scope can -turn a MEDIUM into "no new findings." Credentialed mutations are -*supposed* to escalate; don't try to bypass that — propose the narrow -rule and wait for review. +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. ## Refining an earlier auto-suggested rule diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index bf6be9812..7b635fb67 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -362,8 +362,8 @@ fn summarize_draft_chunk_rule(chunk: &DraftChunkRecord) -> Result: ` -/// line per finding (shorthand from `openshell-prover`) +/// - `prover: N new finding(s)` followed by one ` : ` +/// 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 @@ -526,23 +526,29 @@ async fn build_credential_set_for_sandbox( /// 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 { - match path { - FindingPath::Exfil(p) => format!( - "exfil|{}|{}:{}|{}", - p.binary, p.endpoint_host, p.endpoint_port, p.l7_status - ), - FindingPath::WriteBypass(p) => format!( - "writebypass|{}|{}:{}|{}", - p.binary, p.endpoint_host, p.endpoint_port, p.bypass_reason - ), - } + 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 endpoint added to an existing `data_exfiltration` -/// finding) surfaces only the new evidence paths. +/// 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| { @@ -552,7 +558,7 @@ fn finding_delta(base: &[Finding], merged: &[Finding]) -> Vec { .map(move |p| (query.clone(), finding_path_key(p))) }) .collect(); - let mut delta = Vec::new(); + let mut delta: Vec = Vec::new(); for finding in merged { let new_paths: Vec = finding .paths @@ -568,6 +574,32 @@ fn finding_delta(base: &[Finding], merged: &[Finding]) -> Vec { ..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 } @@ -5123,11 +5155,21 @@ mod tests { .find(|c| c.id == mechanistic_chunk_id) .expect("mechanistic chunk present"); assert_eq!(mech.status, "pending"); - assert!(mech.validation_result.contains("[HIGH]")); + // 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: {}", + mech.validation_result + ); // Step 2: the agent refines into a narrow L7 proposal for the SAME - // (host, port, binary). Under v1 calibration, L7 with a credential - // in scope flags MEDIUM (bounded but authenticated), so the agent + // (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, @@ -5196,14 +5238,17 @@ mod tests { assert_eq!( agent.status, "pending", - "agent-authored narrow L7 with credential in scope flags MEDIUM under v1 \ - calibration; it should land in pending for human review, not auto-approve; \ - got: {}", + "agent-authored L7 PUT with credential in scope must land in pending; \ + the baseline policy has no pre-existing rule for curl on api.github.com \ + so the agent's chunk grants brand-new credentialed reach. got: {}", agent.status ); assert!( - agent.validation_result.contains("[MEDIUM]"), - "agent chunk should carry the MEDIUM L7+credential verdict; got: {}", + 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_eq!( @@ -5313,14 +5358,13 @@ mod tests { ); } - /// `protocol: rest, access: full` is L7-annotated but L4-equivalent in - /// reach — the L7 protocol doesn't actually bound what the binary can - /// do. With a credential in scope, this must emit HIGH (not MEDIUM), - /// because the agent has done no meaningful narrowing despite the L7 - /// dressing. Regression test for the narrowness classifier in - /// `openshell-prover::queries::endpoint_is_narrowly_bounded`. + /// `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.) #[tokio::test] - async fn agent_authored_l7_full_with_credential_records_high_finding() { + async fn agent_authored_l7_full_with_credential_emits_reach_expansion() { use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -5405,17 +5449,19 @@ mod tests { .into_inner(); let verdict = &draft.chunks[0].validation_result; assert!( - verdict.contains("[HIGH]"), - "L7 `access: full` with credential in scope must emit HIGH (not MEDIUM) — \ - the L7 annotation doesn't actually narrow reach. got: {verdict}" + 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("[MEDIUM]"), - "MEDIUM must NOT fire when the L7 scope is effectively all-methods; got: {verdict}" + !verdict.contains("capability_expansion"), + "capability_expansion must be suppressed when reach itself is new; got: {verdict}" ); assert_eq!( draft.chunks[0].status, "pending", - "HIGH finding must keep the chunk in pending despite auto mode; got: {}", + "any prover finding must keep the chunk in pending despite auto mode; got: {}", draft.chunks[0].status ); } @@ -5773,8 +5819,9 @@ mod tests { "expected first line like `prover: N new finding(s)`, got: {verdict}" ); assert!( - verdict.contains("[HIGH]"), - "v1 emits HIGH for L4 + credential in scope; got: {verdict}" + 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"), @@ -5946,8 +5993,9 @@ mod tests { .into_inner(); let verdict = &draft.chunks[0].validation_result; assert!( - verdict.contains("[HIGH]"), - "link-local proposal must emit HIGH regardless of credentials; got: {verdict}" + 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"), @@ -6271,12 +6319,30 @@ mod tests { assert_eq!( step2_chunk.status, "pending", - "credentialed L7 proposal under v2 + auto mode must stay pending (MEDIUM); got: {}", + "credentialed L7 PUT under v2 + auto mode must stay 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("[MEDIUM]"), - "credentialed L7 must carry MEDIUM verdict; got: {}", + !step2_chunk + .validation_result + .contains("capability_expansion"), + "capability_expansion must be suppressed when reach itself is new; got: {}", step2_chunk.validation_result ); } diff --git a/examples/agent-driven-policy-management/README.md b/examples/agent-driven-policy-management/README.md index 0a014589e..4d604d974 100644 --- a/examples/agent-driven-policy-management/README.md +++ b/examples/agent-driven-policy-management/README.md @@ -116,7 +116,7 @@ Validation: prover: no new findings ```text Validation: prover: 1 new finding - [HIGH] data_exfiltration: L4-only: api.github.com:443 + capability_expansion: PUT on api.github.com:443 via /usr/bin/curl ``` Other possible verdicts: `validation unavailable` (gateway-side prover infra diff --git a/examples/agent-driven-policy-management/agent-task.md b/examples/agent-driven-policy-management/agent-task.md index e2e9c4bdb..69e1a4e55 100644 --- a/examples/agent-driven-policy-management/agent-task.md +++ b/examples/agent-driven-policy-management/agent-task.md @@ -85,11 +85,12 @@ 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 flags MEDIUM: the proposal is narrow L7 but - the github credential is in scope, 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 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. - `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 492d73a63..1a451da38 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -390,7 +390,10 @@ start_agent_sandbox() { AGENT_PID="$!" } -# Strip `rule get` down to the approval contract: chunk, binary, access, risk. +# 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`). summarize_pending() { local pending="$1" sed 's/\x1b\[[0-9;]*m//g' "$pending" \ @@ -399,13 +402,11 @@ summarize_pending() { in_validation = 0 chunk_count = 0 validation_printed = 0 - severity_printed = 0 } /^[[:space:]]*Chunk:/ { in_validation = 0 chunk_count++ validation_printed = 0 - severity_printed = 0 if (chunk_count > 1) print "" sub(/^[[:space:]]*/, "") chunk_id = $2 @@ -446,16 +447,12 @@ summarize_pending() { print " " $0 next } - in_validation && /\[(LOW|MEDIUM|HIGH|CRITICAL)\]/ { - if (!severity_printed) { - severity = "UNKNOWN" - if ($0 ~ /\[LOW\]/) severity = "LOW" - if ($0 ~ /\[MEDIUM\]/) severity = "MEDIUM" - if ($0 ~ /\[HIGH\]/) severity = "HIGH" - if ($0 ~ /\[CRITICAL\]/) severity = "CRITICAL" - print " Severity: " severity - severity_printed = 1 - } + # 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 } @@ -469,7 +466,7 @@ pending_requires_review() { # 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)|\[(LOW|MEDIUM|HIGH|CRITICAL)\]' <<<"$clean"; then + 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 diff --git a/providers/github.yaml b/providers/github.yaml index cc24ae922..daf7f8316 100644 --- a/providers/github.yaml +++ b/providers/github.yaml @@ -13,11 +13,15 @@ credentials: auth_style: bearer header_name: authorization endpoints: + # api.github.com is the REST API surface. Defaults to read-only — + # writes require an explicit policy proposal so the agentic loop + + # prover can audit each capability change. - host: api.github.com port: 443 protocol: rest - access: read-write + access: read-only enforcement: enforce + # github.com is the git transport (clone / fetch by default). - host: github.com port: 443 protocol: rest From ff2eb8a3efb71f49cc7ac93b388e7ca5a4a5f2df Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Fri, 22 May 2026 13:45:17 -0700 Subject: [PATCH 5/6] fix(policy): move approval mode into settings --- architecture/security-policy.md | 23 +- crates/openshell-cli/src/run.rs | 32 +- crates/openshell-core/src/settings.rs | 21 + crates/openshell-prover/README.md | 4 +- .../src/skills/policy_advisor.md | 27 +- crates/openshell-server/src/grpc/policy.rs | 411 ++++++++++++++++-- .../policy.template.yaml | 3 +- proto/openshell.proto | 20 +- 8 files changed, 468 insertions(+), 73 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index fad9933cd..72ce1e4b1 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -77,17 +77,18 @@ agent-authored via `policy.local`); the gateway is the single referee. 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 per sandbox).** Auto-approval - fires when *both* (a) the prover delta is empty (`prover: no new findings`) - AND (b) the sandbox sets `spec.proposal_approval_mode = "auto"`. 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` as unmapped fields, with message text - `"auto-approved: no new prover findings"` — never `safe`. The opt-in gate - preserves OpenShell's default-deny posture: sandboxes that leave - `proposal_approval_mode` unset (proto3 default of `""`, treated as - `"manual"`) keep every proposal in `pending` for human review, even when - the prover sees no findings. +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. 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 diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 561aa6557..a5421970c 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1696,7 +1696,6 @@ pub async fn sandbox_create( policy, providers: configured_providers, template, - proposal_approval_mode: approval_mode.to_string(), ..SandboxSpec::default() }), name: name.unwrap_or_default().to_string(), @@ -1732,6 +1731,37 @@ 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![], + }) + .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 { diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 897317a5a..733bb1f03 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -59,6 +59,21 @@ 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"; + pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ // Gateway-level opt-in for provider profile policy composition. Defaults // to false when unset. @@ -79,6 +94,12 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ key: AGENT_POLICY_PROPOSALS_ENABLED_KEY, kind: SettingValueKind::Bool, }, + // 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, + }, // Test-only keys live behind the `dev-settings` feature flag so they // don't appear in production builds. #[cfg(feature = "dev-settings")] diff --git a/crates/openshell-prover/README.md b/crates/openshell-prover/README.md index 4291f0b24..f8b45eca6 100644 --- a/crates/openshell-prover/README.md +++ b/crates/openshell-prover/README.md @@ -10,8 +10,8 @@ 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 sandbox opts in via -`spec.proposal_approval_mode = "auto"`). +chunk pass through (when the reviewer opts in via the +`proposal_approval_mode` setting at either gateway or sandbox scope). ## What it decides diff --git a/crates/openshell-sandbox/src/skills/policy_advisor.md b/crates/openshell-sandbox/src/skills/policy_advisor.md index f456b42c9..724d17b66 100644 --- a/crates/openshell-sandbox/src/skills/policy_advisor.md +++ b/crates/openshell-sandbox/src/skills/policy_advisor.md @@ -127,17 +127,22 @@ A complete narrow REST-inspected rule looks like this: ## Auto-approval -Auto-approval is opt-in per sandbox. A sandbox set to -`proposal_approval_mode = "auto"` will auto-approve any proposal the -prover sees as empty-delta; sandboxes left in `"manual"` (the default) -route every proposal to human review regardless of the prover verdict. - -When the sandbox is in `"auto"` mode 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=`, and -`prover_delta=empty`. The agent's `/wait` returns approved in ~1 -second. When the prover does find something — or the sandbox is in -`"manual"` mode — the chunk lands in `pending` for human review. +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 diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 7b635fb67..8826bd733 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -114,6 +114,9 @@ fn emit_gateway_policy_audit_log( /// 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( sandbox_id: &str, sandbox_name: &str, @@ -121,11 +124,13 @@ fn emit_gateway_policy_auto_approve_audit_log( 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()), ]; let message = build_gateway_policy_audit_message( sandbox_id, @@ -784,12 +789,45 @@ async fn self_reject_mechanistic_if_already_covered( /// (`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( 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 @@ -834,11 +872,12 @@ async fn auto_approve_chunk( sandbox_id, sandbox_name, format!( - "auto-approved: no new prover findings (source={source_label}) — chunk {chunk_id}: {chunk_summary}" + "auto-approved: no new prover findings (source={source_label}, resolved_from={resolved_from}) — chunk {chunk_id}: {chunk_summary}" ), version, &hash, source_label, + resolved_from, ); info!( @@ -848,6 +887,7 @@ async fn auto_approve_chunk( version = version, policy_hash = %hash, source = %source_label, + resolved_from = %resolved_from, "Auto-approved chunk: no new prover findings" ); @@ -1959,15 +1999,13 @@ pub(super) async fn handle_submit_policy_analysis( // 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 per-sandbox behavior. Default (empty or - // explicit "manual") preserves OpenShell's default-deny posture: every - // proposal lands in `pending` for a human reviewer. Only sandboxes that - // explicitly set `proposal_approval_mode = "auto"` get prover-gated - // auto-approval for empty-delta proposals. - let auto_approve_enabled = sandbox - .spec - .as_ref() - .is_some_and(|spec| spec.proposal_approval_mode == "auto"); + // 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 @@ -1999,6 +2037,21 @@ pub(super) async fn handle_submit_policy_analysis( rejection_reasons.push("chunk missing rule_name".to_string()); continue; } + // `_provider_*` is the reserved namespace for rules synthesized from + // provider profiles during composition. Agent submissions that target + // those keys would merge directly into the provider rule and bypass + // the merge.rs guard that splits agent-authored chunks into their + // own rule so the prover sees their contribution honestly. Reject at + // the entry boundary — the agent never has reason to address a + // provider rule by name. + if chunk.rule_name.starts_with("_provider_") { + rejected += 1; + rejection_reasons.push(format!( + "chunk '{}' uses reserved '_provider_' rule-name prefix", + chunk.rule_name + )); + continue; + } if chunk.proposed_rule.is_none() { rejected += 1; rejection_reasons.push(format!("chunk '{}' missing proposed_rule", chunk.rule_name)); @@ -2119,12 +2172,13 @@ pub(super) async fn handle_submit_policy_analysis( // Auto-approval gate (proposer-agnostic, opt-in): only fire when // BOTH the prover found nothing new in this proposal's delta AND - // the sandbox owner opted in via `proposal_approval_mode = "auto"`. - // 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. + // 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( @@ -2133,6 +2187,7 @@ pub(super) async fn handle_submit_policy_analysis( sandbox.object_name(), &effective_id, &req.analysis_mode, + resolved_from, ) .await { @@ -4621,6 +4676,37 @@ 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(); + } + + /// 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()), + ); + settings.revision = settings.revision.wrapping_add(1); + save_global_settings(state.store.as_ref(), &settings) + .await + .unwrap(); + } + async fn test_server_state() -> Arc { let store = Arc::new( Store::connect("sqlite::memory:?cache=shared") @@ -4983,15 +5069,16 @@ mod tests { }), ..Default::default() }), - // Opt this sandbox into auto-approval to exercise the - // empty-delta → approved path. - proposal_approval_mode: "auto".to_string(), ..Default::default() }), phase: SandboxPhase::Ready as i32, ..Default::default() }; 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(), @@ -5299,14 +5386,15 @@ mod tests { ..Default::default() }), // No providers → no credential in scope for the proposed host. - // Opt into auto mode to test the proposer-agnostic gate. - proposal_approval_mode: "auto".to_string(), ..Default::default() }), phase: SandboxPhase::Ready as i32, ..Default::default() }; 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(), @@ -5394,13 +5482,13 @@ mod tests { ..Default::default() }), providers: vec!["github-pat".to_string()], - proposal_approval_mode: "auto".to_string(), ..Default::default() }), phase: SandboxPhase::Ready as i32, ..Default::default() }; 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. @@ -5467,11 +5555,11 @@ mod tests { } /// Acceptance criterion #7: default approval mode is manual. A sandbox - /// with `proposal_approval_mode` unset (the proto3 default of `""`) - /// 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 per-sandbox opt-in, not a global - /// behavior change shipped under a feature. + /// 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. #[tokio::test] async fn empty_delta_does_not_auto_approve_when_mode_unset() { use openshell_core::proto::{ @@ -5497,8 +5585,8 @@ mod tests { }), ..Default::default() }), - // proposal_approval_mode left as proto3 default ("") — must - // be treated as "manual". + // No approval-mode setting seeded at sandbox or gateway + // scope — the resolver must treat absence as "manual". ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -5589,14 +5677,14 @@ mod tests { }), ..Default::default() }), - // A future-CLI value the current gateway doesn't recognize. - proposal_approval_mode: "auto_on_low_risk".to_string(), ..Default::default() }), phase: SandboxPhase::Ready as i32, ..Default::default() }; 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(), @@ -5673,13 +5761,13 @@ mod tests { }), ..Default::default() }), - proposal_approval_mode: "manual".to_string(), ..Default::default() }), phase: SandboxPhase::Ready as i32, ..Default::default() }; 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(), @@ -5729,6 +5817,263 @@ mod tests { ); } + /// 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 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(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + 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, + 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, + 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 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(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + 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, + 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, + 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 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(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + 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, + 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(), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.accepted_chunks, 0, "chunk must be rejected"); + assert_eq!(response.rejected_chunks, 1); + assert!( + response + .rejection_reasons + .iter() + .any(|r| r.contains("_provider_")), + "rejection reason must cite the reserved-prefix rule. got: {:?}", + response.rejection_reasons, + ); + } + /// 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 @@ -6193,13 +6538,13 @@ mod tests { ..Default::default() }), providers: vec!["github-pat".to_string()], - proposal_approval_mode: "auto".to_string(), ..Default::default() }), phase: SandboxPhase::Ready as i32, ..Default::default() }; state.store.put_message(&sandbox).await.unwrap(); + seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; // ── Step 1: un-credentialed GET → expected auto-approve ── let uncredentialed_rule = NetworkPolicyRule { diff --git a/examples/agent-driven-policy-management/policy.template.yaml b/examples/agent-driven-policy-management/policy.template.yaml index 8121cb507..0498ecfcc 100644 --- a/examples/agent-driven-policy-management/policy.template.yaml +++ b/examples/agent-driven-policy-management/policy.template.yaml @@ -67,7 +67,8 @@ network_policies: # 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 - # under sandboxes opted into `proposal_approval_mode: auto`. + # when `proposal_approval_mode = auto` (set via `--approval-mode auto` + # at create or via `openshell settings set` at runtime). name: github-raw-scoped endpoints: - host: raw.githubusercontent.com diff --git a/proto/openshell.proto b/proto/openshell.proto index 1db27e5e1..60b83edd1 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -261,20 +261,12 @@ message SandboxSpec { // (e.g. "0", "1"). When empty with gpu=true, the driver assigns the // first available GPU. string gpu_device = 10; - // Approval mode for agent-authored policy proposals. - // - // When unset or "manual" (the default), every proposal lands in the - // draft inbox for human review, regardless of the prover verdict. - // - // When "auto", proposals whose prover delta is empty are approved - // automatically without human action; proposals with findings still - // require human approval. The opt-in preserves OpenShell's - // default-deny posture: auto-approval is a deliberate per-sandbox - // choice, not a global behavior change. - // - // Empty string defaults to "manual". String (not enum) so future - // modes ("auto_on_low_risk", etc.) extend without a proto migration. - string proposal_approval_mode = 11; + // 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. + reserved 11; + reserved "proposal_approval_mode"; } // Public sandbox template mapped onto compute-driver template inputs. From e135a1c323b4208dbfca3060d3cab8604f84a883 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Fri, 22 May 2026 20:57:46 -1000 Subject: [PATCH 6/6] feat(policy): manage approval mode via settings; reject _provider_ aliasing Move proposal_approval_mode out of SandboxSpec and into the existing runtime-mutable settings model so it can be flipped on a running sandbox and pinned fleet-wide via gateway scope. Precedence matches the rest of the settings model: gateway wins over sandbox, default is manual. The CLI's --approval-mode flag on `sandbox create` is now a shorthand that writes the sandbox-scoped setting post-create. Auto-approval audit events carry resolved_from=. Reject agent proposals whose rule_name starts with `_provider_`. That namespace is reserved for provider-profile-synthesized rules; allowing agents to address them by name would bypass the merge guard that splits agent contributions into their own rule so the prover sees them honestly. Refs #1097 --- crates/openshell-ocsf/src/format/shorthand.rs | 32 +++++++++++++++++++ crates/openshell-server/src/grpc/policy.rs | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index d00319d35..96f3632dc 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -319,6 +319,7 @@ impl OcsfEvent { push("auto"); push("source"); push("prover_delta"); + push("resolved_from"); if let Some(ver) = u.get("policy_version").and_then(|v| v.as_str()) { parts.push(format!("version:{ver}")); } @@ -847,6 +848,37 @@ 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. + #[test] + fn test_config_state_change_shorthand_includes_auto_approve_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.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("policy_version", serde_json::json!("v4")); + b.add_unmapped("policy_hash", serde_json::json!("sha256:cafe")); + + let event = OcsfEvent::DeviceConfigStateChange(DeviceConfigStateChangeEvent { + base: b, + state: Some(StateId::Other), + state_custom_label: Some("APPROVED".to_string()), + security_level: None, + prev_security_level: None, + }); + + 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 \ + version:v4 hash:sha256:cafe]" + ); + } + #[test] fn test_base_event_shorthand() { let mut b = base(0, "Base Event", 0, "Uncategorized", 99, "Other"); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 8826bd733..aac8a0fcc 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -872,7 +872,7 @@ async fn auto_approve_chunk( sandbox_id, sandbox_name, format!( - "auto-approved: no new prover findings (source={source_label}, resolved_from={resolved_from}) — chunk {chunk_id}: {chunk_summary}" + "auto-approved: no new prover findings (source={source_label}) — chunk {chunk_id}: {chunk_summary}" ), version, &hash,