mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
fix(mcp): explain revision-scoped policy and rejections (#3850)
* fix(mcp): explain revision-scoped policy and rejections Explain the selected-revision method set in profile output and policy docs. Distinguish protocol and policy rejection causes and give a next step while preserving authorization, response statuses, error codes and YAML keys. Cover CLI serialization, revision selection, exact extension rules, deny precedence and rejection before forwarding with focused regressions. Signed-off-by: Shiju <shiju@nvidia.com> * docs(mcp): correct HTTP cancellation revision support Limit notifications/cancelled to the three 2025 revisions in the core method matrix. State that MCP 2026-07-28 HTTP cancellation closes the response stream, matching the runtime rejection and sessionless docs. Signed-off-by: Shiju <shiju@nvidia.com> --------- Signed-off-by: Shiju <shiju@nvidia.com>
This commit is contained in:
@@ -2566,7 +2566,8 @@ fn format_provider_profile_details(profile: &ProviderProfile) -> String {
|
||||
let access = match network_access_preset_to_str(endpoint.access) {
|
||||
Some("") if !endpoint.rules.is_empty() => "custom rules".to_string(),
|
||||
Some("") if is_mcp && allow_all_known_mcp_methods == Some(true) => {
|
||||
"all known MCP methods (subject to tool and deny rules)".to_string()
|
||||
"core MCP methods for the selected revision (subject to tool and deny rules)"
|
||||
.to_string()
|
||||
}
|
||||
Some("") => "not specified".to_string(),
|
||||
Some(access) => access.to_string(),
|
||||
@@ -2601,9 +2602,26 @@ fn format_provider_profile_details(profile: &ProviderProfile) -> String {
|
||||
|| "not specified".to_string(),
|
||||
|options| options.versions.join(", "),
|
||||
);
|
||||
let _ = writeln!(rendered, " Allow all known MCP methods: {methods}");
|
||||
let _ = writeln!(
|
||||
rendered,
|
||||
" Allow core MCP methods for the selected revision: {methods}"
|
||||
);
|
||||
if allow_all_known_mcp_methods == Some(true) {
|
||||
rendered
|
||||
.push_str(" Tool restrictions still apply; deny rules take precedence.\n");
|
||||
rendered.push_str(
|
||||
" Without tool-specific allow rules, all tool names are allowed.\n",
|
||||
);
|
||||
}
|
||||
rendered.push_str(" Extension methods: require an exact allow rule\n");
|
||||
let _ = writeln!(rendered, " Strict MCP tool names: {strict_names}");
|
||||
let _ = writeln!(rendered, " MCP versions (declared): {versions}");
|
||||
rendered.push_str(
|
||||
" Revision selection: MCP-Protocol-Version header; 2025-03-26 when absent\n",
|
||||
);
|
||||
rendered.push_str(
|
||||
" Legacy initialize requests negotiate their revision in the body.\n",
|
||||
);
|
||||
}
|
||||
if !endpoint.allowed_ips.is_empty() {
|
||||
let _ = writeln!(
|
||||
@@ -3143,11 +3161,39 @@ binaries: [/usr/bin/curl]
|
||||
let rendered = format_provider_profile_details(&proto);
|
||||
assert!(
|
||||
rendered
|
||||
.contains("Access: all known MCP methods (subject to tool and deny rules)\n")
|
||||
.contains("Access: core MCP methods for the selected revision (subject to tool and deny rules)\n")
|
||||
);
|
||||
assert!(rendered.contains("Allow all known MCP methods: true\n"));
|
||||
assert!(rendered.contains("Allow core MCP methods for the selected revision: true\n"));
|
||||
assert!(
|
||||
rendered.contains("Tool restrictions still apply; deny rules take precedence.")
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Without tool-specific allow rules, all tool names are allowed.")
|
||||
);
|
||||
assert!(rendered.contains("Extension methods: require an exact allow rule\n"));
|
||||
assert!(rendered.contains("Strict MCP tool names: true (default)\n"));
|
||||
assert!(rendered.contains("MCP versions (declared): 2025-11-25\n"));
|
||||
assert!(rendered.contains(
|
||||
"Revision selection: MCP-Protocol-Version header; 2025-03-26 when absent\n"
|
||||
));
|
||||
assert!(
|
||||
rendered
|
||||
.contains("Legacy initialize requests negotiate their revision in the body.\n")
|
||||
);
|
||||
|
||||
for output in ["json", "yaml"] {
|
||||
let structured = format_provider_profile_description(&proto, output)
|
||||
.expect("structured description renders");
|
||||
let roundtrip = if output == "json" {
|
||||
parse_profile_json(&structured)
|
||||
} else {
|
||||
parse_profile_yaml(&structured)
|
||||
}
|
||||
.expect("structured description is a full profile document");
|
||||
assert_eq!(roundtrip, ProviderTypeProfile::from_proto(&proto));
|
||||
assert!(structured.contains("allow_all_known_mcp_methods"));
|
||||
assert!(!structured.contains("Allow core MCP methods"));
|
||||
}
|
||||
|
||||
// Explicit rules remain relevant when the method default is enabled,
|
||||
// and independent tool-name validation must not disappear from view.
|
||||
@@ -3163,7 +3209,13 @@ binaries: [/usr/bin/curl]
|
||||
options.strict_tool_names = Some(false);
|
||||
let rendered = format_provider_profile_details(&proto);
|
||||
assert!(rendered.contains("Access: custom rules\n Rules: 1 allow, 0 deny\n"));
|
||||
assert!(rendered.contains(&format!("Allow all known MCP methods: {allow_all}\n")));
|
||||
assert!(rendered.contains(&format!(
|
||||
"Allow core MCP methods for the selected revision: {allow_all}\n"
|
||||
)));
|
||||
assert_eq!(
|
||||
rendered.contains("Tool restrictions still apply"),
|
||||
allow_all
|
||||
);
|
||||
assert!(rendered.contains("Strict MCP tool names: false\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,11 +424,66 @@ request_deny_reason := reason if {
|
||||
reason := "JSON-RPC response frames are not permitted from client to server"
|
||||
}
|
||||
|
||||
# Explain only parsed MCP calls on an endpoint that matches this request path.
|
||||
# The relay evaluates batch members separately. Response frames and protocol
|
||||
# errors keep their own diagnostics, and a sibling endpoint must not select
|
||||
# the explanation merely because it shares the connection's host and port.
|
||||
mcp_policy_request if {
|
||||
input.request.method == "POST"
|
||||
not is_object(object.get(input.request, "graphql", null))
|
||||
not jsonrpc_response_frame_present(input.request)
|
||||
jsonrpc := object.get(input.request, "jsonrpc", null)
|
||||
is_object(jsonrpc)
|
||||
jsonrpc_no_parse_error(jsonrpc)
|
||||
method := object.get(jsonrpc, "method", "")
|
||||
is_string(method)
|
||||
method != ""
|
||||
object.get(jsonrpc, "mcp_method_classification", "") in {"available", "extension"}
|
||||
endpoint := _matching_endpoint_configs[_]
|
||||
endpoint.protocol == "mcp"
|
||||
endpoint_path_matches_request(endpoint, input.request)
|
||||
}
|
||||
|
||||
# These reasons use fixed text because method names and tool parameters can
|
||||
# contain caller data. Deny rules take precedence over missing allow rules.
|
||||
request_deny_reason := reason if {
|
||||
mcp_policy_request
|
||||
deny_request
|
||||
reason := "MCP request blocked by a deny rule; ask the policy owner to review deny_rules and tool selectors"
|
||||
}
|
||||
|
||||
request_deny_reason := reason if {
|
||||
mcp_policy_request
|
||||
not deny_request
|
||||
not allow_request
|
||||
input.request.jsonrpc.mcp_method_classification == "extension"
|
||||
reason := "MCP extension method has no matching exact allow rule; ask the policy owner to review rules with an exact method name and any parameter restrictions; allow_all_known_mcp_methods does not allow extensions"
|
||||
}
|
||||
|
||||
request_deny_reason := reason if {
|
||||
mcp_policy_request
|
||||
not deny_request
|
||||
not allow_request
|
||||
input.request.jsonrpc.mcp_method_classification == "available"
|
||||
input.request.jsonrpc.method == "tools/call"
|
||||
reason := "MCP tool call has no matching allow rule; ask the policy owner to review rules and tool selectors"
|
||||
}
|
||||
|
||||
request_deny_reason := reason if {
|
||||
mcp_policy_request
|
||||
not deny_request
|
||||
not allow_request
|
||||
input.request.jsonrpc.mcp_method_classification == "available"
|
||||
input.request.jsonrpc.method != "tools/call"
|
||||
reason := "MCP core method is not permitted by policy; ask the policy owner to review rules for this method in the selected MCP revision"
|
||||
}
|
||||
|
||||
request_deny_reason := reason if {
|
||||
input.request
|
||||
deny_request
|
||||
not graphql_request_has_operations(input.request)
|
||||
not jsonrpc_response_frame_present(input.request)
|
||||
not mcp_policy_request
|
||||
reason := sprintf("%s %s blocked by deny rule", [input.request.method, input.request.path])
|
||||
}
|
||||
|
||||
@@ -438,6 +493,7 @@ request_deny_reason := reason if {
|
||||
not allow_request
|
||||
not graphql_request_has_operations(input.request)
|
||||
not jsonrpc_response_frame_present(input.request)
|
||||
not mcp_policy_request
|
||||
reason := sprintf("%s %s not permitted by policy", [input.request.method, input.request.path])
|
||||
}
|
||||
|
||||
|
||||
@@ -814,10 +814,7 @@ fn parse_mcp_payload(
|
||||
TowerMcpMethodClassification::Unavailable => {
|
||||
return JsonRpcRequestInfo::rejected(
|
||||
inspection.payload().is_batch(),
|
||||
JsonRpcInspectionError::mcp_profile_violation(format!(
|
||||
"MCP {revision} does not make `{}` available",
|
||||
method.method()
|
||||
)),
|
||||
unavailable_mcp_method_error(revision, method.method()),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
@@ -944,10 +941,7 @@ pub(crate) fn inspect_mcp_payload_for_revision(
|
||||
for method in inspection.methods() {
|
||||
match method.classification() {
|
||||
TowerMcpMethodClassification::Unavailable => {
|
||||
return Err(JsonRpcInspectionError::mcp_profile_violation(format!(
|
||||
"MCP {revision} does not make `{}` available",
|
||||
method.method()
|
||||
)));
|
||||
return Err(unavailable_mcp_method_error(revision, method.method()));
|
||||
}
|
||||
TowerMcpMethodClassification::Available | TowerMcpMethodClassification::Extension => {}
|
||||
_ => {
|
||||
@@ -962,6 +956,17 @@ pub(crate) fn inspect_mcp_payload_for_revision(
|
||||
Ok(inspection)
|
||||
}
|
||||
|
||||
// Only a method recognized by Tower's closed core-method registry reaches
|
||||
// this diagnostic. Policy permission cannot make it valid in another revision.
|
||||
fn unavailable_mcp_method_error(
|
||||
revision: McpProtocolVersion,
|
||||
method: &str,
|
||||
) -> JsonRpcInspectionError {
|
||||
JsonRpcInspectionError::mcp_profile_violation(format!(
|
||||
"MCP method `{method}` is unavailable in revision {revision}; use a method defined by this core revision, or check client/server support and mcp.versions before selecting another revision; allow rules and allow_all_known_mcp_methods cannot enable an unavailable method"
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_mcp_initialize(payload: JsonRpcPayload) -> JsonRpcRequestInfo {
|
||||
if payload.is_batch() {
|
||||
return JsonRpcRequestInfo::rejected(
|
||||
@@ -1080,10 +1085,18 @@ fn mcp_named_request_for_inspected_method(
|
||||
|
||||
fn map_mcp_inspection_error(error: McpInspectionError) -> JsonRpcInspectionError {
|
||||
match error.kind() {
|
||||
McpInspectionErrorKind::BatchUnavailable
|
||||
| McpInspectionErrorKind::DirectionMismatch
|
||||
| McpInspectionErrorKind::UnsupportedProfile => {
|
||||
JsonRpcInspectionError::mcp_profile_violation(error.to_string())
|
||||
McpInspectionErrorKind::BatchUnavailable => JsonRpcInspectionError::mcp_profile_violation(
|
||||
format!("{error}; send each JSON-RPC message in a separate request for this revision"),
|
||||
),
|
||||
McpInspectionErrorKind::DirectionMismatch => {
|
||||
JsonRpcInspectionError::mcp_profile_violation(format!(
|
||||
"{error}; send the method from the peer role defined by this revision; an allow rule cannot change its direction"
|
||||
))
|
||||
}
|
||||
McpInspectionErrorKind::UnsupportedProfile => {
|
||||
JsonRpcInspectionError::mcp_profile_violation(format!(
|
||||
"{error}; use an MCP revision supported by this OpenShell build"
|
||||
))
|
||||
}
|
||||
McpInspectionErrorKind::InitializeInBatch => {
|
||||
JsonRpcInspectionError::mcp_lifecycle_violation(error.to_string())
|
||||
@@ -1889,12 +1902,18 @@ mod tests {
|
||||
info.error.as_ref().map(JsonRpcInspectionError::kind),
|
||||
Some(JsonRpcInspectionErrorKind::McpProfileViolation)
|
||||
);
|
||||
let detail = info.error.as_ref().expect("unavailable method").detail();
|
||||
assert!(detail.contains("`tasks/get` is unavailable in revision 2025-06-18"));
|
||||
assert!(detail.contains("check client/server support and mcp.versions"));
|
||||
assert!(detail.contains("allow_all_known_mcp_methods cannot enable"));
|
||||
assert!(
|
||||
info.error
|
||||
.as_ref()
|
||||
.is_some_and(|error| error.detail().contains("tasks/get")),
|
||||
"expected unavailable-method evidence, got {info:?}"
|
||||
!detail.contains("task-1"),
|
||||
"task params must not be reflected"
|
||||
);
|
||||
|
||||
let november =
|
||||
parse_jsonrpc_body_with_options(body, mcp_options(McpProtocolVersion::V2025_11_25));
|
||||
assert!(november.error.is_none(), "November task remains valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1919,6 +1938,11 @@ mod tests {
|
||||
.is_some_and(|error| error.detail().contains("client-to-server")),
|
||||
"expected direction mismatch evidence, got {info:?}"
|
||||
);
|
||||
assert!(info.error.as_ref().is_some_and(|error| {
|
||||
error
|
||||
.detail()
|
||||
.contains("an allow rule cannot change its direction")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -39,6 +39,20 @@ pub(super) enum McpProtocolVersionError {
|
||||
}
|
||||
|
||||
impl McpProtocolVersionError {
|
||||
/// Explain a rejected revision using validated header metadata.
|
||||
///
|
||||
/// Missing headers select the 2025-03-26 fallback. Only the validated parser
|
||||
/// may identify that case; duplicate or hop-by-hop headers must keep their
|
||||
/// own rejection reason without being described as absent.
|
||||
pub(super) fn rejection_detail(self, request: &L7Request) -> String {
|
||||
match self {
|
||||
Self::NotAllowed(version) => {
|
||||
format!("{self}; {}", selected_revision_context(request, version))
|
||||
}
|
||||
_ => self.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the HTTP status for this transport or policy rejection.
|
||||
#[must_use]
|
||||
pub(super) const fn http_status(self) -> &'static str {
|
||||
@@ -68,20 +82,20 @@ impl std::fmt::Display for McpProtocolVersionError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidHeader => formatter.write_str(
|
||||
"MCP-Protocol-Version must contain one non-empty end-to-end header value",
|
||||
"MCP-Protocol-Version must contain one non-empty end-to-end header value; send exactly one revision, without duplicates, comma-separated values, or Connection nomination",
|
||||
),
|
||||
Self::UnsupportedHeaderValue => {
|
||||
formatter.write_str("MCP-Protocol-Version names an unsupported protocol version")
|
||||
formatter.write_str("MCP-Protocol-Version names a revision unsupported by this OpenShell build; use a supported client/server revision permitted by mcp.versions")
|
||||
}
|
||||
Self::InvalidRequestMetadata => {
|
||||
formatter.write_str("MCP request headers must match the inspected request metadata")
|
||||
formatter.write_str("MCP request headers must match the inspected request metadata; send MCP-Protocol-Version, Mcp-Method, and any required Mcp-Name consistently with the 2026-07-28 message")
|
||||
}
|
||||
Self::MethodNotAllowed => {
|
||||
formatter.write_str("MCP protocol version 2026-07-28 requires HTTP POST")
|
||||
formatter.write_str("MCP protocol version 2026-07-28 requires HTTP POST; send a POST message instead of a legacy GET stream or DELETE session request")
|
||||
}
|
||||
Self::NotAllowed(version) => write!(
|
||||
formatter,
|
||||
"MCP protocol version {version} is not allowed by endpoint policy"
|
||||
"MCP protocol version {version} is not allowed by endpoint policy; use a client/server revision permitted by mcp.versions"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -89,6 +103,23 @@ impl std::fmt::Display for McpProtocolVersionError {
|
||||
|
||||
impl std::error::Error for McpProtocolVersionError {}
|
||||
|
||||
/// Describe how the request selected its MCP revision.
|
||||
///
|
||||
/// Initial and post-middleware inspection supply their current request so the
|
||||
/// explanation describes the headers used for that selection.
|
||||
pub(super) fn selected_revision_context(
|
||||
request: &L7Request,
|
||||
version: McpProtocolVersion,
|
||||
) -> String {
|
||||
match request_protocol_version_header(&request.raw_header) {
|
||||
Ok(None) => format!(
|
||||
"selected MCP revision {version} from the missing MCP-Protocol-Version header fallback; send the client/server revision explicitly in that header"
|
||||
),
|
||||
Ok(Some(_)) => format!("selected MCP revision {version} from MCP-Protocol-Version"),
|
||||
Err(_) => format!("selected MCP revision {version}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select and authorize the protocol revision for one MCP HTTP request.
|
||||
///
|
||||
/// Legacy initialization negotiates its revision in the body. It cannot exempt
|
||||
|
||||
@@ -224,7 +224,10 @@ where
|
||||
// even when the caller disconnects before receiving it.
|
||||
observer.observe(EndpointResult::PolicyDenied);
|
||||
}
|
||||
let reason = error.to_string();
|
||||
let reason = format!(
|
||||
"{error}; {}",
|
||||
crate::l7::mcp::selected_revision_context(request, version)
|
||||
);
|
||||
let summary = l7_protocol_log_summary(None, Some(&info));
|
||||
ocsf_emit!(build_l7_request_event(
|
||||
ctx,
|
||||
@@ -305,7 +308,7 @@ where
|
||||
// Record the denial before client delivery can fail.
|
||||
observer.observe(EndpointResult::PolicyDenied);
|
||||
}
|
||||
let reason = error.to_string();
|
||||
let reason = error.rejection_detail(request);
|
||||
let summary = l7_protocol_log_summary(None, Some(info));
|
||||
ocsf_emit!(build_l7_request_event(
|
||||
ctx,
|
||||
@@ -10992,23 +10995,31 @@ network_policies:
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_relay_rejects_invalid_disallowed_and_missing_versions_without_forwarding() {
|
||||
for (headers, status, code) in [
|
||||
for (headers, status, code, remedy) in [
|
||||
(
|
||||
"MCP-Protocol-Version: 2026-07-29\r\n",
|
||||
"400 Bad Request",
|
||||
"unsupported_mcp_protocol_version",
|
||||
"use a supported client/server revision permitted by mcp.versions",
|
||||
),
|
||||
(
|
||||
"MCP-Protocol-Version: 2025-11-25\r\nMCP-Protocol-Version: 2025-11-25\r\n",
|
||||
"400 Bad Request",
|
||||
"invalid_mcp_protocol_version_header",
|
||||
"send exactly one revision",
|
||||
),
|
||||
(
|
||||
"MCP-Protocol-Version: 2025-06-18\r\n",
|
||||
"403 Forbidden",
|
||||
"mcp_protocol_version_not_allowed",
|
||||
"selected MCP revision 2025-06-18 from MCP-Protocol-Version",
|
||||
),
|
||||
(
|
||||
"",
|
||||
"403 Forbidden",
|
||||
"mcp_protocol_version_not_allowed",
|
||||
"missing MCP-Protocol-Version header fallback; send the client/server revision explicitly",
|
||||
),
|
||||
("", "403 Forbidden", "mcp_protocol_version_not_allowed"),
|
||||
] {
|
||||
let (response, forwarded) = run_rejected_mcp_request(
|
||||
false,
|
||||
@@ -11021,6 +11032,7 @@ network_policies:
|
||||
"{response}"
|
||||
);
|
||||
assert!(response.contains(code), "{response}");
|
||||
assert!(response.contains(remedy), "{response}");
|
||||
assert!(forwarded.is_empty(), "rejected request reached upstream");
|
||||
}
|
||||
}
|
||||
@@ -11506,12 +11518,40 @@ network_policies:
|
||||
response.contains("does not permit top-level JSON-RPC batches"),
|
||||
"{response}"
|
||||
);
|
||||
assert!(response.contains("send each JSON-RPC message in a separate request"));
|
||||
assert!(response.contains("selected MCP revision 2025-11-25 from MCP-Protocol-Version"));
|
||||
assert!(
|
||||
forwarded.is_empty(),
|
||||
"profile-invalid request reached upstream"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_relay_explains_unavailable_method_without_reflecting_params() {
|
||||
// tasks/update is known to the parser but absent from the selected
|
||||
// core revision. The diagnostic must not suggest adding an allow rule.
|
||||
let body = br#"{"jsonrpc":"2.0","id":1,"method":"tasks/update","params":{"taskId":"private-task-marker","inputResponses":{"secret":"private-argument-marker"}}}"#;
|
||||
for route_selected in [false, true] {
|
||||
let (response, forwarded) = run_rejected_mcp_request(
|
||||
route_selected,
|
||||
"MCP-Protocol-Version: 2025-11-25\r\n",
|
||||
body,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 400 Bad Request"),
|
||||
"{response}"
|
||||
);
|
||||
assert!(response.contains("invalid_mcp_request"), "{response}");
|
||||
assert!(response.contains("`tasks/update` is unavailable in revision 2025-11-25"));
|
||||
assert!(response.contains("allow_all_known_mcp_methods cannot enable"));
|
||||
assert!(response.contains("from MCP-Protocol-Version"));
|
||||
assert!(!response.contains("private-task-marker"));
|
||||
assert!(!response.contains("private-argument-marker"));
|
||||
assert!(forwarded.is_empty(), "unavailable method reached upstream");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_mcp_version_check_reclassifies_a_rewritten_initialize_body() {
|
||||
let (config, _, ctx) = mcp_test_relay_context();
|
||||
|
||||
@@ -4925,6 +4925,31 @@ process:
|
||||
val == regorus::Value::from(true)
|
||||
}
|
||||
|
||||
fn assert_l7_denial(
|
||||
engine: &OpaEngine,
|
||||
input: &serde_json::Value,
|
||||
deny_rule_matches: bool,
|
||||
expected_reason: &str,
|
||||
) {
|
||||
let mut eng = engine.engine.lock().unwrap();
|
||||
set_regorus_input(&mut eng, input.clone()).unwrap();
|
||||
assert_eq!(
|
||||
eng.eval_rule("data.openshell.sandbox.allow_request".into())
|
||||
.expect("evaluate allow_request"),
|
||||
regorus::Value::from(false),
|
||||
);
|
||||
assert_eq!(
|
||||
eng.eval_rule("data.openshell.sandbox.deny_request".into())
|
||||
.expect("evaluate deny_request"),
|
||||
regorus::Value::from(deny_rule_matches),
|
||||
);
|
||||
assert_eq!(
|
||||
eng.eval_rule("data.openshell.sandbox.request_deny_reason".into())
|
||||
.expect("evaluate one unambiguous denial reason"),
|
||||
regorus::Value::from(expected_reason),
|
||||
);
|
||||
}
|
||||
|
||||
fn eval_l7_raw_data(data: serde_json::Value, input: serde_json::Value) -> bool {
|
||||
let mut engine = regorus::Engine::new();
|
||||
engine
|
||||
@@ -6271,10 +6296,34 @@ network_policies:
|
||||
"tools/call",
|
||||
serde_json::json!({"name": "blocked_action"}),
|
||||
);
|
||||
assert!(!eval_l7(&engine, &blocked));
|
||||
assert_l7_denial(
|
||||
&engine,
|
||||
&blocked,
|
||||
true,
|
||||
"MCP request blocked by a deny rule; ask the policy owner to review deny_rules and tool selectors",
|
||||
);
|
||||
|
||||
let unmatched_tool = l7_jsonrpc_input_with_params(
|
||||
"mcp.params.test",
|
||||
8000,
|
||||
"/mcp",
|
||||
"tools/call",
|
||||
serde_json::json!({"name": "private_tool_name", "arguments.secret": "private-value"}),
|
||||
);
|
||||
assert_l7_denial(
|
||||
&engine,
|
||||
&unmatched_tool,
|
||||
false,
|
||||
"MCP tool call has no matching allow rule; ask the policy owner to review rules and tool selectors",
|
||||
);
|
||||
|
||||
let list_tools = l7_jsonrpc_input("mcp.params.test", 8000, "/mcp", "tools/list");
|
||||
assert!(!eval_l7(&engine, &list_tools));
|
||||
assert_l7_denial(
|
||||
&engine,
|
||||
&list_tools,
|
||||
false,
|
||||
"MCP core method is not permitted by policy; ask the policy owner to review rules for this method in the selected MCP revision",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6311,12 +6360,19 @@ network_policies:
|
||||
let list_tools = l7_jsonrpc_input("mcp.default.test", 8000, "/mcp", "tools/list");
|
||||
assert!(eval_l7(&engine, &list_tools));
|
||||
|
||||
let mut extension = l7_jsonrpc_input("mcp.default.test", 8000, "/mcp", "vendor/extension");
|
||||
let mut extension = l7_jsonrpc_input(
|
||||
"mcp.default.test",
|
||||
8000,
|
||||
"/mcp",
|
||||
"vendor/private_method_name",
|
||||
);
|
||||
extension["request"]["jsonrpc"]["mcp_method_classification"] =
|
||||
serde_json::json!("extension");
|
||||
assert!(
|
||||
!eval_l7(&engine, &extension),
|
||||
"allow_all_known_mcp_methods must cover only selected-profile methods"
|
||||
assert_l7_denial(
|
||||
&engine,
|
||||
&extension,
|
||||
false,
|
||||
"MCP extension method has no matching exact allow rule; ask the policy owner to review rules with an exact method name and any parameter restrictions; allow_all_known_mcp_methods does not allow extensions",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6382,20 +6438,108 @@ network_policies:
|
||||
l7_jsonrpc_input("mcp.extension-wildcard.test", 8000, "/mcp", "tools/vendor");
|
||||
wildcard["request"]["jsonrpc"]["mcp_method_classification"] =
|
||||
serde_json::json!("extension");
|
||||
assert!(
|
||||
!eval_l7(&engine, &wildcard),
|
||||
"wildcards must not authorize unknown extension methods"
|
||||
assert_l7_denial(
|
||||
&engine,
|
||||
&wildcard,
|
||||
false,
|
||||
"MCP extension method has no matching exact allow rule; ask the policy owner to review rules with an exact method name and any parameter restrictions; allow_all_known_mcp_methods does not allow extensions",
|
||||
);
|
||||
|
||||
let mut denied =
|
||||
l7_jsonrpc_input("mcp.extension-denied.test", 8000, "/mcp", "tools/vendor");
|
||||
denied["request"]["jsonrpc"]["mcp_method_classification"] = serde_json::json!("extension");
|
||||
assert!(
|
||||
!eval_l7(&engine, &denied),
|
||||
"deny-rule wildcards must still block explicitly allowed extensions"
|
||||
assert_l7_denial(
|
||||
&engine,
|
||||
&denied,
|
||||
true,
|
||||
"MCP request blocked by a deny rule; ask the policy owner to review deny_rules and tool selectors",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l7_mcp_denial_reason_matches_request_path_and_protocol() {
|
||||
let data = r#"
|
||||
network_policies:
|
||||
mixed:
|
||||
name: mixed
|
||||
endpoints:
|
||||
- host: mcp.reasons.test
|
||||
port: 8000
|
||||
path: /rpc
|
||||
protocol: json-rpc
|
||||
enforcement: enforce
|
||||
rules: [{allow: {method: reports.list}}]
|
||||
- host: mcp.reasons.test
|
||||
port: 8000
|
||||
path: /rest
|
||||
protocol: rest
|
||||
enforcement: enforce
|
||||
rules: [{allow: {method: GET, path: /rest}}]
|
||||
- host: mcp.reasons.test
|
||||
port: 8000
|
||||
path: /mcp
|
||||
protocol: mcp
|
||||
enforcement: enforce
|
||||
mcp:
|
||||
allow_all_known_mcp_methods: true
|
||||
rules: [{allow: {tool: read_status}}]
|
||||
binaries:
|
||||
- {path: /usr/bin/curl}
|
||||
"#;
|
||||
let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml");
|
||||
|
||||
// Select the explanation by the current request path, not the first
|
||||
// configured endpoint for this host and port.
|
||||
let unmatched_tool = l7_jsonrpc_input_with_params(
|
||||
"mcp.reasons.test",
|
||||
8000,
|
||||
"/mcp",
|
||||
"tools/call",
|
||||
serde_json::json!({"name": "other_tool"}),
|
||||
);
|
||||
assert_l7_denial(
|
||||
&engine,
|
||||
&unmatched_tool,
|
||||
false,
|
||||
"MCP tool call has no matching allow rule; ask the policy owner to review rules and tool selectors",
|
||||
);
|
||||
for path in ["/rest", "/rpc", "/other"] {
|
||||
assert_l7_denial(
|
||||
&engine,
|
||||
&l7_jsonrpc_input("mcp.reasons.test", 8000, path, "tools/call"),
|
||||
false,
|
||||
&format!("POST {path} not permitted by policy"),
|
||||
);
|
||||
}
|
||||
|
||||
// Batch envelopes have no selected call until the relay evaluates
|
||||
// each member. Protocol failures also remain outside policy hints.
|
||||
for field in ["method", "error", "mcp_method_classification"] {
|
||||
let mut input = unmatched_tool.clone();
|
||||
input["request"]["jsonrpc"][field] = match field {
|
||||
"method" => serde_json::Value::Null,
|
||||
"error" => serde_json::json!("invalid MCP request"),
|
||||
_ => serde_json::json!("unavailable"),
|
||||
};
|
||||
assert_l7_denial(&engine, &input, false, "POST /mcp not permitted by policy");
|
||||
}
|
||||
|
||||
assert_l7_denial(
|
||||
&engine,
|
||||
&l7_jsonrpc_response_input("mcp.reasons.test", 8000, "/rpc"),
|
||||
true,
|
||||
"JSON-RPC response frames are not permitted from client to server",
|
||||
);
|
||||
assert!(eval_l7(
|
||||
&engine,
|
||||
&l7_jsonrpc_response_input("mcp.reasons.test", 8000, "/mcp")
|
||||
));
|
||||
assert!(eval_l7(
|
||||
&engine,
|
||||
&l7_jsonrpc_input("mcp.reasons.test", 8000, "/mcp", "tools/list")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l7_jsonrpc_null_metadata_non_matches_without_opa_error() {
|
||||
let data = r#"
|
||||
|
||||
Reference in New Issue
Block a user