mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-19 05:50:41 +08:00
* fix(sandbox-backend): sort boundary request objects before hashing Sort boundary request objects recursively before hashing so serde_json's preserve_order feature cannot change digest identity. Cover canonical bytes, envelope round trips, and rejection of modified provider values and operations. Signed-off-by: Shiju <shiju@nvidia.com> * feat(mcp): upgrade tower-mcp-types to 0.22.2 Upgrade tower-mcp-types from 0.12.0 to an exact-pinned 0.22.2 and use its inspection APIs to validate MCP requests against the selected revision. Carry inspection metadata into policy evaluation and validate requests after header rewriting, before forwarding. Add explicit support for the sessionless 2026-07-28 revision while keeping 2025-11-25 as the default. Validate per-request metadata and standard HTTP header mirrors, and support discovery, tools, and subscription requests. Delegate batch availability and parameter schemas to Tower. Share typed request names between policy and HTTP checks, retain the local batch resource cap, and centralize MCP policy version parsing and ordering. Keep supported MCP revisions and shared allowlist parsing in the canonical policy schema; core re-exports those types. Tower owns wire-profile semantics, and every supported policy revision must map to the matching inspector profile. Reject duplicate JSON keys, invalid known-method parameters, unavailable methods, and unsupported batches. Keep exact extension allow rules and deny precedence. Document request inspection boundaries and add unit, forwarding, and sandbox coverage. Refs #2174. Signed-off-by: Shiju <shiju@nvidia.com> * test(mcp): prove authorization at the forwarding boundary Cover March batch denial in both member orders, valid and malformed controls, and audit behavior across both relay entry paths. Exercise real middleware tool rewrites with matching metadata and assert the exact upstream representation or zero forwarded bytes. Verify legacy bodyless SSE GET remains usable while GET tool bodies and unsupported DELETE cleanup are rejected. Clarify request-selected profile and middleware mutation comments without changing production behavior. Signed-off-by: Shiju <shiju@nvidia.com> * test(mcp): exercise permitted profiles through the sandbox proxy Cover March and June singleton policies and select November and July separately under one endpoint allowlist. Capture upstream tool receipts to distinguish proxy policy denial from an upstream rejection. Extend middleware rewrite coverage to June and multi-version policies, and preserve the sessionless discovery and subscription checks through the shared fixture helpers. Signed-off-by: Shiju <shiju@nvidia.com> * test(kubernetes): box the admission check future Keep the admission test future below Clippy's size limit when the workspace dependency features are unified. Signed-off-by: Shiju <shiju@nvidia.com> * test(mcp): reuse the forwarding fixture identity cache Share the binary identity cache across protocol-profile cases, matching the proxy lifecycle and avoiding repeated hashes of the test executable. Keep procfs authorization and all forwarding assertions intact. Signed-off-by: Shiju <shiju@nvidia.com> --------- Signed-off-by: Shiju <shiju@nvidia.com>
344 lines
12 KiB
Rust
344 lines
12 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! `OpenShell`-owned MCP policy revisions, allowlist parsing, and resource limits.
|
|
|
|
use crate::proto::{McpOptions, ProviderProfile};
|
|
|
|
pub use openshell_policy_schema::{
|
|
DEFAULT_MCP_PROTOCOL_VERSION, MAX_MCP_LEGACY_BATCH_MESSAGES, McpProtocolVersion,
|
|
ParseMcpProtocolVersionError, ParseMcpVersionsError, canonicalize_mcp_versions,
|
|
parse_mcp_versions,
|
|
};
|
|
|
|
/// Return whether a policy protocol name denotes MCP.
|
|
///
|
|
/// Protocol names are case-insensitive throughout policy validation and
|
|
/// execution, so every MCP-specific projection must use the same predicate.
|
|
#[must_use]
|
|
pub fn is_mcp_protocol(protocol: &str) -> bool {
|
|
protocol.trim().eq_ignore_ascii_case("mcp")
|
|
}
|
|
|
|
/// Normalize only the MCP option fields in a provider profile.
|
|
///
|
|
/// MCP protocol matching is case-insensitive. Missing or empty version state
|
|
/// materializes [`DEFAULT_MCP_PROTOCOL_VERSION`], while a valid explicit
|
|
/// allowlist is sorted in [`McpProtocolVersion::ALL`] semantic order. An
|
|
/// explicit list containing an unsupported, padded, or duplicate value is
|
|
/// left byte-for-byte unchanged so a later validation boundary can reject the
|
|
/// original evidence. MCP-shaped data on non-MCP endpoints is also untouched.
|
|
pub fn normalize_provider_profile_mcp_fields(profile: &mut ProviderProfile) {
|
|
for endpoint in &mut profile.endpoints {
|
|
if !is_mcp_protocol(&endpoint.protocol) {
|
|
continue;
|
|
}
|
|
|
|
let Some(options) = endpoint.mcp.as_mut() else {
|
|
endpoint.mcp = Some(McpOptions {
|
|
versions: vec![DEFAULT_MCP_PROTOCOL_VERSION.as_str().to_string()],
|
|
..McpOptions::default()
|
|
});
|
|
continue;
|
|
};
|
|
|
|
if options.versions.is_empty() {
|
|
options.versions = vec![DEFAULT_MCP_PROTOCOL_VERSION.as_str().to_string()];
|
|
continue;
|
|
}
|
|
|
|
// Validate the explicit list before mutation so malformed values retain
|
|
// their original order and spelling for the checked policy boundary.
|
|
let Ok(versions) = parse_mcp_versions(&options.versions) else {
|
|
continue;
|
|
};
|
|
|
|
options.versions = versions
|
|
.into_iter()
|
|
.map(|version| version.as_str().to_string())
|
|
.collect();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use prost::Message;
|
|
use prost_types::{FileDescriptorSet, field_descriptor_proto};
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn mcp_protocol_version_all_values_are_in_canonical_order() {
|
|
assert_eq!(
|
|
McpProtocolVersion::ALL,
|
|
&[
|
|
McpProtocolVersion::V2025_03_26,
|
|
McpProtocolVersion::V2025_06_18,
|
|
McpProtocolVersion::V2025_11_25,
|
|
McpProtocolVersion::V2026_07_28,
|
|
]
|
|
);
|
|
assert!(
|
|
McpProtocolVersion::ALL
|
|
.windows(2)
|
|
.all(|versions| versions[0] < versions[1])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_mcp_protocol_version_is_pinned_to_the_2025_11_25_revision() {
|
|
assert_eq!(
|
|
DEFAULT_MCP_PROTOCOL_VERSION,
|
|
McpProtocolVersion::V2025_11_25
|
|
);
|
|
assert_eq!(DEFAULT_MCP_PROTOCOL_VERSION.as_str(), "2025-11-25");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_mcp_versions_returns_canonical_order_without_mutating_the_authored_list() {
|
|
let values = ["2026-07-28", "2025-03-26", "2025-11-25", "2025-06-18"].map(str::to_string);
|
|
let original = values.clone();
|
|
|
|
let versions = parse_mcp_versions(&values).expect("explicit supported revisions");
|
|
|
|
assert_eq!(
|
|
versions.into_iter().collect::<Vec<_>>(),
|
|
McpProtocolVersion::ALL
|
|
);
|
|
assert_eq!(values, original);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_mcp_versions_reports_the_first_error_without_repairing_input() {
|
|
let duplicate = ParseMcpVersionsError::Duplicate(McpProtocolVersion::V2025_03_26);
|
|
let unsupported = ParseMcpVersionsError::Unsupported(
|
|
" 2025-11-25"
|
|
.parse::<McpProtocolVersion>()
|
|
.expect_err("revision is unsupported"),
|
|
);
|
|
for (values, expected) in [
|
|
(vec![], ParseMcpVersionsError::Empty),
|
|
(vec!["2025-03-26", "2025-03-26", " 2025-11-25"], duplicate),
|
|
(vec![" 2025-11-25", "2025-03-26", "2025-03-26"], unsupported),
|
|
] {
|
|
let values = values.into_iter().map(str::to_string).collect::<Vec<_>>();
|
|
let original = values.clone();
|
|
|
|
assert_eq!(parse_mcp_versions(&values), Err(expected));
|
|
assert_eq!(values, original);
|
|
}
|
|
}
|
|
|
|
fn provider_profile_with_mcp(protocol: &str, options: Option<McpOptions>) -> ProviderProfile {
|
|
ProviderProfile {
|
|
id: "mcp-profile".to_string(),
|
|
display_name: "MCP profile".to_string(),
|
|
description: "source-owned description".to_string(),
|
|
endpoints: vec![crate::proto::NetworkEndpoint {
|
|
host: "mcp.example.com".to_string(),
|
|
port: 443,
|
|
protocol: protocol.to_string(),
|
|
mcp: options,
|
|
..crate::proto::NetworkEndpoint::default()
|
|
}],
|
|
..ProviderProfile::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn provider_profile_mcp_normalization_materializes_omitted_and_empty_versions() {
|
|
let mut omitted = provider_profile_with_mcp("McP", None);
|
|
normalize_provider_profile_mcp_fields(&mut omitted);
|
|
assert_eq!(
|
|
omitted.endpoints[0]
|
|
.mcp
|
|
.as_ref()
|
|
.expect("omitted MCP options must materialize")
|
|
.versions,
|
|
["2025-11-25"]
|
|
);
|
|
|
|
let mut empty = provider_profile_with_mcp(
|
|
"mcp",
|
|
Some(McpOptions {
|
|
strict_tool_names: Some(false),
|
|
allow_all_known_mcp_methods: Some(true),
|
|
versions: Vec::new(),
|
|
}),
|
|
);
|
|
normalize_provider_profile_mcp_fields(&mut empty);
|
|
assert_eq!(
|
|
empty.endpoints[0]
|
|
.mcp
|
|
.as_ref()
|
|
.expect("empty MCP versions must materialize"),
|
|
&McpOptions {
|
|
strict_tool_names: Some(false),
|
|
allow_all_known_mcp_methods: Some(true),
|
|
versions: vec!["2025-11-25".to_string()],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_profile_mcp_normalization_canonicalizes_valid_explicit_versions_only() {
|
|
let mut profile = provider_profile_with_mcp(
|
|
"mcp",
|
|
Some(McpOptions {
|
|
strict_tool_names: Some(true),
|
|
versions: vec![
|
|
"2026-07-28".to_string(),
|
|
"2025-11-25".to_string(),
|
|
"2025-03-26".to_string(),
|
|
"2025-06-18".to_string(),
|
|
],
|
|
..McpOptions::default()
|
|
}),
|
|
);
|
|
let original = profile.clone();
|
|
|
|
normalize_provider_profile_mcp_fields(&mut profile);
|
|
|
|
assert_eq!(profile.id, original.id);
|
|
assert_eq!(profile.display_name, original.display_name);
|
|
assert_eq!(profile.description, original.description);
|
|
assert_eq!(profile.endpoints[0].host, original.endpoints[0].host);
|
|
assert_eq!(profile.endpoints[0].port, original.endpoints[0].port);
|
|
assert_eq!(
|
|
profile.endpoints[0].protocol,
|
|
original.endpoints[0].protocol
|
|
);
|
|
assert_eq!(
|
|
profile.endpoints[0]
|
|
.mcp
|
|
.as_ref()
|
|
.expect("valid MCP options"),
|
|
&McpOptions {
|
|
strict_tool_names: Some(true),
|
|
versions: vec![
|
|
"2025-03-26".to_string(),
|
|
"2025-06-18".to_string(),
|
|
"2025-11-25".to_string(),
|
|
"2026-07-28".to_string(),
|
|
],
|
|
..McpOptions::default()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_profile_mcp_normalization_preserves_every_malformed_explicit_list() {
|
|
for versions in [
|
|
vec!["2025-11-25", "2025-11-25"],
|
|
vec!["2026-07-28", "2025-03-26", "2025-03-26", "2025-06-18"],
|
|
vec!["2026-07-28", "latest", "2025-03-26"],
|
|
vec![" 2025-11-25"],
|
|
vec!["2025-11-25 "],
|
|
vec!["2026-07-29"],
|
|
vec!["latest"],
|
|
vec!["draft"],
|
|
] {
|
|
let mut profile = provider_profile_with_mcp(
|
|
"mcp",
|
|
Some(McpOptions {
|
|
versions: versions.into_iter().map(ToString::to_string).collect(),
|
|
..McpOptions::default()
|
|
}),
|
|
);
|
|
let original = profile.clone();
|
|
|
|
normalize_provider_profile_mcp_fields(&mut profile);
|
|
|
|
assert_eq!(profile, original);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn provider_profile_mcp_normalization_ignores_non_mcp_endpoint_evidence() {
|
|
let mut profile = provider_profile_with_mcp(
|
|
"rest",
|
|
Some(McpOptions {
|
|
versions: vec!["latest".to_string()],
|
|
..McpOptions::default()
|
|
}),
|
|
);
|
|
let original = profile.clone();
|
|
|
|
normalize_provider_profile_mcp_fields(&mut profile);
|
|
|
|
assert_eq!(profile, original);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_protocol_version_parses_only_exact_supported_values() {
|
|
assert_eq!("2025-03-26".parse(), Ok(McpProtocolVersion::V2025_03_26));
|
|
assert_eq!("2025-06-18".parse(), Ok(McpProtocolVersion::V2025_06_18));
|
|
assert_eq!("2025-11-25".parse(), Ok(McpProtocolVersion::V2025_11_25));
|
|
assert_eq!("2026-07-28".parse(), Ok(McpProtocolVersion::V2026_07_28));
|
|
|
|
for unsupported in [
|
|
"",
|
|
"2025-03-26 ",
|
|
" 2025-06-18",
|
|
"2025-11-25\n",
|
|
"2025-11-24",
|
|
"2026-07-28 ",
|
|
"2026-07-29",
|
|
"draft",
|
|
"latest",
|
|
] {
|
|
let error = unsupported
|
|
.parse::<McpProtocolVersion>()
|
|
.expect_err("unsupported revision must fail");
|
|
assert_eq!(error.value(), unsupported);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_protocol_version_display_and_as_str_round_trip() {
|
|
for version in McpProtocolVersion::ALL.iter().copied() {
|
|
assert_eq!(version.to_string(), version.as_str());
|
|
assert_eq!(version.as_str().parse(), Ok(version));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_protocol_version_error_reports_the_rejected_input() {
|
|
let rejected = " 2025-03-26";
|
|
let error = rejected
|
|
.parse::<McpProtocolVersion>()
|
|
.expect_err("padded MCP revision must be rejected");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
format!("unsupported MCP protocol version '{rejected}'")
|
|
);
|
|
assert_eq!(error.value(), rejected);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_options_versions_field_number_is_stable() {
|
|
let descriptor_set = FileDescriptorSet::decode(crate::FILE_DESCRIPTOR_SET)
|
|
.expect("OpenShell descriptor set must decode");
|
|
let mcp_options = descriptor_set
|
|
.file
|
|
.iter()
|
|
.find(|file| file.package.as_deref() == Some("openshell.sandbox.v1"))
|
|
.and_then(|file| {
|
|
file.message_type
|
|
.iter()
|
|
.find(|message| message.name.as_deref() == Some("McpOptions"))
|
|
})
|
|
.expect("sandbox schema must define McpOptions");
|
|
let versions = mcp_options
|
|
.field
|
|
.iter()
|
|
.find(|field| field.name.as_deref() == Some("versions"))
|
|
.expect("McpOptions must define versions");
|
|
|
|
assert_eq!(versions.number, Some(3));
|
|
assert_eq!(versions.label(), field_descriptor_proto::Label::Repeated);
|
|
assert_eq!(versions.r#type(), field_descriptor_proto::Type::String);
|
|
}
|
|
}
|