From 5f1817e5710e0fde6eb46c8d067d037cbefbc195 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Tue, 19 May 2026 09:42:16 -0700 Subject: [PATCH 01/10] 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 92bc18499..ad7efabc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3717,6 +3717,7 @@ dependencies = [ "openshell-driver-podman", "openshell-ocsf", "openshell-policy", + "openshell-prover", "openshell-providers", "openshell-router", "openshell-server-macros", diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index b92be199e..454333874 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -6739,6 +6739,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 ef58ae17b..8bc91a5c6 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" } openshell-server-macros = { path = "../openshell-server-macros" } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index bdc96d862..a2f933f20 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -46,6 +46,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; @@ -305,6 +310,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(); @@ -1444,6 +1616,7 @@ pub(super) async fn handle_submit_policy_analysis( let sandbox = resolve_sandbox_by_name_for_principal(state.store.as_ref(), &principal, &req.name).await?; 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 @@ -1486,6 +1659,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 @@ -1518,7 +1701,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 @@ -4688,10 +4871,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 6ead61f2a2ec55b3b7a67dd45f3cc62ee92d959a Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Wed, 20 May 2026 17:22:10 -0700 Subject: [PATCH 02/10] 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). Signed-off-by: Alexander Watson --- 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 | 1228 ++++++++++++++--- .../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, 1571 insertions(+), 383 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index bc7b0c7a8..cec6a8d1d 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -89,21 +89,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 0e50fc6c5..53b2f59ea 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 a2f933f20..1b7e224f7 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -49,13 +49,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}; @@ -97,6 +102,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, @@ -112,6 +151,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(), @@ -133,6 +173,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() } @@ -310,125 +353,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, @@ -1616,8 +2014,28 @@ pub(super) async fn handle_submit_policy_analysis( let sandbox = resolve_sandbox_by_name_for_principal(state.store.as_ref(), &principal, &req.name).await?; 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) @@ -1659,15 +2077,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 @@ -1701,7 +2120,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 @@ -1717,6 +2136,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); } @@ -4580,6 +5060,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(), @@ -4590,6 +5081,7 @@ mod tests { }), spec: Some(SandboxSpec { policy: None, + providers: vec!["github-pat".to_string()], ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -4599,9 +5091,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() }], @@ -4616,7 +5108,7 @@ mod tests { with_user(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, @@ -4649,6 +5141,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(); @@ -4871,9 +5366,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] @@ -4958,28 +5455,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(), @@ -4993,6 +5506,7 @@ mod tests { }), ..Default::default() }), + providers: vec!["github-pat".to_string()], ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -5000,7 +5514,278 @@ mod tests { }; state.store.put_message(&sandbox).await.unwrap(); - let proposed_rule = NetworkPolicyRule { + // 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, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + 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(), @@ -5041,28 +5826,38 @@ mod tests { .unwrap() .into_inner(); let verdict = &draft.chunks[0].validation_result; + let first_line = verdict.lines().next().unwrap_or(""); + assert!( + first_line.starts_with("prover: ") && first_line.contains("new finding"), + "expected first line like `prover: N new finding(s)`, got: {verdict}" + ); assert!( - verdict.contains("L4/no method-path scope"), - "expected L4 scope warning, got: {verdict}" + verdict.contains("[HIGH]"), + "v1 emits HIGH for L4 + credential in scope; got: {verdict}" ); assert!( - verdict.contains("failed: prover found"), - "expected prover finding for broad L4 curl access, got: {verdict}" + 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(), @@ -5074,30 +5869,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, @@ -5106,19 +5880,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 { @@ -5133,9 +5982,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() @@ -5156,12 +6005,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}" ); } @@ -5291,13 +6140,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}" ); } @@ -5635,6 +6487,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(), @@ -5645,6 +6505,7 @@ mod tests { }), spec: Some(SandboxSpec { policy: None, + providers: vec!["github-pat".to_string()], ..Default::default() }), phase: SandboxPhase::Ready as i32, @@ -5669,9 +6530,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() }], @@ -5781,6 +6642,7 @@ mod tests { "gateway merged incremental policy op: add-allow api.github.com:443 [POST /repos/*/issues]", 7, "sha256:testhash", + &[], ); assert_eq!( @@ -5789,6 +6651,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 614c857461724f591122c70a855c73d82530f948 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Fri, 22 May 2026 08:25:53 -0700 Subject: [PATCH 03/10] 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 cec6a8d1d..43a7de506 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -98,12 +98,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 @@ -111,7 +116,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 @@ -120,6 +127,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 917c8faa1..7b8f5d15f 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1148,6 +1148,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")] @@ -1256,6 +1261,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, @@ -2526,6 +2543,7 @@ async fn main() -> Result<()> { auto_providers, no_auto_providers, labels, + approval_mode, command, } => { // Resolve --tty / --no-tty into an Option override. @@ -2594,6 +2612,7 @@ async fn main() -> Result<()> { tty_override, auto_providers_override, &labels_map, + &approval_mode, &tls, )) .await?; @@ -4134,6 +4153,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 454333874..1e9421a44 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1693,6 +1693,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() { @@ -1771,6 +1772,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 3ed43b2fc..6892ba9bb 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -796,6 +796,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -837,6 +838,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -969,6 +971,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1021,6 +1024,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1065,6 +1069,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1105,6 +1110,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1149,6 +1155,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { Some(true), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1193,6 +1200,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { Some(false), Some(false), &HashMap::new(), + "manual", &tls, ) .await @@ -1237,6 +1245,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 1b7e224f7..22ba36273 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2024,6 +2024,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 @@ -2172,14 +2182,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, @@ -5398,6 +5410,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, @@ -5460,11 +5475,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 ); } @@ -5568,8 +5585,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 { @@ -5633,10 +5654,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; \ @@ -5685,6 +5713,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, @@ -5736,8 +5766,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 ); } @@ -6153,6 +6553,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 90d1594f7..433913ef0 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -322,6 +322,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 d5ddbc4f1cf91d5912844c2cce070dcd6c030832 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Thu, 21 May 2026 05:49:38 -0700 Subject: [PATCH 04/10] 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. Signed-off-by: Alexander Watson --- 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 | 4 + 13 files changed, 784 insertions(+), 921 deletions(-) create mode 100644 crates/openshell-prover/README.md diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 43a7de506..2b2a278bd 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -116,30 +116,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 22ba36273..41b09aa46 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -363,8 +363,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 @@ -527,23 +527,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| { @@ -553,7 +559,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 @@ -569,6 +575,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 } @@ -5582,11 +5614,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, @@ -5655,14 +5697,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!( @@ -5772,14 +5817,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, @@ -5864,17 +5908,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 ); } @@ -6232,8 +6278,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"), @@ -6405,8 +6452,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"), @@ -6730,12 +6778,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 2be9fb2de..4ce5af2d3 100644 --- a/providers/github.yaml +++ b/providers/github.yaml @@ -15,6 +15,9 @@ credentials: discovery: credentials: [api_token] 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 @@ -26,6 +29,7 @@ endpoints: protocol: graphql access: read-only enforcement: enforce + # github.com is the git transport (clone / fetch by default). - host: github.com port: 443 protocol: rest From 192f564f7377cae5e04f8833479785b9b49c1ca1 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Fri, 22 May 2026 13:45:17 -0700 Subject: [PATCH 05/10] fix(policy): move approval mode into settings Signed-off-by: Alexander Watson --- 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 | 430 ++++++++++++++++-- .../policy.template.yaml | 3 +- proto/openshell.proto | 20 +- 8 files changed, 487 insertions(+), 73 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 2b2a278bd..082416d56 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -98,17 +98,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 1e9421a44..dd4ac6836 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1772,7 +1772,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(), @@ -1808,6 +1807,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 41b09aa46..aeed33f06 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -115,6 +115,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, @@ -122,11 +125,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, @@ -785,12 +790,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 @@ -835,11 +873,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!( @@ -849,6 +888,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" ); @@ -2056,15 +2096,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 @@ -2096,6 +2134,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)); @@ -2216,12 +2269,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( @@ -2230,6 +2284,7 @@ pub(super) async fn handle_submit_policy_analysis( sandbox.object_name(), &effective_id, &req.analysis_mode, + resolved_from, ) .await { @@ -5097,6 +5152,56 @@ 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") + .await + .unwrap(), + ); + let compute = new_test_runtime(store.clone()).await; + Arc::new(ServerState::new( + Config::new(None).with_database_url("sqlite::memory:?cache=shared"), + store, + compute, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + None, + )) + } + #[tokio::test] async fn draft_chunk_handler_lifecycle_round_trip() { use openshell_core::proto::{ @@ -5442,15 +5547,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(), @@ -5758,14 +5864,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(), @@ -5853,13 +5960,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. @@ -5926,11 +6033,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::{ @@ -5956,8 +6063,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, @@ -6048,14 +6155,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(), @@ -6132,13 +6239,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(), @@ -6188,6 +6295,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 @@ -6652,13 +7016,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 433913ef0..0b96fcd99 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -322,20 +322,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 d698a98a415d30b1cda06489ac9ba6e70f67dda3 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Fri, 22 May 2026 20:57:46 -1000 Subject: [PATCH 06/10] 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 Signed-off-by: Alexander Watson --- 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 53b2f59ea..aeae77f9e 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 aeed33f06..eb09ca549 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -873,7 +873,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, From 2e6b2297d446ae0e3ecbcd3eb71d3324b0b2b645 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Mon, 25 May 2026 09:55:58 -1000 Subject: [PATCH 07/10] fix(policy): align approval loop branch with main Signed-off-by: Alexander Watson --- crates/openshell-cli/src/run.rs | 1 + .../sandbox_create_lifecycle_integration.rs | 1 + crates/openshell-server/src/grpc/policy.rs | 164 +++++++++--------- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index dd4ac6836..b8cba2e25 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1824,6 +1824,7 @@ pub async fn sandbox_create( delete_setting: false, global: false, merge_operations: vec![], + expected_resource_version: 0, }) .await { diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 6892ba9bb..7f194b53d 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -915,6 +915,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { Some(true), Some(false), &HashMap::new(), + "manual", &tls, ) .await diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index eb09ca549..b474873b7 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -5183,25 +5183,6 @@ mod tests { .unwrap(); } - async fn test_server_state() -> Arc { - let store = Arc::new( - Store::connect("sqlite::memory:?cache=shared") - .await - .unwrap(), - ); - let compute = new_test_runtime(store.clone()).await; - Arc::new(ServerState::new( - Config::new(None).with_database_url("sqlite::memory:?cache=shared"), - store, - compute, - SandboxIndex::new(), - SandboxWatchBus::new(), - TracingLogBus::new(), - Arc::new(SupervisorSessionRegistry::new()), - None, - )) - } - #[tokio::test] async fn draft_chunk_handler_lifecycle_round_trip() { use openshell_core::proto::{ @@ -5537,6 +5518,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -5582,7 +5564,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -5592,17 +5574,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -5651,6 +5633,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -5685,7 +5668,7 @@ mod tests { }; let mechanistic_submit = handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { @@ -5695,7 +5678,7 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap() @@ -5706,10 +5689,10 @@ mod tests { // finding. let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -5762,7 +5745,7 @@ mod tests { }; let agent_submit = handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -5772,7 +5755,7 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap() @@ -5781,10 +5764,10 @@ mod tests { let draft_after = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -5853,6 +5836,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -5889,7 +5873,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { @@ -5899,17 +5883,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -5949,6 +5933,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -5988,7 +5973,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -5998,17 +5983,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -6053,6 +6038,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6087,7 +6073,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6097,17 +6083,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -6145,6 +6131,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6179,7 +6166,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6189,17 +6176,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -6229,6 +6216,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6262,7 +6250,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6272,17 +6260,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -6315,6 +6303,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6349,7 +6338,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6359,17 +6348,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -6401,6 +6390,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6439,7 +6429,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6449,17 +6439,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -6492,6 +6482,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6524,7 +6515,7 @@ mod tests { let response = handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6534,7 +6525,7 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap() @@ -6577,6 +6568,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6610,7 +6602,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6620,17 +6612,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -6672,6 +6664,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6705,7 +6698,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6715,17 +6708,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -6756,6 +6749,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6789,7 +6783,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6799,17 +6793,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -6849,6 +6843,7 @@ mod tests { name: "custom-api".to_string(), created_at_ms: 1_000_000, labels: HashMap::new(), + resource_version: 0, }), profile: Some(ProviderProfile { id: "custom-api".to_string(), @@ -6872,6 +6867,7 @@ mod tests { ..Default::default() }], inference_capable: false, + discovery: None, }), }) .await @@ -6884,6 +6880,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6926,7 +6923,7 @@ mod tests { handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -6936,17 +6933,17 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap(); let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() @@ -7005,6 +7002,7 @@ mod tests { name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), + resource_version: 0, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7049,7 +7047,7 @@ mod tests { }; let step1 = handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -7059,7 +7057,7 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap() @@ -7090,7 +7088,7 @@ mod tests { }; let step2 = handle_submit_policy_analysis( &state, - Request::new(SubmitPolicyAnalysisRequest { + with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { @@ -7100,7 +7098,7 @@ mod tests { ..Default::default() }], ..Default::default() - }), + })), ) .await .unwrap() @@ -7109,10 +7107,10 @@ mod tests { let draft = handle_get_draft_policy( &state, - Request::new(GetDraftPolicyRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), - }), + })), ) .await .unwrap() From 716d436e52ce39145adfb36d893cc7422567b10e Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Thu, 28 May 2026 09:42:45 -0700 Subject: [PATCH 08/10] fix(policy): validate proposal_approval_mode at configure time Previously the setting was a free-form string, so `openshell settings set ... proposal_approval_mode autom` was accepted and silently resolved to manual at runtime. Operators got no signal that they had fat-fingered the value. Extend RegisteredSetting with an optional allowed_string_values whitelist and apply it at every operator entry point: - Server-side proto_setting_to_stored rejects out-of-whitelist values with Status::invalid_argument listing the allowed set, so all gRPC callers get consistent validation. - CLI parse_cli_setting_value rejects client-side before the round-trip. - TUI global and sandbox setting editors surface the same error inline. Runtime resolve_proposal_approval_mode is intentionally unchanged: it still treats any value other than exact "auto" as manual, so stale storage or future-mode values never enable auto-approval on older gateways. Also documents the approval-mode loop in docs/sandboxes/policy-advisor.mdx with new Approval Modes and What Auto-Approval Checks sections covering mode precedence, the --approval-mode create shorthand, the audit-event fields, and the four categorical prover findings. Refs #1528 Signed-off-by: Alexander Watson --- crates/openshell-cli/src/run.rs | 16 ++- crates/openshell-core/src/settings.rs | 82 +++++++++++++++- crates/openshell-server/src/grpc/policy.rs | 109 +++++++++++++++++++-- crates/openshell-tui/src/app.rs | 20 +++- docs/sandboxes/policy-advisor.mdx | 59 ++++++++++- 5 files changed, 269 insertions(+), 17 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index b8cba2e25..1257caab7 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5552,7 +5552,21 @@ fn parse_cli_setting_value(key: &str, raw_value: &str) -> Result { })?; let value = match setting.kind { - SettingValueKind::String => setting_value::Value::StringValue(raw_value.to_string()), + SettingValueKind::String => { + // Reject typos client-side so `openshell settings set ... + // proposal_approval_mode autom` errors immediately instead of + // round-tripping through the server. The server enforces the + // same check independently for non-CLI callers. + setting.validate_string_value(raw_value).map_err(|allowed| { + miette::miette!( + "invalid value '{}' for key '{}'; expected one of: {}", + raw_value, + key, + allowed.join(", ") + ) + })?; + setting_value::Value::StringValue(raw_value.to_string()) + } SettingValueKind::Int => { let parsed = raw_value.trim().parse::().map_err(|_| { miette::miette!( diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 733bb1f03..93774175b 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -28,6 +28,32 @@ impl SettingValueKind { pub struct RegisteredSetting { pub key: &'static str, pub kind: SettingValueKind, + /// Optional whitelist of allowed string values. When `Some`, values + /// outside the list are rejected at configure time by every API surface + /// that goes through [`validate_string_value`] (CLI, TUI, gRPC). `None` + /// means the value is free-form and any string is accepted. Only + /// meaningful for [`SettingValueKind::String`] entries. + pub allowed_string_values: Option<&'static [&'static str]>, +} + +impl RegisteredSetting { + /// Validate a string value against [`allowed_string_values`]. Returns + /// `Ok(())` when the setting has no constraint or when the value is in + /// the allowed list. On rejection, returns the allowed slice so callers + /// can format their own diagnostic. + /// + /// [`allowed_string_values`]: Self::allowed_string_values + /// + /// # Errors + /// + /// Returns the allowed-value slice when the setting has an + /// `allowed_string_values` whitelist and `value` is not in it. + pub fn validate_string_value(&self, value: &str) -> Result<(), &'static [&'static str]> { + match self.allowed_string_values { + Some(allowed) if !allowed.contains(&value) => Err(allowed), + _ => Ok(()), + } + } } /// Static registry of currently-supported runtime settings. @@ -74,12 +100,19 @@ pub const AGENT_POLICY_PROPOSALS_ENABLED_KEY: &str = "agent_policy_proposals_ena /// global is set. pub const PROPOSAL_APPROVAL_MODE_KEY: &str = "proposal_approval_mode"; +/// Allowed values for [`PROPOSAL_APPROVAL_MODE_KEY`]. Any other string is +/// rejected at configure time (so operators get immediate feedback on typos +/// like `"autom"`) while the runtime resolver still fail-closes on unknown +/// persisted values for defense in depth. +pub const PROPOSAL_APPROVAL_MODE_VALUES: &[&str] = &["manual", "auto"]; + pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ // Gateway-level opt-in for provider profile policy composition. Defaults // to false when unset. RegisteredSetting { key: PROVIDERS_V2_ENABLED_KEY, kind: SettingValueKind::Bool, + allowed_string_values: None, }, // When true the sandbox writes OCSF v1.7.0 JSONL records to // `/var/log/openshell-ocsf*.log` (daily rotation, 3 files) in addition @@ -87,18 +120,21 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ RegisteredSetting { key: "ocsf_json_enabled", kind: SettingValueKind::Bool, + allowed_string_values: None, }, // Sandbox-level opt-in for the agent-driven policy proposal surface. // See AGENT_POLICY_PROPOSALS_ENABLED_KEY for details. Defaults to false. RegisteredSetting { key: AGENT_POLICY_PROPOSALS_ENABLED_KEY, kind: SettingValueKind::Bool, + allowed_string_values: None, }, // Approval mode for agent-authored proposals. See // PROPOSAL_APPROVAL_MODE_KEY for details. Defaults to manual. RegisteredSetting { key: PROPOSAL_APPROVAL_MODE_KEY, kind: SettingValueKind::String, + allowed_string_values: Some(PROPOSAL_APPROVAL_MODE_VALUES), }, // Test-only keys live behind the `dev-settings` feature flag so they // don't appear in production builds. @@ -106,11 +142,13 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ RegisteredSetting { key: "dummy_int", kind: SettingValueKind::Int, + allowed_string_values: None, }, #[cfg(feature = "dev-settings")] RegisteredSetting { key: "dummy_bool", kind: SettingValueKind::Bool, + allowed_string_values: None, }, ]; @@ -143,8 +181,9 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - PROVIDERS_V2_ENABLED_KEY, REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, - parse_bool_like, registered_keys_csv, setting_for_key, + PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, PROVIDERS_V2_ENABLED_KEY, + REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, + registered_keys_csv, setting_for_key, }; #[cfg(feature = "dev-settings")] @@ -174,6 +213,44 @@ mod tests { assert_eq!(setting.kind, SettingValueKind::Bool); } + // ---- RegisteredSetting::validate_string_value ---- + + #[test] + fn validate_string_value_accepts_anything_when_unconstrained() { + let setting = setting_for_key(PROVIDERS_V2_ENABLED_KEY) + .expect("providers_v2_enabled should be registered"); + // Bool-kind entries currently leave `allowed_string_values = None`; + // the helper still returns Ok for arbitrary strings. + assert!(setting.validate_string_value("anything").is_ok()); + assert!(setting.validate_string_value("").is_ok()); + } + + #[test] + fn proposal_approval_mode_accepts_manual_and_auto() { + let setting = setting_for_key(PROPOSAL_APPROVAL_MODE_KEY) + .expect("proposal_approval_mode should be registered"); + assert_eq!(setting.kind, SettingValueKind::String); + assert_eq!( + setting.allowed_string_values, + Some(PROPOSAL_APPROVAL_MODE_VALUES) + ); + assert!(setting.validate_string_value("manual").is_ok()); + assert!(setting.validate_string_value("auto").is_ok()); + } + + #[test] + fn proposal_approval_mode_rejects_typos_and_future_modes() { + let setting = setting_for_key(PROPOSAL_APPROVAL_MODE_KEY) + .expect("proposal_approval_mode should be registered"); + for bad in ["autom", "AUTO", "Manual", "", " auto", "auto_on_low_risk", "yes"] { + let err = setting + .validate_string_value(bad) + .expect_err(&format!("expected '{bad}' to be rejected")); + // Caller gets the allowed slice back for diagnostics. + assert_eq!(err, PROPOSAL_APPROVAL_MODE_VALUES); + } + } + // ---- parse_bool_like ---- #[test] @@ -292,6 +369,7 @@ mod tests { let a = RegisteredSetting { key: "test", kind: SettingValueKind::Bool, + allowed_string_values: None, }; let b = a; assert_eq!(a, b); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index b474873b7..2a2a4d3cb 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3408,25 +3408,36 @@ async fn remove_chunk_from_policy( // Settings helpers // --------------------------------------------------------------------------- -fn validate_registered_setting_key(key: &str) -> Result { - settings::setting_for_key(key) - .map(|entry| entry.kind) - .ok_or_else(|| { - Status::invalid_argument(format!( - "unknown setting key '{key}'. Allowed keys: {}", - settings::registered_keys_csv() - )) - }) +fn validate_registered_setting_key( + key: &str, +) -> Result<&'static settings::RegisteredSetting, Status> { + settings::setting_for_key(key).ok_or_else(|| { + Status::invalid_argument(format!( + "unknown setting key '{key}'. Allowed keys: {}", + settings::registered_keys_csv() + )) + }) } fn proto_setting_to_stored(key: &str, value: &SettingValue) -> Result { - let expected = validate_registered_setting_key(key)?; + let setting = validate_registered_setting_key(key)?; + let expected = setting.kind; let inner = value .value .as_ref() .ok_or_else(|| Status::invalid_argument("setting_value.value is required"))?; let stored = match (expected, inner) { (SettingValueKind::String, setting_value::Value::StringValue(v)) => { + // Enforce per-key string whitelist at configure time so typos + // (e.g. `proposal_approval_mode=autom`) get rejected here instead + // of silently falling back to the default at runtime. + if let Err(allowed) = setting.validate_string_value(v) { + return Err(Status::invalid_argument(format!( + "setting '{key}' expects one of [{}]; got '{}'", + allowed.join(", "), + v + ))); + } StoredSettingValue::String(v.clone()) } (SettingValueKind::Bool, setting_value::Value::BoolValue(v)) => { @@ -8382,6 +8393,84 @@ mod tests { assert_eq!(stored, StoredSettingValue::Bool(true)); } + #[test] + fn proto_setting_to_stored_accepts_allowed_proposal_approval_mode_values() { + for raw in ["manual", "auto"] { + let value = SettingValue { + value: Some(setting_value::Value::StringValue(raw.to_string())), + }; + let stored = proto_setting_to_stored( + settings::PROPOSAL_APPROVAL_MODE_KEY, + &value, + ) + .unwrap_or_else(|e| panic!("expected '{raw}' to be accepted, got: {e}")); + assert_eq!(stored, StoredSettingValue::String(raw.to_string())); + } + } + + #[test] + fn proto_setting_to_stored_rejects_invalid_proposal_approval_mode_value() { + // Typos and future-reserved modes must be rejected at configure time + // — without this, the value silently resolves to manual at runtime + // (fail-closed) and the operator never finds out they fat-fingered + // the setting. + for raw in ["autom", "AUTO", "Manual", "auto_on_low_risk", "", " auto"] { + let value = SettingValue { + value: Some(setting_value::Value::StringValue(raw.to_string())), + }; + let res = proto_setting_to_stored( + settings::PROPOSAL_APPROVAL_MODE_KEY, + &value, + ); + assert!(res.is_err(), "expected '{raw}' to be rejected, got: {res:?}"); + let err = res.unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + } + + #[test] + fn proto_setting_to_stored_rejection_message_lists_allowed_proposal_approval_mode_values() { + let value = SettingValue { + value: Some(setting_value::Value::StringValue("autom".to_string())), + }; + let err = proto_setting_to_stored( + settings::PROPOSAL_APPROVAL_MODE_KEY, + &value, + ) + .unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + let msg = err.message(); + assert!(msg.contains("manual"), "missing 'manual' in {msg}"); + assert!(msg.contains("auto"), "missing 'auto' in {msg}"); + assert!(msg.contains("autom"), "missing offending value in {msg}"); + } + + /// Locks in that invalid `proposal_approval_mode` is rejected at the + /// `UpdateConfig` RPC boundary — not just in the `proto_setting_to_stored` + /// helper. Prevents a future refactor from accidentally routing setting + /// writes around the validation chokepoint. + #[tokio::test] + async fn update_config_global_rejects_invalid_proposal_approval_mode() { + let state = test_server_state().await; + let req = with_user(Request::new(UpdateConfigRequest { + global: true, + setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), + setting_value: Some(SettingValue { + value: Some(setting_value::Value::StringValue("autom".to_string())), + }), + ..Default::default() + })); + let err = handle_update_config(&state, req) + .await + .expect_err("invalid proposal_approval_mode must be rejected at UpdateConfig"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!( + err.message().contains("autom") && err.message().contains("manual"), + "expected rejection message to echo the bad value and list allowed values; got: {}", + err.message() + ); + } + #[cfg(feature = "dev-settings")] #[test] fn merge_effective_settings_global_overrides_sandbox_key() { diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index ba817bcf8..dc610e6ca 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -1017,7 +1017,15 @@ impl App { return; } } - SettingValueKind::String => {} + SettingValueKind::String => { + if let Some(setting) = settings::setting_for_key(&entry.key) { + if let Err(allowed) = setting.validate_string_value(raw) { + edit.error = + Some(format!("expected one of: {}", allowed.join(", "))); + return; + } + } + } } } edit.error = None; @@ -1261,7 +1269,15 @@ impl App { return; } } - SettingValueKind::String => {} + SettingValueKind::String => { + if let Some(setting) = settings::setting_for_key(&entry.key) { + if let Err(allowed) = setting.validate_string_value(raw) { + edit.error = + Some(format!("expected one of: {}", allowed.join(", "))); + return; + } + } + } } } edit.error = None; diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index d129e5e5a..73909f543 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -47,6 +47,46 @@ openshell settings delete --global \ Set the value before creating a sandbox when you want the first denied request to include policy advisor guidance. Running sandboxes poll settings and can enable the surface after startup, but startup enablement gives the agent the clearest first-denial path. +## Approval Modes + +Every proposal — mechanistic or agent-authored — is routed through the [policy prover](#what-auto-approval-checks). The `proposal_approval_mode` setting decides what happens when the prover finds nothing to flag. + +| Mode | When unset / `manual` | `auto` | +|---|---|---| +| Empty prover delta | Lands in the draft inbox for human review. | Approved automatically; the sandbox hot-reloads the new rule and the agent retries. | +| Any prover finding | Lands in the draft inbox. | Lands in the draft inbox — auto-approval is gated on an empty delta. | + +`manual` is the default. Auto mode is an explicit opt-in; OpenShell's default-deny posture is preserved unless you choose otherwise. + +Enable auto mode at gateway scope when you want every sandbox on this gateway to auto-approve safe proposals: + +```shell +openshell settings set --global \ + --key proposal_approval_mode \ + --value auto \ + --yes +``` + +Enable it for one sandbox when no global value is set: + +```shell +openshell settings set \ + --key proposal_approval_mode \ + --value auto +``` + +The shorthand at create time writes the sandbox-scoped setting for you: + +```shell +openshell sandbox create --approval-mode auto +``` + +Only `manual` and `auto` are accepted; typos like `autom` are rejected at configure time. Stale or unknown values found in storage are still treated as `manual` at runtime as a defense-in-depth measure. + +**Precedence.** Gateway scope wins over sandbox scope. A reviewer can pin `manual` for a fleet by setting it globally; per-sandbox overrides only apply when no global value is set. + +**Audit trail.** Every auto-approval emits a `CONFIG:APPROVED` event with `auto=true`, `source=`, `prover_delta=empty`, and `resolved_from=` so operators can reconstruct why a given approval ran without human review. + ## How It Works When policy advisor is enabled, the sandbox supervisor turns on three agent-facing surfaces: @@ -64,7 +104,7 @@ The loop has six steps: 5. The gateway stores accepted proposals as pending draft chunks for the sandbox. 6. A developer reviews the draft, approves or rejects it, and the agent waits on `/v1/proposals/{chunk_id}/wait` until a decision is available. -When a proposal is approved, `/wait` reports `policy_reloaded: true` only after the local sandbox policy covers the approved rule. At that point the agent can retry the original denied action once. If a proposal is rejected, `/wait` returns `rejection_reason` and `validation_result` so the agent can revise or stop. +When a proposal is approved, `/wait` reports `policy_reloaded: true` only after the local sandbox policy covers the approved rule. At that point the agent can retry the original denied action once. If a proposal is rejected, `/wait` returns `rejection_reason` and `validation_result` so the agent can revise or stop. `validation_result` carries the categorical prover findings — `link_local_reach`, `l7_bypass_credentialed`, `credential_reach_expansion`, `capability_expansion` — so the agent can narrow the next attempt to the specific concern the prover flagged. ## What Gets Proposed @@ -118,6 +158,21 @@ The current `policy.local` JSON shape covers L4 endpoints and REST method or pat Policy advisor proposals do not add `allowed_ips` automatically. If a hostname resolves to an internal or private address, OpenShell's SSRF protections still block the connection until a developer explicitly adds the required `allowed_ips` entry. +## What Auto-Approval Checks + +The policy prover runs against every proposal — mechanistic and agent-authored alike — and asks four formal questions about the proposed change. Each "yes" is one categorical finding. Any finding blocks auto-approval; only an empty delta is eligible. + +| Category | Triggered when | +|---|---| +| `link_local_reach` | The proposal reaches a host in `169.254.0.0/16` or `fe80::/10` (the cloud-metadata range, which serves credentials regardless of sandbox state). Unconditional. | +| `l7_bypass_credentialed` | A binary using a wire protocol the L7 proxy cannot inspect (`git-remote-https`, `ssh`, `nc`) gains reach to a host where a credential is in scope. | +| `credential_reach_expansion` | A binary gains credentialed reach to a `(host, port)` it could not reach before. | +| `capability_expansion` | On a `(binary, host, port)` that already had credentialed reach, the proposal adds a new HTTP method. The finding cites the specific method. | + +Findings are categorical — there is no severity tier. The reviewer reads the category and the structured evidence to decide. When the prover delta is empty, the proposal is provably safe under the model and auto-approval (if enabled) can fire. + +The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Providers v2](/sandboxes/providers-v2) are part of the effective policy the prover reasons over. + ## Review Proposals Review pending chunks from the host: @@ -172,7 +227,7 @@ Policy advisor emits audit events into the sandbox log. Use these lines to trace openshell logs --since 10m ``` -Look for `HTTP:* DENIED`, `CONFIG:PROPOSED`, `CONFIG:APPROVED` or `CONFIG:REJECTED`, `CONFIG:LOADED`, and the final allowed request if the agent retries successfully. +Look for `HTTP:* DENIED`, `CONFIG:PROPOSED`, `CONFIG:APPROVED` or `CONFIG:REJECTED`, `CONFIG:LOADED`, and the final allowed request if the agent retries successfully. Auto-approved chunks emit `CONFIG:APPROVED` with `auto=true`, `source=`, `prover_delta=empty`, and `resolved_from=`. ## Next Steps From d851129eb62067da4c05a6ec3674e558cbb013c8 Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Thu, 28 May 2026 09:55:11 -0700 Subject: [PATCH 09/10] docs(policy-advisor): correct stale claims from PR #1480 The opening claim, the loop description, and the Review Proposals section all predated auto-approval mode and read as if a developer always sat in the loop. Update them to reflect the prover-gated auto-approval path: - Opening: preserve the default-deny framing but acknowledge opt-in auto mode lets the gateway approve empty-delta proposals. - Loop: now seven steps. Step 5 mentions the prover. Step 6 splits manual vs auto behavior. Step 7 covers the agent wait/retry path. - Review Proposals: note that under auto mode, only flagged proposals show as pending; empty-delta ones are visible under --status approved with the audit fields documented in Approval Modes. Refs #1528 #1480 Signed-off-by: Alexander Watson --- docs/sandboxes/policy-advisor.mdx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index 73909f543..81eeca2bb 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -10,7 +10,7 @@ position: 6 Policy advisor lets a running sandboxed agent ask for a narrow network policy change after OpenShell denies a request. The agent submits a draft through `policy.local`, a developer approves or rejects it from outside the sandbox, and approved network policy hot-reloads into the same sandbox. -Policy advisor does not grant access automatically. The structured rule is the approval contract, and the agent's rationale is supporting context. +Policy advisor preserves OpenShell's default-deny posture. The structured rule is the approval contract, and the agent's rationale is supporting context. By default every proposal lands in the draft inbox for human review. Opt-in [auto mode](#approval-modes) lets the gateway approve provably safe proposals — those whose [prover delta](#what-auto-approval-checks) is empty — without a reviewer in the loop; proposals with any prover finding still require human approval. ## Enable Policy Advisor @@ -95,14 +95,15 @@ When policy advisor is enabled, the sandbox supervisor turns on three agent-faci - It serves `http://policy.local` from inside the sandbox. - It adds `next_steps` to L7 `policy_denied` response bodies so the agent can find the skill and local API. -The loop has six steps: +The loop has seven steps: 1. A sandboxed process attempts a network request that policy denies. 2. For inspected REST traffic, OpenShell returns a structured `403` body with fields such as `layer`, `host`, `port`, `binary`, `method`, `path`, `rule_missing`, and `next_steps`. 3. The agent reads the policy advisor skill, inspects the current policy, and optionally reads recent denial log lines. 4. The agent submits one or more `addRule` proposals to `http://policy.local/v1/proposals`. -5. The gateway stores accepted proposals as pending draft chunks for the sandbox. -6. A developer reviews the draft, approves or rejects it, and the agent waits on `/v1/proposals/{chunk_id}/wait` until a decision is available. +5. The gateway stores accepted proposals as pending draft chunks for the sandbox and runs the [policy prover](#what-auto-approval-checks) against the proposed delta. +6. Under `auto` mode, proposals with an empty prover delta are approved immediately and skipped past human review. Under `manual` mode (the default), every proposal — and under `auto` mode, every proposal with a prover finding — lands in the draft inbox for a developer to approve or reject. +7. The agent waits on `/v1/proposals/{chunk_id}/wait` until a decision is available. Approved proposals hot-reload into the sandbox; rejected proposals return `rejection_reason` and `validation_result` so the agent can revise. When a proposal is approved, `/wait` reports `policy_reloaded: true` only after the local sandbox policy covers the approved rule. At that point the agent can retry the original denied action once. If a proposal is rejected, `/wait` returns `rejection_reason` and `validation_result` so the agent can revise or stop. `validation_result` carries the categorical prover findings — `link_local_reach`, `l7_bypass_credentialed`, `credential_reach_expansion`, `capability_expansion` — so the agent can narrow the next attempt to the specific concern the prover flagged. @@ -181,6 +182,8 @@ Review pending chunks from the host: openshell rule get --status pending ``` +Under `auto` mode, only proposals the prover flagged appear here; empty-delta proposals are already approved and visible under `--status approved` with the auto-approval audit fields described in [Approval Modes](#approval-modes). Under `manual` mode, every proposal — regardless of prover verdict — shows up as pending. + The output shows the chunk ID, status, rationale, binary, and endpoint summary. For L7 proposals, the endpoint summary includes the protocol, method, and path: ```text From abe84ef10e199756219b6fe825cb62feecbcac2e Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Fri, 29 May 2026 11:42:08 -0700 Subject: [PATCH 10/10] fix(policy): address approval loop review feedback Signed-off-by: Alexander Watson --- architecture/security-policy.md | 11 +- crates/openshell-cli/src/run.rs | 18 +- crates/openshell-core/src/net.rs | 25 +++ crates/openshell-core/src/settings.rs | 19 +- crates/openshell-prover/src/credentials.rs | 196 +++++++++++++++++- crates/openshell-prover/src/lib.rs | 74 +++++++ crates/openshell-prover/src/queries.rs | 42 +++- crates/openshell-sandbox/src/l7/rest.rs | 41 ++++ .../src/mechanistic_mapper.rs | 40 +++- crates/openshell-sandbox/src/policy_local.rs | 26 +++ crates/openshell-sandbox/src/process.rs | 6 +- crates/openshell-sandbox/src/skills.rs | 97 ++++++++- .../src/skills/policy-advisor/SKILL.md | 8 + .../src/skills/policy_advisor.md | 8 +- crates/openshell-server/src/grpc/policy.rs | 128 ++++++++---- crates/openshell-tui/src/app.rs | 24 +-- docs/observability/logging.mdx | 2 + docs/sandboxes/policy-advisor.mdx | 9 +- 18 files changed, 672 insertions(+), 102 deletions(-) create mode 100644 crates/openshell-sandbox/src/skills/policy-advisor/SKILL.md diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 082416d56..ad8d7d0ec 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -91,6 +91,9 @@ because it changes the effective access model for every sandbox on the gateway. The policy advisor pipeline turns observed denials into draft policy recommendations. There are two proposers (sandbox-side mechanistic mapper, agent-authored via `policy.local`); the gateway is the single referee. +When enabled, L7 `policy_denied` responses include both structured +`next_steps` and a short `agent_guidance` string so generic agents can continue +through the proposal loop instead of treating the denial as terminal. 1. **Submit.** Both proposers POST through the same `SubmitPolicyAnalysis` path. Each chunk is persisted with its `analysis_mode` for audit provenance. @@ -130,15 +133,17 @@ 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. | +| `link_local_reach` | The proposal grants reach to a host in `169.254.0.0/16`, `fe80::/10`, or a known metadata hostname such as `metadata.google.internal`. Unconditional — cloud-metadata endpoints serve credentials regardless of sandbox state. | | `l7_bypass_credentialed` | The proposal lets a binary using a non-HTTP wire protocol (`git-remote-https`, `ssh`, `nc`) reach a host where a sandbox credential is in scope. The L7 proxy cannot inspect the wire protocol; the reviewer decides whether to trust the binary with the credential. | | `credential_reach_expansion` | A binary gained credentialed reach to a (host, port) it could not reach before. New authenticated reach is a stated intent change; the reviewer confirms the binary should authenticate to the host at all. | | `capability_expansion` | On a (binary, host, port) that already had credentialed reach, the policy adds a new HTTP method. The reviewer sees exactly which method was added (e.g., PUT) and decides if it's part of the agent's task. | "Credential in scope" is sandbox-coarse, not binary-fine: a credential is considered in scope if the sandbox has a provider attached whose -`target_hosts` include the proposed endpoint's host. v1 does not model -credential scopes (read-only vs write); presence is enough. +`target_hosts` include the proposed endpoint's host, including runtime-like +first-label wildcard coverage such as `*.github.com` covering +`api.github.com`. v1 does not model credential scopes (read-only vs write); +presence is enough. 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 diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 1257caab7..451bb971a 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5557,14 +5557,16 @@ fn parse_cli_setting_value(key: &str, raw_value: &str) -> Result { // proposal_approval_mode autom` errors immediately instead of // round-tripping through the server. The server enforces the // same check independently for non-CLI callers. - setting.validate_string_value(raw_value).map_err(|allowed| { - miette::miette!( - "invalid value '{}' for key '{}'; expected one of: {}", - raw_value, - key, - allowed.join(", ") - ) - })?; + setting + .validate_string_value(raw_value) + .map_err(|allowed| { + miette::miette!( + "invalid value '{}' for key '{}'; expected one of: {}", + raw_value, + key, + allowed.join(", ") + ) + })?; setting_value::Value::StringValue(raw_value.to_string()) } SettingValueKind::Int => { diff --git a/crates/openshell-core/src/net.rs b/crates/openshell-core/src/net.rs index 0e2654fc3..06e6096ee 100644 --- a/crates/openshell-core/src/net.rs +++ b/crates/openshell-core/src/net.rs @@ -12,6 +12,16 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +/// Check if a hostname is a known cloud metadata hostname that resolves to an +/// always-blocked metadata service. +/// +/// This is intentionally a static name check. Do not perform DNS resolution in +/// policy validation or proposal generation paths. +pub fn is_known_metadata_hostname(host: &str) -> bool { + let normalized = host.trim().trim_end_matches('.').to_ascii_lowercase(); + matches!(normalized.as_str(), "metadata.google.internal") +} + /// Check if an IP address is link-local. /// /// Covers IPv4 `169.254.0.0/16`, IPv6 `fe80::/10`, and IPv4-mapped IPv6 @@ -213,6 +223,21 @@ fn is_internal_v4(v4: Ipv4Addr) -> bool { mod tests { use super::*; + // -- is_known_metadata_hostname -- + + #[test] + fn test_known_metadata_hostname_accepts_gcp_variants() { + assert!(is_known_metadata_hostname("metadata.google.internal")); + assert!(is_known_metadata_hostname("METADATA.GOOGLE.INTERNAL")); + assert!(is_known_metadata_hostname("metadata.google.internal.")); + } + + #[test] + fn test_known_metadata_hostname_rejects_public_hosts() { + assert!(!is_known_metadata_hostname("api.github.com")); + assert!(!is_known_metadata_hostname("")); + } + // -- is_link_local_ip -- #[test] diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 93774175b..3ff1f36f8 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -100,10 +100,11 @@ pub const AGENT_POLICY_PROPOSALS_ENABLED_KEY: &str = "agent_policy_proposals_ena /// global is set. pub const PROPOSAL_APPROVAL_MODE_KEY: &str = "proposal_approval_mode"; -/// Allowed values for [`PROPOSAL_APPROVAL_MODE_KEY`]. Any other string is -/// rejected at configure time (so operators get immediate feedback on typos -/// like `"autom"`) while the runtime resolver still fail-closes on unknown -/// persisted values for defense in depth. +/// Allowed values for [`PROPOSAL_APPROVAL_MODE_KEY`]. +/// +/// Any other string is rejected at configure time (so operators get immediate +/// feedback on typos like `"autom"`) while the runtime resolver still +/// fail-closes on unknown persisted values for defense in depth. pub const PROPOSAL_APPROVAL_MODE_VALUES: &[&str] = &["manual", "auto"]; pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ @@ -242,7 +243,15 @@ mod tests { fn proposal_approval_mode_rejects_typos_and_future_modes() { let setting = setting_for_key(PROPOSAL_APPROVAL_MODE_KEY) .expect("proposal_approval_mode should be registered"); - for bad in ["autom", "AUTO", "Manual", "", " auto", "auto_on_low_risk", "yes"] { + for bad in [ + "autom", + "AUTO", + "Manual", + "", + " auto", + "auto_on_low_risk", + "yes", + ] { let err = setting .validate_string_value(bad) .expect_err(&format!("expected '{bad}' to be rejected")); diff --git a/crates/openshell-prover/src/credentials.rs b/crates/openshell-prover/src/credentials.rs index c23387be1..586d0fbbf 100644 --- a/crates/openshell-prover/src/credentials.rs +++ b/crates/openshell-prover/src/credentials.rs @@ -135,27 +135,115 @@ pub struct CredentialSet { } impl CredentialSet { - /// 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`. + /// Credentials that target a given host. Matching mirrors runtime host + /// policy semantics for exact names and first-label wildcards, so a + /// proposal for `*.github.com` is treated as credentialed when the + /// attached credential targets `api.github.com`. pub fn credentials_for_host(&self, host: &str) -> Vec<&Credential> { - let needle = host.to_ascii_lowercase(); self.credentials .iter() .filter(|c| { c.target_hosts .iter() - .any(|h| h.eq_ignore_ascii_case(&needle)) + .any(|target| host_patterns_overlap(host, target)) }) .collect() } - /// API capability registry for a given host. Case-insensitive match. + /// API capability registry for a given host. Exact matches win, then + /// wildcard host overlap is used so credentialed wildcard proposals can be + /// evaluated against concrete API capability registries. pub fn api_for_host(&self, host: &str) -> Option<&ApiCapability> { + let needle = normalize_host(host); self.api_registries .values() - .find(|api| api.host.eq_ignore_ascii_case(host)) + .find(|api| normalize_host(&api.host) == needle) + .or_else(|| { + self.api_registries + .values() + .find(|api| host_patterns_overlap(host, &api.host)) + }) + } +} + +fn normalize_host(host: &str) -> String { + host.trim().trim_end_matches('.').to_ascii_lowercase() +} + +fn host_patterns_overlap(left: &str, right: &str) -> bool { + let left = normalize_host(left); + let right = normalize_host(right); + if left.is_empty() || right.is_empty() { + return false; + } + left == right || host_pattern_covers(&left, &right) || host_pattern_covers(&right, &left) +} + +fn host_pattern_covers(pattern: &str, host: &str) -> bool { + let pattern_labels: Vec<&str> = pattern.split('.').collect(); + let host_labels: Vec<&str> = host.split('.').collect(); + let Some(first_pattern_label) = pattern_labels.first().copied() else { + return false; + }; + + if first_pattern_label == "**" { + let suffix = &pattern_labels[1..]; + let host_suffix = host_labels + .len() + .checked_sub(suffix.len()) + .map(|start| &host_labels[start..]); + return !suffix.is_empty() + && host_labels.len() > suffix.len() + && matches!(host_suffix, Some(host_suffix) if host_suffix == suffix); + } + + if !first_pattern_label.contains('*') { + return false; } + + // Runtime host wildcards only apply in the first DNS label. Wildcards in + // later labels are not treated as policy globs here. + pattern_labels.len() == host_labels.len() + && pattern_labels[1..] == host_labels[1..] + && wildcard_label_matches(first_pattern_label, host_labels[0]) +} + +fn wildcard_label_matches(pattern: &str, label: &str) -> bool { + if pattern == "*" { + return !label.is_empty(); + } + if label.is_empty() || !pattern.contains('*') { + return false; + } + + let parts: Vec<&str> = pattern.split('*').collect(); + let mut remaining = label; + + if let Some(prefix) = parts.first().copied().filter(|part| !part.is_empty()) { + let Some(stripped) = remaining.strip_prefix(prefix) else { + return false; + }; + remaining = stripped; + } + + if parts.len() > 2 { + for part in parts[1..parts.len() - 1] + .iter() + .copied() + .filter(|part| !part.is_empty()) + { + let Some(offset) = remaining.find(part) else { + return false; + }; + remaining = &remaining[offset + part.len()..]; + } + } + + parts + .last() + .copied() + .filter(|suffix| !suffix.is_empty()) + .is_none_or(|suffix| remaining.ends_with(suffix)) } // --------------------------------------------------------------------------- @@ -276,3 +364,97 @@ pub fn load_credential_set_from_dir( api_registries, }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn github_credential() -> Credential { + Credential { + name: "github-pat".to_string(), + cred_type: "github-pat".to_string(), + scopes: vec!["repo".to_string()], + injected_via: "GITHUB_TOKEN".to_string(), + target_hosts: vec!["api.github.com".to_string()], + } + } + + fn github_api() -> ApiCapability { + ApiCapability { + api: "github".to_string(), + host: "api.github.com".to_string(), + port: 443, + credential_type: "github-pat".to_string(), + scope_capabilities: HashMap::new(), + action_risk: HashMap::new(), + } + } + + #[test] + fn host_patterns_overlap_matches_exact_case_and_trailing_dot() { + assert!(host_patterns_overlap("API.GITHUB.COM.", "api.github.com")); + assert!(!host_patterns_overlap( + "api.github.com", + "uploads.github.com" + )); + } + + #[test] + fn host_patterns_overlap_matches_first_label_wildcard_only() { + assert!(host_patterns_overlap("*.github.com", "api.github.com")); + assert!(!host_patterns_overlap("*.github.com", "github.com")); + assert!(!host_patterns_overlap( + "*.github.com", + "deep.api.github.com" + )); + } + + #[test] + fn host_patterns_overlap_matches_intra_label_first_label_wildcard() { + assert!(host_patterns_overlap( + "api-*.github.com", + "api-v3.github.com" + )); + assert!(!host_patterns_overlap( + "api-*.github.com", + "uploads.github.com" + )); + assert!(!host_patterns_overlap( + "api.*.github.com", + "api.v3.github.com" + )); + } + + #[test] + fn host_patterns_overlap_matches_recursive_first_label_wildcard() { + assert!(host_patterns_overlap("**.github.com", "api.github.com")); + assert!(host_patterns_overlap( + "**.github.com", + "deep.api.github.com" + )); + assert!(!host_patterns_overlap("**.github.com", "github.com")); + } + + #[test] + fn wildcard_policy_host_finds_credentialed_concrete_target() { + let set = CredentialSet { + credentials: vec![github_credential()], + api_registries: HashMap::new(), + }; + + let creds = set.credentials_for_host("*.github.com"); + assert_eq!(creds.len(), 1); + assert_eq!(creds[0].name, "github-pat"); + } + + #[test] + fn wildcard_policy_host_finds_concrete_api_registry() { + let set = CredentialSet { + credentials: Vec::new(), + api_registries: HashMap::from([("github".to_string(), github_api())]), + }; + + let api = set.api_for_host("*.github.com").expect("github API"); + assert_eq!(api.host, "api.github.com"); + } +} diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index 892e79cba..226705204 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -198,6 +198,80 @@ filesystem_policy: } } + #[test] + fn test_wildcard_endpoint_covering_credential_host_emits_credential_reach() { + use finding::{FindingPath, category}; + + let policy = policy::parse_policy_str( + r#" +version: 1 +network_policies: + github_wildcard: + name: github-wildcard + endpoints: + - host: "*.github.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-write + binaries: + - path: /usr/bin/curl +"#, + ) + .expect("parse policy"); + let cred_set = + credentials::load_credential_set_embedded(&testdata_dir().join("credentials.yaml")) + .expect("load creds"); + let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); + + let z3_model = build_model(policy, cred_set, bin_reg); + let findings = run_all_queries(&z3_model); + + let reach = findings + .iter() + .find(|finding| finding.query == category::CREDENTIAL_REACH_EXPANSION) + .expect("wildcard host covering api.github.com must be credentialed"); + assert!(reach.paths.iter().any(|path| matches!( + path, + FindingPath::Exfil(exfil) + if exfil.endpoint_host == "*.github.com" && exfil.binary == "/usr/bin/curl" + ))); + } + + #[test] + fn test_known_metadata_hostname_emits_link_local_finding() { + use finding::{FindingPath, category}; + + let policy = policy::parse_policy_str( + r" +version: 1 +network_policies: + metadata: + name: metadata + endpoints: + - host: metadata.google.internal + port: 80 + binaries: + - path: /usr/bin/curl +", + ) + .expect("parse policy"); + let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); + + let z3_model = build_model(policy, credentials::CredentialSet::default(), bin_reg); + let findings = run_all_queries(&z3_model); + + let link_local = findings + .iter() + .find(|finding| finding.query == category::LINK_LOCAL_REACH) + .expect("known metadata hostname must emit link-local/metadata finding"); + assert!(link_local.paths.iter().any(|path| matches!( + path, + FindingPath::Exfil(exfil) + if exfil.endpoint_host == "metadata.google.internal" + ))); + } + // 7. Empty policy produces no findings. #[test] fn test_empty_policy_no_findings() { diff --git a/crates/openshell-prover/src/queries.rs b/crates/openshell-prover/src/queries.rs index 6aae4b184..24e1402b7 100644 --- a/crates/openshell-prover/src/queries.rs +++ b/crates/openshell-prover/src/queries.rs @@ -56,6 +56,17 @@ pub(crate) fn is_link_local(host: &str) -> bool { } } +/// Return true for static cloud metadata hostnames that should be treated like +/// link-local metadata reach without performing DNS resolution. +pub(crate) fn is_known_metadata_hostname(host: &str) -> bool { + let normalized = host.trim().trim_end_matches('.').to_ascii_lowercase(); + matches!(normalized.as_str(), "metadata.google.internal") +} + +fn is_link_local_or_metadata_host(host: &str) -> bool { + is_link_local(host) || is_known_metadata_hostname(host) +} + /// Run all four formal queries against the model and emit one finding /// per category that has at least one path. /// @@ -80,11 +91,11 @@ pub fn check_credential_safety(model: &ReachabilityModel) -> Vec { continue; } - let host_is_link_local = is_link_local(&eid.host); + let host_is_link_local = is_link_local_or_metadata_host(&eid.host); let has_credential = !model.credentials.credentials_for_host(&eid.host).is_empty(); - // Tier 1: link-local. Unconditional. Other categories not - // emitted on link-local hosts — the link-local signal is the + // Tier 1: link-local/metadata. Unconditional. Other categories + // are not emitted on these hosts — the metadata signal is the // story. if host_is_link_local { link_local_paths.push(ExfilPath { @@ -174,13 +185,14 @@ pub fn check_credential_safety(model: &ReachabilityModel) -> Vec { 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 or Metadata Reach", + "Reach to a host in a link-local range or known metadata hostname — cloud-metadata territory.", link_local_paths, vec![ - "Endpoint host is in a link-local range (cloud-metadata territory). \ - Sandboxes should not reach these endpoints — reaching them can return \ - host credentials the sandbox should not have." + "Endpoint host is in a link-local range or known metadata hostname \ + (cloud-metadata territory). Sandboxes should not reach these \ + endpoints — reaching them can return host credentials the sandbox \ + should not have." .to_owned(), ], )); @@ -317,7 +329,19 @@ mod tests { #[test] fn is_link_local_rejects_hostnames() { assert!(!is_link_local("api.github.com")); - assert!(!is_link_local("metadata.google.internal")); assert!(!is_link_local("")); } + + #[test] + fn is_known_metadata_hostname_recognises_gcp_variants() { + assert!(is_known_metadata_hostname("metadata.google.internal")); + assert!(is_known_metadata_hostname("METADATA.GOOGLE.INTERNAL")); + assert!(is_known_metadata_hostname("metadata.google.internal.")); + } + + #[test] + fn is_known_metadata_hostname_rejects_other_hostnames() { + assert!(!is_known_metadata_hostname("api.github.com")); + assert!(!is_known_metadata_hostname("")); + } } diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index c513499f4..20d52459c 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -1317,6 +1317,9 @@ fn deny_response_body( "next_steps".to_string(), crate::policy_local::agent_next_steps(), ); + if let Some(guidance) = crate::policy_local::agent_guidance() { + body.insert("agent_guidance".to_string(), serde_json::json!(guidance)); + } serde_json::Value::Object(body) } @@ -2333,12 +2336,49 @@ mod tests { "/etc/openshell/skills/policy_advisor.md" ); assert_eq!(body["next_steps"][3]["body_type"], "PolicyMergeOperation"); + let guidance = body["agent_guidance"] + .as_str() + .expect("agent_guidance is present when proposals are enabled"); + assert!(guidance.contains("do not stop")); + assert!(guidance.contains("/etc/openshell/skills/policy_advisor.md")); + assert!(guidance.contains("http://policy.local/v1/proposals")); assert!( !body.to_string().contains("secret-token"), "deny body must not leak query params or credential values" ); } + #[test] + fn deny_response_body_omits_agent_guidance_when_policy_advisor_is_off() { + let _proposals = crate::test_helpers::ProposalsFlagGuard::set_blocking(false); + let req = L7Request { + action: "GET".to_string(), + target: "/gists".to_string(), + query_params: HashMap::new(), + raw_header: Vec::new(), + body_length: BodyLength::None, + }; + + let body = deny_response_body( + &req, + "github-readonly", + "no matching L7 allow rule", + None, + Some(DenyResponseContext { + host: Some("api.github.com"), + port: Some(443), + binary: Some("/usr/bin/gh"), + }), + ); + + assert_eq!(body["error"], "policy_denied"); + assert_eq!(body["next_steps"], serde_json::json!([])); + assert!( + body.get("agent_guidance").is_none(), + "agent_guidance must only be present when the policy advisor is enabled" + ); + } + #[tokio::test] async fn send_deny_response_writes_structured_json_403() { // Agent-readable next_steps is gated on the proposals feature flag. @@ -2384,6 +2424,7 @@ mod tests { assert_eq!(body["path"], "/user/repos"); assert_eq!(body["rule_missing"]["host"], "api.github.com"); assert_eq!(body["next_steps"][2]["action"], "inspect_recent_denials"); + assert!(body["agent_guidance"].as_str().unwrap().contains("retry")); } #[test] diff --git a/crates/openshell-sandbox/src/mechanistic_mapper.rs b/crates/openshell-sandbox/src/mechanistic_mapper.rs index 521c882a0..50cb0b040 100644 --- a/crates/openshell-sandbox/src/mechanistic_mapper.rs +++ b/crates/openshell-sandbox/src/mechanistic_mapper.rs @@ -12,7 +12,7 @@ //! The LLM-powered `PolicyAdvisor` (issue #205) wraps and enriches these //! mechanistic proposals with context-aware rationale and smarter grouping. -use openshell_core::net::is_always_blocked_ip; +use openshell_core::net::{is_always_blocked_ip, is_known_metadata_hostname}; use openshell_core::proto::{ DenialSummary, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, }; @@ -106,15 +106,15 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { } // Skip proposals for always-blocked destinations (loopback, - // link-local, unspecified). These would be denied at runtime by the - // proxy's is_always_blocked_ip check regardless of policy, producing - // an infinite proposal loop in the TUI. + // link-local, unspecified, and known metadata hostnames). These would + // be denied at runtime regardless of policy, producing an infinite + // proposal loop in the TUI. if is_always_blocked_destination(host) { tracing::info!( host, port, "Skipped proposal for always-blocked destination \ - (SSRF hardening — loopback/link-local/unspecified)" + (SSRF hardening — loopback/link-local/unspecified/metadata)" ); continue; } @@ -416,7 +416,7 @@ fn short_binary_name(path: &str) -> String { /// Check if a destination host is always-blocked. /// /// For literal IP hosts, checks against [`is_always_blocked_ip`]. -/// For hostnames like "localhost", checks well-known loopback names. +/// For hostnames, checks well-known loopback and cloud metadata names. /// For other hostnames, returns false (DNS may resolve to anything). fn is_always_blocked_destination(host: &str) -> bool { // Check literal IP addresses @@ -425,7 +425,7 @@ fn is_always_blocked_destination(host: &str) -> bool { } // Check well-known loopback hostnames let host_lc = host.to_lowercase(); - host_lc == "localhost" || host_lc == "localhost." + host_lc == "localhost" || host_lc == "localhost." || is_known_metadata_hostname(host) } #[cfg(test)] @@ -598,6 +598,12 @@ mod tests { assert!(is_always_blocked_destination("LOCALHOST")); } + #[test] + fn test_always_blocked_destination_known_metadata_hostname() { + assert!(is_always_blocked_destination("metadata.google.internal")); + assert!(is_always_blocked_destination("METADATA.GOOGLE.INTERNAL.")); + } + #[test] fn test_always_blocked_destination_allows_rfc1918() { assert!(!is_always_blocked_destination("10.0.5.20")); @@ -651,6 +657,26 @@ mod tests { ); } + #[test] + fn test_generate_proposals_skips_known_metadata_hostname() { + let summaries = vec![DenialSummary { + host: "metadata.google.internal".to_string(), + port: 80, + binary: "/usr/bin/curl".to_string(), + count: 5, + first_seen_ms: 1000, + last_seen_ms: 2000, + denial_stage: "ssrf".to_string(), + ..Default::default() + }]; + + let proposals = generate_proposals(&summaries); + assert!( + proposals.is_empty(), + "should skip proposals for metadata hostname: {proposals:?}" + ); + } + #[test] fn test_generate_proposals_skips_localhost_hostname() { let summaries = vec![DenialSummary { diff --git a/crates/openshell-sandbox/src/policy_local.rs b/crates/openshell-sandbox/src/policy_local.rs index 657fd760f..aca07596c 100644 --- a/crates/openshell-sandbox/src/policy_local.rs +++ b/crates/openshell-sandbox/src/policy_local.rs @@ -24,6 +24,10 @@ pub const POLICY_LOCAL_HOST: &str = "policy.local"; /// renders the same path. Changing the location is a one-line update here. pub const SKILL_PATH: &str = "/etc/openshell/skills/policy_advisor.md"; +/// Human-readable guidance for agents that are more likely to follow plain +/// instructions than structured next-step JSON alone. +pub const AGENT_GUIDANCE: &str = "OpenShell blocked this request with sandbox policy. If the user's task still needs this network action, do not stop here: read /etc/openshell/skills/policy_advisor.md, submit the narrowest policy proposal to http://policy.local/v1/proposals, wait for approval and `policy_reloaded: true`, then retry the original request."; + /// Routes served by the in-sandbox policy advisor API. Held in one place so /// the L7 deny `next_steps` array, the route dispatcher, the skill content, /// and tests all stay in sync — change the wire path here and every caller @@ -242,6 +246,12 @@ pub fn agent_next_steps() -> serde_json::Value { ]) } +/// Build the optional natural-language guidance embedded in L7 deny bodies. +#[must_use] +pub fn agent_guidance() -> Option<&'static str> { + crate::agent_proposals_enabled().then_some(AGENT_GUIDANCE) +} + async fn current_policy_response(ctx: &PolicyLocalContext) -> (u16, serde_json::Value) { let Some(policy) = ctx.current_policy.read().await.clone() else { return ( @@ -1567,6 +1577,22 @@ mod tests { assert!(actions.contains(&"submit_proposal")); } + #[test] + fn agent_guidance_is_absent_when_flag_off() { + let _guard = ProposalsFlagGuard::set_blocking(false); + assert!(agent_guidance().is_none()); + } + + #[test] + fn agent_guidance_points_to_policy_advisor_when_flag_on() { + let _guard = ProposalsFlagGuard::set_blocking(true); + let guidance = agent_guidance().expect("guidance when proposals are enabled"); + assert!(guidance.contains("do not stop")); + assert!(guidance.contains("/etc/openshell/skills/policy_advisor.md")); + assert!(guidance.contains("http://policy.local/v1/proposals")); + assert!(guidance.contains("policy_reloaded: true")); + } + #[tokio::test] async fn route_request_returns_feature_disabled_when_flag_off() { let _guard = ProposalsFlagGuard::set(false).await; diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 0fc657007..76786a84d 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -20,7 +20,7 @@ use std::os::unix::io::RawFd; use std::path::PathBuf; use std::process::Stdio; use tokio::process::{Child, Command}; -use tracing::{debug, warn}; +use tracing::debug; fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap) { for (key, value) in provider_env { @@ -89,7 +89,7 @@ fn check_runtime_pid_limit_status( if matches!(mode, RuntimePidLimitMode::Require) { Err(miette::miette!(message)) } else { - warn!("{message}"); + tracing::warn!("{message}"); Ok(()) } } @@ -100,7 +100,7 @@ fn check_runtime_pid_limit_status( if matches!(mode, RuntimePidLimitMode::Require) { Err(miette::miette!(message)) } else { - warn!("{message}"); + tracing::warn!("{message}"); Ok(()) } } diff --git a/crates/openshell-sandbox/src/skills.rs b/crates/openshell-sandbox/src/skills.rs index d29d56247..13e1da025 100644 --- a/crates/openshell-sandbox/src/skills.rs +++ b/crates/openshell-sandbox/src/skills.rs @@ -8,11 +8,21 @@ use std::path::{Path, PathBuf}; const SKILLS_RELATIVE_DIR: &str = "etc/openshell/skills"; const POLICY_ADVISOR_FILE: &str = "policy_advisor.md"; +const POLICY_ADVISOR_SKILL_DIR: &str = "policy-advisor"; +const POLICY_ADVISOR_SKILL_FILE: &str = "SKILL.md"; const POLICY_ADVISOR_CONTENT: &str = include_str!("skills/policy_advisor.md"); +const POLICY_ADVISOR_SKILL_CONTENT: &str = include_str!("skills/policy-advisor/SKILL.md"); +const AGENTS_FILE: &str = "AGENTS.md"; +const AGENTS_CONTENT: &str = r"# OpenShell Sandbox Guidance + +When you see `policy_denied`, do not treat it as final if the user task still needs that request. Read `/etc/openshell/skills/policy_advisor.md` and use `http://policy.local` to submit the narrowest policy proposal, then wait for approval before retrying. +"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct InstalledSkills { pub policy_advisor: PathBuf, + pub policy_advisor_skill: PathBuf, + pub agents: Option, } pub fn install_static_skills() -> Result { @@ -24,17 +34,43 @@ fn install_static_skills_at(root: &Path) -> Result { std::fs::create_dir_all(&skills_dir).into_diagnostic()?; let policy_advisor = skills_dir.join(POLICY_ADVISOR_FILE); - std::fs::write(&policy_advisor, POLICY_ADVISOR_CONTENT).into_diagnostic()?; + write_readonly(&policy_advisor, POLICY_ADVISOR_CONTENT)?; + + let policy_advisor_skill_dir = skills_dir.join(POLICY_ADVISOR_SKILL_DIR); + std::fs::create_dir_all(&policy_advisor_skill_dir).into_diagnostic()?; + let policy_advisor_skill = policy_advisor_skill_dir.join(POLICY_ADVISOR_SKILL_FILE); + write_readonly(&policy_advisor_skill, POLICY_ADVISOR_SKILL_CONTENT)?; + + let agents = install_optional_agents_pointer(root); + + Ok(InstalledSkills { + policy_advisor, + policy_advisor_skill, + agents, + }) +} + +fn write_readonly(path: &Path, contents: &str) -> Result<()> { + std::fs::write(path, contents).into_diagnostic()?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; - std::fs::set_permissions(&policy_advisor, std::fs::Permissions::from_mode(0o444)) - .into_diagnostic()?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o444)).into_diagnostic()?; } + Ok(()) +} - Ok(InstalledSkills { policy_advisor }) +fn install_optional_agents_pointer(root: &Path) -> Option { + let agents_path = root.join(AGENTS_FILE); + match std::fs::symlink_metadata(&agents_path) { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + write_readonly(&agents_path, AGENTS_CONTENT).ok()?; + Some(agents_path) + } + Ok(_) | Err(_) => None, + } } #[cfg(test)] @@ -55,7 +91,7 @@ mod tests { .join("policy_advisor.md"); assert_eq!(installed.policy_advisor, expected); - let content = std::fs::read_to_string(expected).unwrap(); + let content = std::fs::read_to_string(&expected).unwrap(); assert!(content.contains("# OpenShell Policy Advisor")); assert!(content.contains("policy.local")); assert!(content.contains("addRule")); @@ -71,5 +107,56 @@ mod tests { // and re-runs into policy_denied. assert!(content.contains("`policy_reloaded: true`")); assert!(content.contains("`policy_reloaded: false`")); + + let skill_file = dir + .path() + .join("etc") + .join("openshell") + .join("skills") + .join("policy-advisor") + .join("SKILL.md"); + assert_eq!(installed.policy_advisor_skill, skill_file); + let skill_content = std::fs::read_to_string(&skill_file).unwrap(); + assert!(skill_content.contains("policy_denied")); + assert!(skill_content.contains("policy.local")); + assert!(skill_content.contains("/etc/openshell/skills/policy_advisor.md")); + + let agents = installed.agents.expect("AGENTS.md should be installed"); + assert_eq!(agents, dir.path().join("AGENTS.md")); + let agents_content = std::fs::read_to_string(agents).unwrap(); + assert!(agents_content.contains("policy_denied")); + assert!(agents_content.contains("policy.local")); + } + + #[test] + fn install_static_skills_at_does_not_overwrite_existing_agents_file() { + let dir = tempfile::tempdir().unwrap(); + let agents = dir.path().join("AGENTS.md"); + std::fs::write(&agents, "keep me").unwrap(); + + let installed = install_static_skills_at(dir.path()).unwrap(); + + assert_eq!(installed.agents, None); + assert_eq!(std::fs::read_to_string(agents).unwrap(), "keep me"); + } + + #[cfg(unix)] + #[test] + fn install_static_skills_at_treats_broken_agents_symlink_as_existing() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let agents = dir.path().join("AGENTS.md"); + symlink(dir.path().join("missing-target"), &agents).unwrap(); + + let installed = install_static_skills_at(dir.path()).unwrap(); + + assert_eq!(installed.agents, None); + assert!( + std::fs::symlink_metadata(agents) + .unwrap() + .file_type() + .is_symlink() + ); } } diff --git a/crates/openshell-sandbox/src/skills/policy-advisor/SKILL.md b/crates/openshell-sandbox/src/skills/policy-advisor/SKILL.md new file mode 100644 index 000000000..f05ba6a0b --- /dev/null +++ b/crates/openshell-sandbox/src/skills/policy-advisor/SKILL.md @@ -0,0 +1,8 @@ +--- +name: openshell-policy-advisor +description: Use when an OpenShell sandbox returns policy_denied, mentions policy.local, or needs a narrow network policy proposal. +--- + +# OpenShell Policy Advisor + +When a request fails with `policy_denied`, do not treat the denial as final if the user task still needs the request. Read `/etc/openshell/skills/policy_advisor.md`, inspect `http://policy.local/v1/policy/current`, submit the narrowest proposal to `http://policy.local/v1/proposals`, wait on `/v1/proposals/{chunk_id}/wait?timeout=300`, and retry only after approval with `policy_reloaded: true`. diff --git a/crates/openshell-sandbox/src/skills/policy_advisor.md b/crates/openshell-sandbox/src/skills/policy_advisor.md index 724d17b66..4b7074f6d 100644 --- a/crates/openshell-sandbox/src/skills/policy_advisor.md +++ b/crates/openshell-sandbox/src/skills/policy_advisor.md @@ -149,7 +149,8 @@ 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 + range (`169.254.0.0/16`, `fe80::/10`) or a known metadata hostname + such as `metadata.google.internal`. Cloud metadata endpoints like `169.254.169.254` live here. **Never** propose access to these — these endpoints serve credentials regardless of what the sandbox itself holds. @@ -203,8 +204,9 @@ The new submission wins by structural overlap. - 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. + `fe80::/10`) or known metadata hostnames such as + `metadata.google.internal`. 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 2a2a4d3cb..0f5f3d58e 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3064,30 +3064,44 @@ fn generate_security_notes(host: &str, port: u16) -> String { /// /// This is defense-in-depth: the proxy blocks these at runtime, so /// merging them into the active policy would be silently un-enforceable. +fn validate_host_not_always_blocked(host: &str) -> Result<(), Status> { + use openshell_core::net::{is_always_blocked_ip, is_known_metadata_hostname}; + use std::net::IpAddr; + + let host = host.trim(); + // Check if the host is a literal always-blocked IP. + if let Ok(ip) = host.parse::() + && is_always_blocked_ip(ip) + { + return Err(Status::invalid_argument(format!( + "proposed rule endpoint host '{host}' is an always-blocked address \ + (loopback/link-local/unspecified); the proxy will deny traffic \ + to this destination regardless of policy" + ))); + } + let host_lc = host.to_lowercase(); + if host_lc == "localhost" || host_lc == "localhost." { + return Err(Status::invalid_argument( + "proposed rule endpoint host 'localhost' is always blocked; \ + the proxy will deny traffic to loopback regardless of policy" + .to_string(), + )); + } + if is_known_metadata_hostname(host) { + return Err(Status::invalid_argument(format!( + "proposed rule endpoint host '{host}' is a known cloud metadata hostname; \ + the proxy will deny traffic to this destination regardless of policy" + ))); + } + Ok(()) +} + fn validate_rule_not_always_blocked(rule: &NetworkPolicyRule) -> Result<(), Status> { - use openshell_core::net::{is_always_blocked_ip, is_always_blocked_net}; + use openshell_core::net::is_always_blocked_net; use std::net::IpAddr; for ep in &rule.endpoints { - // Check if the endpoint host is a literal always-blocked IP. - if let Ok(ip) = ep.host.parse::() - && is_always_blocked_ip(ip) - { - return Err(Status::invalid_argument(format!( - "proposed rule endpoint host '{}' is an always-blocked address \ - (loopback/link-local/unspecified); the proxy will deny traffic \ - to this destination regardless of policy", - ep.host - ))); - } - let host_lc = ep.host.to_lowercase(); - if host_lc == "localhost" || host_lc == "localhost." { - return Err(Status::invalid_argument( - "proposed rule endpoint host 'localhost' is always blocked; \ - the proxy will deny traffic to loopback regardless of policy" - .to_string(), - )); - } + validate_host_not_always_blocked(&ep.host)?; // Check allowed_ips entries. for entry in &ep.allowed_ips { @@ -3258,8 +3272,10 @@ fn parse_proto_add_allow_rules( fn validate_merge_operations_for_server(operations: &[PolicyMergeOp]) -> Result<(), Status> { for operation in operations { - if let PolicyMergeOp::AddRule { rule, .. } = operation { - validate_rule_not_always_blocked(rule)?; + match operation { + PolicyMergeOp::AddRule { rule, .. } => validate_rule_not_always_blocked(rule)?, + PolicyMergeOp::AddAllowRules { host, .. } => validate_host_not_always_blocked(host)?, + _ => {} } } Ok(()) @@ -8242,6 +8258,52 @@ mod tests { assert!(result.unwrap_err().message().contains("always blocked")); } + #[test] + fn validate_rule_rejects_known_metadata_hostname() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + + let rule = NetworkPolicyRule { + name: "bad".to_string(), + endpoints: vec![NetworkEndpoint { + host: "METADATA.GOOGLE.INTERNAL.".to_string(), + port: 80, + ..Default::default() + }], + binaries: vec![], + }; + let result = validate_rule_not_always_blocked(&rule); + assert!(result.is_err()); + let status = result.unwrap_err(); + assert_eq!(status.code(), Code::InvalidArgument); + assert!(status.message().contains("cloud metadata hostname")); + } + + #[test] + fn validate_merge_operations_rejects_add_allow_for_known_metadata_hostname() { + let operation = PolicyMergeOp::AddAllowRules { + host: "metadata.google.internal".to_string(), + port: 80, + rules: vec![L7Rule { + allow: Some(openshell_core::proto::L7Allow { + method: "GET".to_string(), + path: "/computeMetadata/v1/**".to_string(), + command: String::new(), + query: HashMap::new(), + operation_type: String::new(), + operation_name: String::new(), + fields: Vec::new(), + }), + }], + }; + + let result = validate_merge_operations_for_server(&[operation]); + + assert!(result.is_err()); + let status = result.unwrap_err(); + assert_eq!(status.code(), Code::InvalidArgument); + assert!(status.message().contains("cloud metadata hostname")); + } + #[test] fn validate_rule_accepts_rfc1918_allowed_ips() { use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; @@ -8399,11 +8461,8 @@ mod tests { let value = SettingValue { value: Some(setting_value::Value::StringValue(raw.to_string())), }; - let stored = proto_setting_to_stored( - settings::PROPOSAL_APPROVAL_MODE_KEY, - &value, - ) - .unwrap_or_else(|e| panic!("expected '{raw}' to be accepted, got: {e}")); + let stored = proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value) + .unwrap_or_else(|e| panic!("expected '{raw}' to be accepted, got: {e}")); assert_eq!(stored, StoredSettingValue::String(raw.to_string())); } } @@ -8418,11 +8477,11 @@ mod tests { let value = SettingValue { value: Some(setting_value::Value::StringValue(raw.to_string())), }; - let res = proto_setting_to_stored( - settings::PROPOSAL_APPROVAL_MODE_KEY, - &value, + let res = proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value); + assert!( + res.is_err(), + "expected '{raw}' to be rejected, got: {res:?}" ); - assert!(res.is_err(), "expected '{raw}' to be rejected, got: {res:?}"); let err = res.unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); } @@ -8433,11 +8492,8 @@ mod tests { let value = SettingValue { value: Some(setting_value::Value::StringValue("autom".to_string())), }; - let err = proto_setting_to_stored( - settings::PROPOSAL_APPROVAL_MODE_KEY, - &value, - ) - .unwrap_err(); + let err = + proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value).unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); let msg = err.message(); assert!(msg.contains("manual"), "missing 'manual' in {msg}"); diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index dc610e6ca..47fa02c64 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -1018,12 +1018,12 @@ impl App { } } SettingValueKind::String => { - if let Some(setting) = settings::setting_for_key(&entry.key) { - if let Err(allowed) = setting.validate_string_value(raw) { - edit.error = - Some(format!("expected one of: {}", allowed.join(", "))); - return; - } + if let Some(setting) = settings::setting_for_key(&entry.key) + && let Err(allowed) = setting.validate_string_value(raw) + { + edit.error = + Some(format!("expected one of: {}", allowed.join(", "))); + return; } } } @@ -1270,12 +1270,12 @@ impl App { } } SettingValueKind::String => { - if let Some(setting) = settings::setting_for_key(&entry.key) { - if let Err(allowed) = setting.validate_string_value(raw) { - edit.error = - Some(format!("expected one of: {}", allowed.join(", "))); - return; - } + if let Some(setting) = settings::setting_for_key(&entry.key) + && let Err(allowed) = setting.validate_string_value(raw) + { + edit.error = + Some(format!("expected one of: {}", allowed.join(", "))); + return; } } } diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index 81c47248c..dcfe9f19d 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -198,6 +198,8 @@ An upstream that the proxy cannot reach returns `502 Bad Gateway`: The `error` field is a short machine-readable code (`policy_denied`, `ssrf_denied`, `upstream_unreachable`). The `detail` field is a human-readable explanation suitable for display in an agent transcript. +For L7 REST denials, the body also includes structured policy fields such as `method`, `path`, `rule_missing`, and `next_steps`. When policy advisor is enabled, it also includes `agent_guidance`, a short plain-language instruction telling the agent to read `/etc/openshell/skills/policy_advisor.md`, propose the narrowest rule through `http://policy.local/v1/proposals`, wait for `policy_reloaded: true`, and retry. + ## Filesystem Sandbox Logs Landlock filesystem restrictions emit `CONFIG:` events at startup and whenever the sandbox has to skip a requested path. diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index 81eeca2bb..050db004f 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -92,13 +92,14 @@ Only `manual` and `auto` are accepted; typos like `autom` are rejected at config When policy advisor is enabled, the sandbox supervisor turns on three agent-facing surfaces: - It installs `/etc/openshell/skills/policy_advisor.md` inside the sandbox. +- It also installs `/etc/openshell/skills/policy-advisor/SKILL.md` as a short Codex/generic-agent pointer, and writes a root `/AGENTS.md` pointer only when the image does not already provide one. - It serves `http://policy.local` from inside the sandbox. -- It adds `next_steps` to L7 `policy_denied` response bodies so the agent can find the skill and local API. +- It adds `agent_guidance` and `next_steps` to L7 `policy_denied` response bodies so the agent can find the skill and local API. The loop has seven steps: 1. A sandboxed process attempts a network request that policy denies. -2. For inspected REST traffic, OpenShell returns a structured `403` body with fields such as `layer`, `host`, `port`, `binary`, `method`, `path`, `rule_missing`, and `next_steps`. +2. For inspected REST traffic, OpenShell returns a structured `403` body with fields such as `layer`, `host`, `port`, `binary`, `method`, `path`, `rule_missing`, `agent_guidance`, and `next_steps`. 3. The agent reads the policy advisor skill, inspects the current policy, and optionally reads recent denial log lines. 4. The agent submits one or more `addRule` proposals to `http://policy.local/v1/proposals`. 5. The gateway stores accepted proposals as pending draft chunks for the sandbox and runs the [policy prover](#what-auto-approval-checks) against the proposed delta. @@ -165,7 +166,7 @@ The policy prover runs against every proposal — mechanistic and agent-authored | Category | Triggered when | |---|---| -| `link_local_reach` | The proposal reaches a host in `169.254.0.0/16` or `fe80::/10` (the cloud-metadata range, which serves credentials regardless of sandbox state). Unconditional. | +| `link_local_reach` | The proposal reaches a host in `169.254.0.0/16`, `fe80::/10`, or a known metadata hostname such as `metadata.google.internal` (cloud-metadata territory, which serves credentials regardless of sandbox state). Unconditional. | | `l7_bypass_credentialed` | A binary using a wire protocol the L7 proxy cannot inspect (`git-remote-https`, `ssh`, `nc`) gains reach to a host where a credential is in scope. | | `credential_reach_expansion` | A binary gains credentialed reach to a `(host, port)` it could not reach before. | | `capability_expansion` | On a `(binary, host, port)` that already had credentialed reach, the proposal adds a new HTTP method. The finding cites the specific method. | @@ -218,7 +219,7 @@ The rejection reason is returned to the agent through `policy.local`. The agent | `GET /v1/proposals/{chunk_id}` | Returns one proposal's current `pending`, `approved`, or `rejected` status. | | `GET /v1/proposals/{chunk_id}/wait?timeout=300` | Holds one HTTP request open until the proposal is approved, rejected, or the timeout expires. | -If policy advisor is disabled, every route returns `404 feature_disabled`, the skill is not installed for new sandboxes, and L7 deny bodies do not advertise `policy.local` routes. +If policy advisor is disabled, every route returns `404 feature_disabled`, the skill is not installed for new sandboxes, and L7 deny bodies do not advertise `policy.local` routes or include `agent_guidance`. ## What to Expect