diff --git a/crates/openshell-prover/README.md b/crates/openshell-prover/README.md index 0026a04172..4e83aa2aa4 100644 --- a/crates/openshell-prover/README.md +++ b/crates/openshell-prover/README.md @@ -186,6 +186,12 @@ not form part of the authored policy schema. See `src/model.rs`. Briefly: +- Endpoint facts are indexed by rule, endpoint position, and effective port. + Sibling endpoints retain their own grants even when their destinations overlap. +- Query entrypoints return an error if the base constraints are inconsistent or + the solver cannot reach a conclusion. Callers must propagate that error; it + cannot be accepted as a finding or compared as an empty finding list. + - Bool sorts per `(binary, endpoint)` pair encode policy reachability, filtered by binary capability flags (`can_exfiltrate`, `bypasses_l7`). diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index b8bd2bfc13..79a8b5ff0a 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -31,7 +31,7 @@ use report::{render_compact, render_report}; /// /// - `Ok(0)` — pass (no findings, or all accepted) /// - `Ok(1)` — fail (one or more unaccepted findings present) -/// - `Err(_)` — input or registry loading error +/// - `Err(_)` — input, registry, or solver error /// /// Binary and API capability registries are embedded at compile time. /// Pass `registry_dir` to override with a custom filesystem registry. @@ -59,7 +59,7 @@ pub fn prove( }; let z3_model = build_model(policy, credential_set, binary_registry); - let mut findings = run_all_queries(&z3_model); + let mut findings = run_all_queries(&z3_model)?; if let Some(ar_path) = accepted_risks_path { let accepted = load_accepted_risks(Path::new(ar_path))?; @@ -223,7 +223,7 @@ filesystem_policy: let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); let z3_model = build_model(pol, cred_set, bin_reg); - let findings = run_all_queries(&z3_model); + let findings = run_all_queries(&z3_model).expect("run queries"); let categories: std::collections::HashSet<&str> = findings.iter().map(|f| f.query.as_str()).collect(); @@ -276,7 +276,7 @@ network_policies: 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 findings = run_all_queries(&z3_model).expect("run queries"); let reach = findings .iter() @@ -310,7 +310,7 @@ network_policies: 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 findings = run_all_queries(&z3_model).expect("run queries"); let link_local = findings .iter() @@ -323,7 +323,104 @@ network_policies: ))); } - // 7. Empty policy produces no findings. + #[test] + fn sibling_endpoints_preserve_findings_and_method_identity() { + use finding::{FindingPath, category}; + + // Exercise both conflicting write facts and agreeing write facts with + // different methods, in either endpoint order and on overlapping ports. + for (first_method, second_method) in [("GET", "*"), ("POST", "DELETE")] { + for reverse in [false, true] { + let mut policy = policy::parse_policy_str(&format!( + r#" +version: 1 +network_policies: + github: + endpoints: + - host: api.github.com + ports: [443, 8443] + protocol: rest + rules: + - allow: {{method: "{first_method}", path: "/**"}} + - host: api.github.com + port: 443 + protocol: rest + rules: + - allow: {{method: "{second_method}", path: "/**"}} + binaries: + - path: /usr/bin/curl + metadata: + endpoints: + - host: 169.254.169.254 + port: 80 + binaries: + - path: /usr/bin/curl +"# + )) + .unwrap(); + if reverse { + policy + .network_policies + .get_mut("github") + .unwrap() + .endpoints + .reverse(); + } + let credentials = credentials::load_credential_set_embedded( + &testdata_dir().join("credentials.yaml"), + ) + .unwrap(); + let registry = registry::load_embedded_binary_registry().unwrap(); + let model = build_model(policy, credentials, registry); + let findings = run_all_queries(&model).unwrap(); + assert!( + findings + .iter() + .any(|f| f.query == category::LINK_LOCAL_REACH) + ); + assert!( + findings + .iter() + .any(|f| f.query == category::CREDENTIAL_REACH_EXPANSION) + ); + let methods = &findings + .iter() + .find(|f| f.query == category::CAPABILITY_EXPANSION) + .unwrap() + .paths; + assert!(methods.iter().any(|p| matches!(p, FindingPath::Exfil(p) if p.method == "DELETE" && p.endpoint_port == 443))); + assert!(!methods.iter().any(|p| matches!(p, FindingPath::Exfil(p) if p.method == "DELETE" && p.endpoint_port == 8443))); + } + } + } + + #[test] + fn sibling_l4_and_l7_endpoints_preserve_reach() { + let policy = policy::parse_policy_str( + r" +version: 1 +network_policies: + metadata: + endpoints: + - host: 169.254.169.254 + port: 80 + protocol: rest + access: read-only + - host: 169.254.169.254 + port: 80 + binaries: + - path: /usr/bin/curl +", + ) + .unwrap(); + let registry = registry::load_embedded_binary_registry().unwrap(); + let model = build_model(policy, credentials::CredentialSet::default(), registry); + let findings = run_all_queries(&model).unwrap(); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].query, finding::category::LINK_LOCAL_REACH); + } + + // Empty policy produces no findings. #[test] fn test_empty_policy_no_findings() { let policy_path = testdata_dir().join("empty-policy.yaml"); @@ -334,7 +431,7 @@ network_policies: let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); let z3_model = build_model(pol, cred_set, bin_reg); - let findings = run_all_queries(&z3_model); + let findings = run_all_queries(&z3_model).expect("run queries"); assert!( findings.is_empty(), diff --git a/crates/openshell-prover/src/model.rs b/crates/openshell-prover/src/model.rs index 3769b17e43..8462a92bed 100644 --- a/crates/openshell-prover/src/model.rs +++ b/crates/openshell-prover/src/model.rs @@ -16,6 +16,8 @@ use crate::registry::BinaryRegistry; #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct EndpointId { pub policy_name: String, + /// Position within the policy rule's endpoint list. + pub endpoint_index: usize, pub host: String, pub port: u16, } @@ -23,7 +25,14 @@ pub struct EndpointId { impl EndpointId { /// Stable string key used for Z3 variable naming. pub fn key(&self) -> String { - format!("{}:{}:{}", self.policy_name, self.host, self.port) + // Length-prefix the authored name so delimiters cannot alias another rule. + format!( + "{}:{}:{}:{}", + self.policy_name.len(), + self.policy_name, + self.endpoint_index, + self.port + ) } } @@ -97,10 +106,11 @@ impl ReachabilityModel { fn index_endpoints(&mut self) { for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { + for (endpoint_index, ep) in rule.endpoints.iter().enumerate() { for port in ep.effective_ports() { self.endpoints.push(EndpointId { policy_name: policy_name.clone(), + endpoint_index, host: ep.host.clone(), port, }); @@ -122,15 +132,16 @@ impl ReachabilityModel { fn encode_policy_allows(&mut self) { for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { + for (endpoint_index, ep) in rule.endpoints.iter().enumerate() { for port in ep.effective_ports() { let eid = EndpointId { policy_name: policy_name.clone(), + endpoint_index, host: ep.host.clone(), port, }; for b in &rule.binaries { - let key = format!("{}:{}", b.path, eid.key()); + let key = format!("{}:{}:{}", b.path.len(), b.path, eid.key()); let var = Bool::new_const(format!("policy_allows_{key}")); self.solver.assert(&var); self.policy_allows.insert(key, var); @@ -142,10 +153,11 @@ impl ReachabilityModel { fn encode_l7_enforcement(&mut self) { for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { + for (endpoint_index, ep) in rule.endpoints.iter().enumerate() { for port in ep.effective_ports() { let eid = EndpointId { policy_name: policy_name.clone(), + endpoint_index, host: ep.host.clone(), port, }; @@ -284,7 +296,7 @@ impl ReachabilityModel { /// Build a Z3 expression for whether a binary can write to an endpoint. pub fn can_write_to_endpoint(&self, bpath: &str, eid: &EndpointId) -> Bool { let ek = eid.key(); - let access_key = format!("{bpath}:{ek}"); + let access_key = format!("{}:{bpath}:{ek}", bpath.len()); let has_access = match self.policy_allows.get(&access_key) { Some(v) => v.clone(), @@ -328,7 +340,7 @@ impl ReachabilityModel { /// Build a Z3 expression for whether data can be exfiltrated via this path. pub fn can_exfil_via_endpoint(&self, bpath: &str, eid: &EndpointId) -> Bool { let ek = eid.key(); - let access_key = format!("{bpath}:{ek}"); + let access_key = format!("{}:{bpath}:{ek}", bpath.len()); let has_access = match self.policy_allows.get(&access_key) { Some(v) => v.clone(), @@ -372,6 +384,15 @@ impl ReachabilityModel { ]) } + /// Reject inconsistent or inconclusive base constraints before querying them. + pub fn validate(&self) -> miette::Result<()> { + match self.solver.check() { + SatResult::Sat => Ok(()), + SatResult::Unsat => miette::bail!("inconsistent reachability model"), + SatResult::Unknown => miette::bail!("reachability model validation inconclusive"), + } + } + /// Check satisfiability of an expression against the base constraints. pub fn check_sat(&self, expr: &Bool) -> SatResult { self.solver.push(); @@ -392,3 +413,52 @@ pub fn build_model( let _ctx = Context::thread_local(); ReachabilityModel::new(policy, credentials, binary_registry) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::queries::{check_credential_safety, run_all_queries}; + + #[test] + fn inconsistent_base_model_is_an_error_even_without_query_paths() { + let model = build_model( + PolicyModel::default(), + CredentialSet::default(), + crate::registry::load_embedded_binary_registry().unwrap(), + ); + model.solver.assert(Bool::from_bool(false)); + assert!(check_credential_safety(&model).is_err()); + assert!(run_all_queries(&model).is_err()); + } + + #[test] + fn inconclusive_base_model_is_an_error() { + let model = build_model( + PolicyModel::default(), + CredentialSet::default(), + crate::registry::load_embedded_binary_registry().unwrap(), + ); + model.solver.assert(Bool::new_const("resource_limited")); + let mut params = z3::Params::new(); + params.set_u32("rlimit", 1); + model.solver.set_params(¶ms); + assert_eq!(model.solver.check(), SatResult::Unknown); + assert!(run_all_queries(&model).is_err()); + } + + #[test] + fn endpoint_keys_distinguish_rule_names_with_delimiters() { + let left = EndpointId { + policy_name: "rule:2001".to_string(), + endpoint_index: 0, + host: "db8::1".to_string(), + port: 443, + }; + let right = EndpointId { + policy_name: "rule".to_string(), + host: "2001:db8::1".to_string(), + ..left + }; + assert_ne!(left.key(), right.key()); + } +} diff --git a/crates/openshell-prover/src/queries.rs b/crates/openshell-prover/src/queries.rs index 24e1402b7f..15def7795d 100644 --- a/crates/openshell-prover/src/queries.rs +++ b/crates/openshell-prover/src/queries.rs @@ -34,7 +34,6 @@ //! 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; @@ -73,7 +72,10 @@ fn is_link_local_or_metadata_host(host: &str) -> bool { /// 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 { +/// +/// Returns an error if the base model is invalid or a query is inconclusive. +pub fn check_credential_safety(model: &ReachabilityModel) -> miette::Result> { + model.validate()?; let mut reach_paths: Vec = Vec::new(); let mut capability_paths: Vec = Vec::new(); let mut bypass_paths: Vec = Vec::new(); @@ -87,8 +89,10 @@ pub fn check_credential_safety(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; + match model.check_sat(&expr) { + SatResult::Sat => {} + SatResult::Unsat => continue, + SatResult::Unknown => miette::bail!("reachability query inconclusive"), } let host_is_link_local = is_link_local_or_metadata_host(&eid.host); @@ -157,12 +161,9 @@ pub fn check_credential_safety(model: &ReachabilityModel) -> Vec { // 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, - ); + let methods = model.policy.network_policies[&eid.policy_name].endpoints + [eid.endpoint_index] + .allowed_methods(); for method in methods { capability_paths.push(ExfilPath { binary: bpath.clone(), @@ -243,7 +244,7 @@ pub fn check_credential_safety(model: &ReachabilityModel) -> Vec { ], )); } - findings + Ok(findings) } fn build_finding( @@ -268,34 +269,10 @@ fn build_finding( } /// Run all queries (single entry point for end-to-end callers). -pub fn run_all_queries(model: &ReachabilityModel) -> Vec { +pub fn run_all_queries(model: &ReachabilityModel) -> miette::Result> { check_credential_safety(model) } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Allowed HTTP methods for the endpoint in `policy.network_policies[policy_name]` -/// matching `(host, port)`. Returns empty when the rule or endpoint is not -/// found (e.g. SAT path threaded through a stale model). -fn endpoint_allowed_methods_in_rule( - policy: &crate::policy::PolicyModel, - policy_name: &str, - host: &str, - port: u16, -) -> HashSet { - let Some(rule) = policy.network_policies.get(policy_name) else { - return HashSet::new(); - }; - for ep in &rule.endpoints { - if ep.host.eq_ignore_ascii_case(host) && ep.effective_ports().contains(&port) { - return ep.allowed_methods(); - } - } - HashSet::new() -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index bf115370b4..aaface1a46 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -590,15 +590,12 @@ fn validation_result_for_agent_proposal( return "validation unavailable".to_string(); } }; - // If the baseline prover run fails (e.g. the current policy uses a shape - // the prover hasn't caught up to yet), fall back to an empty baseline so - // every merged finding surfaces as new. Safer to over-warn than miss a - // real regression introduced by the proposal. + // A delta requires conclusive results for both policies. let base_findings = match run_prover_findings(¤t_policy, credentials) { Ok(findings) => findings, Err(error) => { - warn!(error = %error, "prover baseline run failed; treating baseline as empty"); - Vec::new() + warn!(error = %error, "prover validation unavailable for baseline policy"); + return "validation unavailable".to_string(); } }; @@ -658,8 +655,8 @@ fn proposal_prover_result( let base_findings = match run_prover_findings(current_effective_policy, credentials) { Ok(findings) => findings, Err(error) => { - warn!(error = %error, "prover baseline run failed; treating baseline as empty"); - Vec::new() + warn!(error = %error, "prover validation unavailable for baseline policy"); + return "validation unavailable".to_string(); } }; let new_findings = finding_delta(&base_findings, &candidate_findings); @@ -686,7 +683,7 @@ fn compute_proposal_review_token( credentials: &CredentialSet, ) -> String { let mut hasher = Sha256::new(); - hasher.update(b"openshell-proposal-evaluator-v2\0"); + hasher.update(b"openshell-proposal-evaluator-v3\0"); hasher.update(rule_name.as_bytes()); hasher.update(canonical_rule_bytes(rule)); hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes()); @@ -724,7 +721,7 @@ fn compute_failed_proposal_evaluation_hash( application_error: &str, ) -> String { let mut hasher = Sha256::new(); - hasher.update(b"openshell-proposal-evaluator-v2-failed\0"); + hasher.update(b"openshell-proposal-evaluator-v3-failed\0"); hasher.update(rule_name.as_bytes()); hasher.update(canonical_rule_bytes(rule)); hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes()); @@ -927,7 +924,7 @@ fn run_prover_findings( 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)) + run_all_queries(&model).map_err(|e| format!("prover analysis failed: {e}")) } /// Build a `CredentialSet` for the sandbox by walking its attached providers. @@ -17443,6 +17440,260 @@ mod tests { ); } + #[tokio::test] + async fn sibling_endpoint_proposals_do_not_hide_new_findings_from_auto_approval() { + use openshell_core::proto::{L7Allow, NetworkEnforcementMode}; + + for (first, second, expected) in [ + ("GET", "*", "credential_reach_expansion"), + ("POST", "DELETE", "capability_expansion"), + ] { + for reverse in [false, true] { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("github-pat", "github")) + .await + .unwrap(); + let endpoint = |method: &str| NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: NetworkEnforcementMode::Enforce as i32, + rules: vec![L7Rule { + allow: Some(L7Allow { + method: method.to_string(), + path: "/**".to_string(), + ..Default::default() + }), + }], + ..Default::default() + }; + let mut rule = NetworkPolicyRule { + name: "github_split".to_string(), + endpoints: vec![endpoint(first), endpoint(second)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + }], + }; + let mut base = ProtoSandboxPolicy::default(); + if first == "POST" { + base.network_policies.insert( + "existing".to_string(), + NetworkPolicyRule { + name: "existing".to_string(), + endpoints: vec![endpoint("POST")], + binaries: rule.binaries.clone(), + }, + ); + } + if reverse { + rule.endpoints.reverse(); + } + let sandbox = test_sandbox( + "sb-siblings", + "siblings", + base, + vec!["github-pat".to_string()], + ); + state.store.put_message(&sandbox).await.unwrap(); + seed_sandbox_approval_mode(&state, "siblings", "auto").await; + let response = handle_submit_policy_analysis( + &state, + with_sandbox( + Request::new(SubmitPolicyAnalysisRequest { + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default", + )), + name: "siblings".to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: rule.name.clone(), + proposed_rule: Some(rule), + ..Default::default() + }], + ..Default::default() + }), + "sb-siblings", + ), + ) + .await + .unwrap() + .into_inner(); + assert_eq!( + response.accepted_chunks, 1, + "{:?}", + response.rejection_reasons + ); + let chunk = state + .store + .get_draft_chunk(&response.accepted_chunk_ids[0]) + .await + .unwrap() + .unwrap(); + assert!( + chunk.application_error.is_empty(), + "{}", + chunk.application_error + ); + assert!( + current_draft_chunk_security_notes(&chunk) + .unwrap() + .is_empty() + ); + assert!( + chunk.validation_result.contains(expected), + "{}", + chunk.validation_result + ); + if first == "POST" { + assert!(chunk.validation_result.contains("DELETE")); + } + assert_eq!(chunk.status, "pending"); + let active = current_base_policy_for_sandbox(state.store.as_ref(), &sandbox) + .await + .unwrap(); + assert!(!active.network_policies.contains_key("github_split")); + } + } + } + + #[tokio::test] + async fn auto_approval_refreshes_legacy_verdict_and_blocks_unavailable_validation() { + let state = test_server_state().await; + let sandbox = test_sandbox( + "sb-verdict-refresh", + "verdict-refresh", + ProtoSandboxPolicy::default(), + vec![], + ); + state.store.put_message(&sandbox).await.unwrap(); + let response = handle_submit_policy_analysis( + &state, + with_sandbox( + Request::new(SubmitPolicyAnalysisRequest { + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + name: "verdict-refresh".to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "example".to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: "example".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() + }], + ..Default::default() + }), + "sb-verdict-refresh", + ), + ) + .await + .unwrap() + .into_inner(); + let chunk_id = &response.accepted_chunk_ids[0]; + let mut chunk = state + .store + .get_draft_chunk(chunk_id) + .await + .unwrap() + .unwrap(); + let evaluation = + evaluate_stored_chunk_against_live_inputs(&state, "default", &sandbox, &chunk, None) + .await + .unwrap(); + + // Reconstruct a v2 token for unchanged inputs and no attached credentials. + let mut legacy = Sha256::new(); + legacy.update(b"openshell-proposal-evaluator-v2\0"); + legacy.update(evaluation.rule_name.as_bytes()); + legacy.update(canonical_rule_bytes(&evaluation.rule)); + legacy.update(evaluation.current_hash().as_bytes()); + legacy.update(evaluation.candidate_hash().as_bytes()); + chunk.review_token = hex::encode(legacy.finalize()); + chunk.validation_result = "prover: no new findings".to_string(); + assert!( + state + .store + .update_draft_chunk_evaluation(&chunk) + .await + .unwrap() + ); + let context = || AutoApproveChunkContext { + sandbox: &sandbox, + workspace: "default", + source: "agent_authored", + resolved_from: "sandbox", + }; + let error = auto_approve_chunk(&state, chunk_id, context()) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::FailedPrecondition); + let mut refreshed = state + .store + .get_draft_chunk(chunk_id) + .await + .unwrap() + .unwrap(); + assert_ne!(refreshed.review_token, chunk.review_token); + assert_eq!(refreshed.review_token, evaluation.review_token); + assert_eq!(refreshed.status, "pending"); + + refreshed.validation_result = "validation unavailable".to_string(); + assert!( + state + .store + .update_draft_chunk_evaluation(&refreshed) + .await + .unwrap() + ); + let error = auto_approve_chunk(&state, chunk_id, context()) + .await + .unwrap_err(); + assert!(error.message().contains("prover validation unavailable")); + let blocked = state + .store + .get_draft_chunk(chunk_id) + .await + .unwrap() + .unwrap(); + assert_eq!(blocked.status, "pending"); + assert!( + blocked + .application_error + .contains("prover validation unavailable") + ); + assert!( + current_base_policy_for_sandbox(state.store.as_ref(), &sandbox) + .await + .unwrap() + .network_policies + .is_empty() + ); + } + + #[test] + fn proposal_prover_errors_in_either_policy_block_the_delta() { + let valid = ProtoSandboxPolicy::default(); + let invalid = ProtoSandboxPolicy { + version: u32::MAX, + ..Default::default() + }; + for (base, candidate) in [(&valid, &invalid), (&invalid, &valid)] { + assert_eq!( + proposal_prover_result(base, candidate, &CredentialSet::default()), + "validation unavailable" + ); + } + } + /// `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` diff --git a/docs/how-it-works/policies/advisor.mdx b/docs/how-it-works/policies/advisor.mdx index 66e04d68cd..d2b8e38821 100644 --- a/docs/how-it-works/policies/advisor.mdx +++ b/docs/how-it-works/policies/advisor.mdx @@ -210,6 +210,9 @@ A proposal with any prover finding or flagged destination needs your review, even in automatic mode. When you reject a proposal, the agent receives the findings with your reason, so it can narrow its next attempt. +If either policy cannot be analyzed, the proposal shows `validation unavailable` +and cannot be approved until validation succeeds. + ## What Agents Can Propose An agent proposes endpoints and binaries. An endpoint can include an access diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 4b82af9514..b9fdbd59eb 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -673,6 +673,8 @@ openshell rule history dev Review the proposed scope, candidate hash, prover findings, and application errors before approval. Treat `rule approve-all --include-security-flagged` as a high-risk bulk action. +`validation unavailable` blocks approval until the gateway can analyze both the current and proposed policies. It is not an accepted-risk finding. + --- ## Workflow 5: BYOC (Bring Your Own Container)