Skip to content

Commit c021ec0

Browse files
committed
fix(policy): require manual review for unmodeled MCP rules
Signed-off-by: Shiju <shiju@nvidia.com>
1 parent ee79082 commit c021ec0

4 files changed

Lines changed: 317 additions & 49 deletions

File tree

‎crates/openshell-server/src/grpc/policy.rs‎

Lines changed: 178 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -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(&current_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(&current_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`

‎crates/openshell-supervisor-process/src/skills/policy_advisor.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ agent's `/wait` returns approved in ~1 second. When the prover does
146146
find something — or the setting is `"manual"`/unset — the chunk lands
147147
in `pending` for human review.
148148

149+
MCP tool permissions are not modeled by the prover. A policy containing MCP
150+
rules reports `prover: unsupported MCP rules; manual review required` and stays
151+
pending for human approval, even in automatic mode.
152+
149153
The prover answers four formal questions about each proposed change.
150154
Each "yes" answer is its own categorical finding — there is no
151155
severity grade. Any finding blocks auto-approval.

0 commit comments

Comments
 (0)