@@ -605,6 +605,7 @@ fn summarize_draft_chunk_rule(chunk: &DraftChunkRecord) -> Result<String, Status
605605/// The string is the entire output — no taxonomy, no greppable prefixes; the
606606/// reviewer reads it like an OCSF shorthand line. One of:
607607///
608+ /// - `prover: unsupported MCP rules; manual review required`
608609/// - `prover: no new findings`
609610/// - `prover: N new finding(s)` followed by one ` <category>: <detail>`
610611/// line per finding path (categorical shorthand from `openshell-prover`)
@@ -634,37 +635,7 @@ fn validation_result_for_agent_proposal(
634635 return format!("policy invalid: {}", one_line(&error.to_string()));
635636 }
636637
637- let merged_findings = match run_prover_findings(&merged, credentials) {
638- Ok(findings) => findings,
639- Err(error) => {
640- warn!(error = %error, "prover validation unavailable for merged policy");
641- return "validation unavailable".to_string();
642- }
643- };
644- // A delta requires conclusive results for both policies.
645- let base_findings = match run_prover_findings(¤t_policy, credentials) {
646- Ok(findings) => findings,
647- Err(error) => {
648- warn!(error = %error, "prover validation unavailable for baseline policy");
649- return "validation unavailable".to_string();
650- }
651- };
652-
653- let new_findings = finding_delta(&base_findings, &merged_findings);
654- if new_findings.is_empty() {
655- return "prover: no new findings".to_string();
656- }
657- let count = new_findings.len();
658- let mut out = format!(
659- "prover: {} new finding{}",
660- count,
661- if count == 1 { "" } else { "s" }
662- );
663- for finding in &new_findings {
664- out.push_str("\n ");
665- out.push_str(&finding_shorthand(finding));
666- }
667- out
638+ proposal_prover_result(¤t_policy, &merged, credentials)
668639}
669640
670641#[derive(Debug)]
@@ -696,6 +667,14 @@ fn proposal_prover_result(
696667 candidate_effective_policy: &ProtoSandboxPolicy,
697668 credentials: &CredentialSet,
698669) -> String {
670+ if [current_effective_policy, candidate_effective_policy]
671+ .into_iter()
672+ .flat_map(|policy| policy.network_policies.values())
673+ .flat_map(|rule| &rule.endpoints)
674+ .any(|endpoint| endpoint.protocol.eq_ignore_ascii_case("mcp"))
675+ {
676+ return "prover: unsupported MCP rules; manual review required".to_string();
677+ }
699678 let candidate_findings = match run_prover_findings(candidate_effective_policy, credentials) {
700679 Ok(findings) => findings,
701680 Err(error) => {
@@ -734,7 +713,7 @@ fn compute_proposal_review_token(
734713 credentials: &CredentialSet,
735714) -> String {
736715 let mut hasher = Sha256::new();
737- hasher.update(b"openshell-proposal-evaluator-v3 \0");
716+ hasher.update(b"openshell-proposal-evaluator-v4 \0");
738717 hasher.update(rule_name.as_bytes());
739718 hasher.update(canonical_rule_bytes(rule));
740719 hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes());
@@ -772,7 +751,7 @@ fn compute_failed_proposal_evaluation_hash(
772751 application_error: &str,
773752) -> String {
774753 let mut hasher = Sha256::new();
775- hasher.update(b"openshell-proposal-evaluator-v3 -failed\0");
754+ hasher.update(b"openshell-proposal-evaluator-v4 -failed\0");
776755 hasher.update(rule_name.as_bytes());
777756 hasher.update(canonical_rule_bytes(rule));
778757 hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes());
@@ -1847,7 +1826,7 @@ async fn auto_approve_chunk(
18471826 validation_result = %validation_result,
18481827 source = %context.source,
18491828 resolved_from = %context.resolved_from,
1850- "Auto-approval skipped: current stored rule has prover findings "
1829+ "Auto-approval skipped: current stored rule requires human review "
18511830 );
18521831 return Ok(());
18531832 }
@@ -18979,6 +18958,171 @@ mod tests {
1897918958 }
1898018959 }
1898118960
18961+ #[test]
18962+ fn proposal_prover_mcp_rules_require_manual_review_on_either_side() {
18963+ let mcp = openshell_policy::parse_sandbox_policy(
18964+ r"
18965+ version: 1
18966+ network_policies:
18967+ mcp:
18968+ endpoints:
18969+ - host: mcp.example.com
18970+ port: 443
18971+ protocol: mcp
18972+ rules:
18973+ - allow:
18974+ method: tools/call
18975+ tool: read_public
18976+ binaries:
18977+ - path: /usr/bin/curl
18978+ ",
18979+ )
18980+ .unwrap();
18981+ let empty = ProtoSandboxPolicy::default();
18982+ for (base, candidate) in [(&empty, &mcp), (&mcp, &empty), (&mcp, &mcp)] {
18983+ assert_eq!(
18984+ proposal_prover_result(base, candidate, &CredentialSet::default()),
18985+ "prover: unsupported MCP rules; manual review required"
18986+ );
18987+ }
18988+ }
18989+
18990+ #[tokio::test]
18991+ async fn mcp_proposals_refresh_legacy_clean_verdict_and_require_manual_approval() {
18992+ let state = test_server_state().await;
18993+ let sandbox = test_sandbox(
18994+ "sb-mcp-review",
18995+ "mcp-review",
18996+ ProtoSandboxPolicy::default(),
18997+ vec![],
18998+ );
18999+ state.store.put_message(&sandbox).await.unwrap();
19000+ seed_sandbox_approval_mode(&state, "mcp-review", "auto").await;
19001+ let policy = openshell_policy::parse_sandbox_policy(
19002+ r"
19003+ version: 1
19004+ network_policies:
19005+ mcp:
19006+ endpoints:
19007+ - host: mcp.example.com
19008+ port: 443
19009+ protocol: mcp
19010+ rules:
19011+ - allow:
19012+ method: tools/call
19013+ tool: read_public
19014+ binaries:
19015+ - path: /usr/bin/curl
19016+ ",
19017+ )
19018+ .unwrap();
19019+ let response = handle_submit_policy_analysis(
19020+ &state,
19021+ with_user(Request::new(SubmitPolicyAnalysisRequest {
19022+ workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
19023+ name: "mcp-review".to_string(),
19024+ analysis_mode: "agent_authored".to_string(),
19025+ proposed_chunks: vec![PolicyChunk {
19026+ rule_name: "mcp".to_string(),
19027+ proposed_rule: Some(policy.network_policies["mcp"].clone()),
19028+ ..Default::default()
19029+ }],
19030+ ..Default::default()
19031+ })),
19032+ )
19033+ .await
19034+ .unwrap()
19035+ .into_inner();
19036+ let chunk_id = &response.accepted_chunk_ids[0];
19037+ let mut chunk = state
19038+ .store
19039+ .get_draft_chunk(chunk_id)
19040+ .await
19041+ .unwrap()
19042+ .unwrap();
19043+ assert_eq!(chunk.status, "pending");
19044+ assert_eq!(
19045+ chunk.validation_result,
19046+ "prover: unsupported MCP rules; manual review required"
19047+ );
19048+ assert!(chunk.application_error.is_empty());
19049+ let evaluation =
19050+ evaluate_stored_chunk_against_live_inputs(&state, "default", &sandbox, &chunk, None)
19051+ .await
19052+ .unwrap();
19053+ let mut legacy = Sha256::new();
19054+ legacy.update(b"openshell-proposal-evaluator-v3\0");
19055+ legacy.update(evaluation.rule_name.as_bytes());
19056+ legacy.update(canonical_rule_bytes(&evaluation.rule));
19057+ legacy.update(evaluation.current_hash().as_bytes());
19058+ legacy.update(evaluation.candidate_hash().as_bytes());
19059+ chunk.review_token = hex::encode(legacy.finalize());
19060+ chunk.validation_result = "prover: no new findings".to_string();
19061+ assert!(
19062+ state
19063+ .store
19064+ .update_draft_chunk_evaluation(&chunk)
19065+ .await
19066+ .unwrap()
19067+ );
19068+ let error = auto_approve_chunk(
19069+ &state,
19070+ chunk_id,
19071+ AutoApproveChunkContext {
19072+ sandbox: &sandbox,
19073+ workspace: "default",
19074+ source: "agent_authored",
19075+ resolved_from: "sandbox",
19076+ },
19077+ )
19078+ .await
19079+ .unwrap_err();
19080+ assert_eq!(error.code(), Code::FailedPrecondition);
19081+ let refreshed = state
19082+ .store
19083+ .get_draft_chunk(chunk_id)
19084+ .await
19085+ .unwrap()
19086+ .unwrap();
19087+ assert_ne!(refreshed.review_token, chunk.review_token);
19088+ assert_eq!(refreshed.status, "pending");
19089+ assert_eq!(
19090+ refreshed.validation_result,
19091+ "prover: unsupported MCP rules; manual review required"
19092+ );
19093+ assert!(
19094+ current_base_policy_for_sandbox(state.store.as_ref(), &sandbox)
19095+ .await
19096+ .unwrap()
19097+ .network_policies
19098+ .is_empty()
19099+ );
19100+ handle_approve_draft_chunk(
19101+ &state,
19102+ with_user(Request::new(ApproveDraftChunkRequest {
19103+ sandbox: "mcp-review".to_string(),
19104+ workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
19105+ chunk_id: chunk_id.clone(),
19106+ review_token: refreshed.review_token,
19107+ ..Default::default()
19108+ })),
19109+ )
19110+ .await
19111+ .unwrap();
19112+ let approved = current_base_policy_for_sandbox(state.store.as_ref(), &sandbox)
19113+ .await
19114+ .unwrap();
19115+ assert_eq!(
19116+ approved.network_policies["mcp"].endpoints[0].rules[0]
19117+ .allow
19118+ .as_ref()
19119+ .unwrap()
19120+ .params["name"]
19121+ .glob,
19122+ "read_public"
19123+ );
19124+ }
19125+
1898219126 /// `protocol: rest, access: full` on a host where the binary had no
1898319127 /// prior credentialed reach: the prover emits
1898419128 /// `credential_reach_expansion`. (The per-method `capability_expansion`
0 commit comments