diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 2950ab36e..8815db637 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -44,6 +44,9 @@ jobs: cmd: "mise run --no-deps --skip-deps e2e:podman:rootless" apt_packages: "openssh-client podman uidmap" rootless: true + - suite: mcp + cmd: "mise run --no-deps --skip-deps e2e:mcp" + apt_packages: "" container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -65,6 +68,17 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs['checkout-ref'] || github.sha }} + persist-credentials: false + + - name: Check out MCP conformance tests + if: matrix.suite == 'mcp' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: modelcontextprotocol/conformance + # Pin after v0.1.16 to include the tools_call client scenario fix. + ref: b9041ea41b0188581803459dbae71bc7e02fd995 + path: .cache/mcp-conformance + persist-credentials: false - name: Install OS test dependencies if: matrix.apt_packages != '' @@ -104,6 +118,7 @@ jobs: - name: Run tests env: OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }} + OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE: ${{ format('openshell-mcp-conformance-client:{0}', inputs.image-tag) }} E2E_CMD: ${{ matrix.cmd }} run: | if [ "${{ matrix.rootless }}" = "true" ]; then diff --git a/architecture/sandbox.md b/architecture/sandbox.md index e60b727a5..0e3d68c64 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -49,6 +49,15 @@ paths, such as proxy support files or GPU device paths when a GPU is present. All ordinary agent egress is routed through the sandbox proxy. The proxy identifies the calling binary, checks trust-on-first-use binary identity, rejects unsafe internal destinations, and evaluates the active policy. +For inspected HTTP traffic, the proxy can enforce REST method/path rules, +WebSocket upgrade and text-message rules, GraphQL operation rules, and +JSON-RPC method and params rules on sandbox-to-server request bodies. JSON-RPC +request inspection buffers up to the endpoint `json_rpc.max_body_bytes` limit. +Literal dotted keys in JSON-RPC params are accepted. If a literal key and a +flattened nested selector path produce the same matcher key, the literal key +takes precedence. +JSON-RPC responses and server-to-client MCP messages on response or SSE streams +are relayed but are not currently parsed for policy enforcement. `https://inference.local` is special. It bypasses OPA network policy and is handled by the inference interception path: diff --git a/crates/openshell-cli/src/policy_update.rs b/crates/openshell-cli/src/policy_update.rs index 57656b878..1f1f64750 100644 --- a/crates/openshell-cli/src/policy_update.rs +++ b/crates/openshell-cli/src/policy_update.rs @@ -205,6 +205,7 @@ fn group_allow_rules(specs: &[String]) -> Result Result, #[serde(default, skip_serializing_if = "is_zero_u32")] graphql_max_body_bytes: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + json_rpc: Option, } // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. @@ -149,6 +151,17 @@ fn is_zero_u32(v: &u32) -> bool { *v == 0 } +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsonRpcConfigDef { + #[serde(default, skip_serializing_if = "is_zero_u32")] + max_body_bytes: u32, +} + +fn json_rpc_config_from_proto(max_body_bytes: u32) -> Option { + (max_body_bytes > 0).then_some(JsonRpcConfigDef { max_body_bytes }) +} + #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct GraphqlOperationDef { @@ -183,6 +196,8 @@ struct L7AllowDef { operation_name: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] fields: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + params: BTreeMap, } #[derive(Debug, Serialize, Deserialize)] @@ -216,6 +231,8 @@ struct L7DenyRuleDef { operation_name: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] fields: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + params: BTreeMap, } #[derive(Debug, Serialize, Deserialize)] @@ -232,6 +249,24 @@ struct NetworkBinaryDef { // YAML → proto conversion // --------------------------------------------------------------------------- +fn matcher_def_to_proto(matcher: QueryMatcherDef) -> L7QueryMatcher { + match matcher { + QueryMatcherDef::Glob(glob) => L7QueryMatcher { glob, any: vec![] }, + QueryMatcherDef::Any(any) => L7QueryMatcher { + glob: String::new(), + any: any.any, + }, + } +} + +fn matcher_proto_to_def(matcher: L7QueryMatcher) -> QueryMatcherDef { + if matcher.any.is_empty() { + QueryMatcherDef::Glob(matcher.glob) + } else { + QueryMatcherDef::Any(QueryAnyDef { any: matcher.any }) + } +} + fn to_proto(raw: PolicyFile) -> SandboxPolicy { let network_policies = raw .network_policies @@ -281,16 +316,15 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { .query .into_iter() .map(|(key, matcher)| { - let proto = match matcher { - QueryMatcherDef::Glob(glob) => { - L7QueryMatcher { glob, any: vec![] } - } - QueryMatcherDef::Any(any) => L7QueryMatcher { - glob: String::new(), - any: any.any, - }, - }; - (key, proto) + (key, matcher_def_to_proto(matcher)) + }) + .collect(), + params: r + .allow + .params + .into_iter() + .map(|(key, matcher)| { + (key, matcher_def_to_proto(matcher)) }) .collect(), }), @@ -310,18 +344,12 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { query: d .query .into_iter() - .map(|(key, matcher)| { - let proto = match matcher { - QueryMatcherDef::Glob(glob) => { - L7QueryMatcher { glob, any: vec![] } - } - QueryMatcherDef::Any(any) => L7QueryMatcher { - glob: String::new(), - any: any.any, - }, - }; - (key, proto) - }) + .map(|(key, matcher)| (key, matcher_def_to_proto(matcher))) + .collect(), + params: d + .params + .into_iter() + .map(|(key, matcher)| (key, matcher_def_to_proto(matcher))) .collect(), }) .collect(), @@ -347,6 +375,10 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { }) .collect(), graphql_max_body_bytes: e.graphql_max_body_bytes, + json_rpc_max_body_bytes: e + .json_rpc + .as_ref() + .map_or(0, |config| config.max_body_bytes), } }) .collect(), @@ -452,14 +484,14 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { .query .into_iter() .map(|(key, matcher)| { - let yaml_matcher = if matcher.any.is_empty() { - QueryMatcherDef::Glob(matcher.glob) - } else { - QueryMatcherDef::Any(QueryAnyDef { - any: matcher.any, - }) - }; - (key, yaml_matcher) + (key, matcher_proto_to_def(matcher)) + }) + .collect(), + params: a + .params + .into_iter() + .map(|(key, matcher)| { + (key, matcher_proto_to_def(matcher)) }) .collect(), }, @@ -481,14 +513,14 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { .query .iter() .map(|(key, matcher)| { - let yaml_matcher = if matcher.any.is_empty() { - QueryMatcherDef::Glob(matcher.glob.clone()) - } else { - QueryMatcherDef::Any(QueryAnyDef { - any: matcher.any.clone(), - }) - }; - (key.clone(), yaml_matcher) + (key.clone(), matcher_proto_to_def(matcher.clone())) + }) + .collect(), + params: d + .params + .iter() + .map(|(key, matcher)| { + (key.clone(), matcher_proto_to_def(matcher.clone())) }) .collect(), }) @@ -512,6 +544,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { }) .collect(), graphql_max_body_bytes: e.graphql_max_body_bytes, + json_rpc: json_rpc_config_from_proto(e.json_rpc_max_body_bytes), } }) .collect(), @@ -1699,6 +1732,60 @@ network_policies: assert_eq!(ep.deny_rules[0].fields, vec!["deleteRepository"]); } + #[test] + fn round_trip_preserves_json_rpc_max_body_bytes() { + let yaml = r" +version: 1 +network_policies: + mcp: + name: mcp + endpoints: + - host: mcp.example.com + port: 443 + protocol: json-rpc + enforcement: enforce + json_rpc: + max_body_bytes: 131072 + rules: + - allow: + method: initialize + binaries: + - path: /usr/bin/curl +"; + let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); + let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); + let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed"); + + let ep = &proto2.network_policies["mcp"].endpoints[0]; + assert_eq!(ep.protocol, "json-rpc"); + assert_eq!(ep.json_rpc_max_body_bytes, 131_072); + } + + #[test] + fn parse_rejects_unsupported_json_rpc_config_fields() { + let yaml = r" +version: 1 +network_policies: + mcp: + endpoints: + - host: mcp.example.com + port: 443 + protocol: json-rpc + json_rpc: + max_body_bytes: 131072 + on_parse_error: deny + batch_policy: all + access: full + binaries: + - path: /usr/bin/curl +"; + + assert!( + parse_sandbox_policy(yaml).is_err(), + "unsupported json_rpc fields must not be silently accepted" + ); + } + #[test] fn round_trip_preserves_websocket_credential_rewrite() { let yaml = r" diff --git a/crates/openshell-policy/src/merge.rs b/crates/openshell-policy/src/merge.rs index f191cd272..ef6566a1f 100644 --- a/crates/openshell-policy/src/merge.rs +++ b/crates/openshell-policy/src/merge.rs @@ -747,6 +747,7 @@ fn expand_access_preset(protocol: &str, access: &str) -> Option> { operation_type: String::new(), operation_name: String::new(), fields: Vec::new(), + params: HashMap::default(), }), }) .collect(), @@ -961,6 +962,7 @@ mod tests { operation_type: String::new(), operation_name: String::new(), fields: Vec::new(), + params: HashMap::default(), }), } } diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index d31085b64..5b4ede2ae 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -201,6 +201,8 @@ pub struct EndpointProfile { pub graphql_persisted_queries: HashMap, #[serde(default, skip_serializing_if = "is_zero")] pub graphql_max_body_bytes: u32, + #[serde(default, skip_serializing_if = "is_zero")] + pub json_rpc_max_body_bytes: u32, #[serde(default, skip_serializing_if = "String::is_empty")] pub path: String, } @@ -744,6 +746,7 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { .map(|(name, operation)| (name.clone(), graphql_operation_to_proto(operation))) .collect(), graphql_max_body_bytes: endpoint.graphql_max_body_bytes, + json_rpc_max_body_bytes: endpoint.json_rpc_max_body_bytes, path: endpoint.path.clone(), } } @@ -774,6 +777,7 @@ fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile { .map(|(name, operation)| (name.clone(), graphql_operation_from_proto(operation))) .collect(), graphql_max_body_bytes: endpoint.graphql_max_body_bytes, + json_rpc_max_body_bytes: endpoint.json_rpc_max_body_bytes, path: endpoint.path.clone(), } } @@ -817,6 +821,7 @@ fn allow_to_proto(allow: &L7AllowProfile) -> L7Allow { operation_type: allow.operation_type.clone(), operation_name: allow.operation_name.clone(), fields: allow.fields.clone(), + params: HashMap::new(), } } @@ -849,6 +854,7 @@ fn deny_rule_to_proto(rule: &L7DenyRuleProfile) -> L7DenyRule { operation_type: rule.operation_type.clone(), operation_name: rule.operation_name.clone(), fields: rule.fields.clone(), + params: HashMap::new(), } } diff --git a/crates/openshell-sandbox/src/mechanistic_mapper.rs b/crates/openshell-sandbox/src/mechanistic_mapper.rs index ba7c51de9..8ee2fc37f 100644 --- a/crates/openshell-sandbox/src/mechanistic_mapper.rs +++ b/crates/openshell-sandbox/src/mechanistic_mapper.rs @@ -355,6 +355,7 @@ fn build_l7_rules(samples: &HashMap<(String, String), u32>) -> Vec { operation_type: String::new(), operation_name: String::new(), fields: Vec::new(), + params: HashMap::new(), }), }); } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 2e2210f44..3ea156f98 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -8049,6 +8049,7 @@ mod tests { operation_type: String::new(), operation_name: String::new(), fields: Vec::new(), + params: HashMap::default(), }), }], }; @@ -8444,6 +8445,7 @@ mod tests { operation_type: String::new(), operation_name: String::new(), fields: Vec::new(), + params: HashMap::default(), }), }], }]; @@ -8590,6 +8592,7 @@ mod tests { operation_type: String::new(), operation_name: String::new(), fields: Vec::new(), + params: HashMap::default(), }), }], }; diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index afcd28863..71bcb1fe3 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -257,6 +257,7 @@ deny_request if { # --- L7 deny rule matching: REST method + path + query --- request_denied_for_endpoint(request, endpoint) if { + object.get(endpoint, "protocol", "") != "json-rpc" some deny_rule deny_rule := endpoint.deny_rules[_] deny_rule.method @@ -274,6 +275,23 @@ request_denied_for_endpoint(request, endpoint) if { command_matches(request.command, deny_rule.command) } +# --- L7 deny rule matching: JSON-RPC method + params --- + +request_denied_for_endpoint(request, endpoint) if { + endpoint.protocol == "json-rpc" + request.method == "POST" + some deny_rule + deny_rule := endpoint.deny_rules[_] + deny_rule.method + jsonrpc_rule_matches(request, deny_rule) +} + +request_denied_for_endpoint(request, endpoint) if { + endpoint.protocol == "json-rpc" + request.method == "POST" + jsonrpc_response_frame_present(request) +} + # --- L7 deny rule matching: GraphQL operation --- request_denied_for_endpoint(request, endpoint) if { @@ -382,10 +400,17 @@ request_deny_reason := reason if { reason := "GraphQL operation not permitted by policy" } +request_deny_reason := reason if { + input.request + jsonrpc_response_frame_present(input.request) + reason := "JSON-RPC response frames are not permitted from client to server" +} + request_deny_reason := reason if { input.request deny_request not graphql_request_has_operations(input.request) + not jsonrpc_response_frame_present(input.request) reason := sprintf("%s %s blocked by deny rule", [input.request.method, input.request.path]) } @@ -394,12 +419,14 @@ request_deny_reason := reason if { not deny_request not allow_request not graphql_request_has_operations(input.request) + not jsonrpc_response_frame_present(input.request) reason := sprintf("%s %s not permitted by policy", [input.request.method, input.request.path]) } # --- L7 rule matching: REST method + path --- request_allowed_for_endpoint(request, endpoint) if { + object.get(endpoint, "protocol", "") != "json-rpc" some rule rule := endpoint.rules[_] rule.allow.method @@ -417,6 +444,31 @@ request_allowed_for_endpoint(request, endpoint) if { command_matches(request.command, rule.allow.command) } +# --- L7 rule matching: JSON-RPC method --- + +request_allowed_for_endpoint(request, endpoint) if { + endpoint.protocol == "json-rpc" + request.method == "POST" + some rule + rule := endpoint.rules[_] + rule.allow.method + not jsonrpc_response_frame_present(request) + jsonrpc_rule_matches(request, rule.allow) +} + +# MCP Streamable HTTP uses GET on the JSON-RPC endpoint as a receive stream for +# server-to-client messages. The stream itself has no client-to-server JSON-RPC +# request body to inspect; allow it once the endpoint path and binary matched. +request_allowed_for_endpoint(request, endpoint) if { + endpoint.protocol == "json-rpc" + request.method == "GET" + is_object(request.jsonrpc) + object.get(request.jsonrpc, "receive_stream", false) + jsonrpc_no_parse_error(request.jsonrpc) + object.get(request.jsonrpc, "method", null) == null + not object.get(request.jsonrpc, "has_response", false) +} + # --- L7 rule matching: GraphQL operation --- request_allowed_for_endpoint(request, endpoint) if { @@ -638,6 +690,53 @@ query_value_matches(value, matcher) if { glob.match(any_patterns[i], [], value) } +# JSON-RPC method and params matching. The sandbox presents scalar object params +# as dot-separated matcher keys, e.g. arguments.scope. Literal dotted param keys +# are preserved by Rust flattening and take precedence over deeper nested paths. +jsonrpc_rule_matches(request, rule) if { + jsonrpc := object.get(request, "jsonrpc", {}) + is_object(jsonrpc) + method := object.get(jsonrpc, "method", null) + method != null + glob.match(rule.method, [], method) + jsonrpc_params_match(jsonrpc, rule) +} + +jsonrpc_response_frame_present(request) if { + jsonrpc := object.get(request, "jsonrpc", {}) + is_object(jsonrpc) + object.get(jsonrpc, "has_response", false) +} + +jsonrpc_no_parse_error(jsonrpc) if { + object.get(jsonrpc, "error", null) == null +} + +jsonrpc_no_parse_error(jsonrpc) if { + object.get(jsonrpc, "error", "") == "" +} + +jsonrpc_params_match(jsonrpc, rule) if { + is_object(jsonrpc) + param_rules := object.get(rule, "params", {}) + not jsonrpc_param_mismatch(jsonrpc, param_rules) +} + +jsonrpc_param_mismatch(jsonrpc, param_rules) if { + some key + matcher := param_rules[key] + not jsonrpc_param_key_matches(jsonrpc, key, matcher) +} + +jsonrpc_param_key_matches(jsonrpc, key, matcher) if { + params := object.get(jsonrpc, "params", {}) + is_object(params) + value := object.get(params, key, null) + value != null + is_string(value) + query_value_matches(value, matcher) +} + # SQL command matching: "*" matches any; otherwise case-insensitive. command_matches(_, "*") if true diff --git a/crates/openshell-supervisor-network/src/l7/graphql.rs b/crates/openshell-supervisor-network/src/l7/graphql.rs index 82c35720e..12979f0b1 100644 --- a/crates/openshell-supervisor-network/src/l7/graphql.rs +++ b/crates/openshell-supervisor-network/src/l7/graphql.rs @@ -810,6 +810,7 @@ network_policies: target: req.target, query_params: req.query_params, graphql: Some(info), + jsonrpc: None, }; let tunnel_engine = engine diff --git a/crates/openshell-supervisor-network/src/l7/http.rs b/crates/openshell-supervisor-network/src/l7/http.rs new file mode 100644 index 000000000..66269f6ba --- /dev/null +++ b/crates/openshell-supervisor-network/src/l7/http.rs @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared HTTP/1.1 request helpers for L7 protocols carried over HTTP. + +use crate::l7::provider::{BodyLength, L7Request}; +use miette::{IntoDiagnostic, Result, miette}; +use tokio::io::{AsyncRead, AsyncReadExt}; + +const READ_BUF_SIZE: usize = 8192; + +pub async fn read_body_for_inspection( + client: &mut C, + request: &mut L7Request, + max_body_bytes: usize, +) -> Result> { + let header_end = request + .raw_header + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(request.raw_header.len(), |p| p + 4); + let overflow = request.raw_header[header_end..].to_vec(); + + match request.body_length { + BodyLength::None => Ok(Vec::new()), + BodyLength::ContentLength(len) => { + let len = usize::try_from(len) + .map_err(|_| miette!("HTTP request body length exceeds platform limit"))?; + if len > max_body_bytes { + return Err(miette!( + "HTTP request body exceeds {max_body_bytes} byte inspection limit" + )); + } + if overflow.len() > len { + return Err(miette!( + "HTTP request contains more body bytes than Content-Length" + )); + } + let remaining = len - overflow.len(); + let mut body = overflow; + if remaining > 0 { + let start = body.len(); + body.resize(len, 0); + client + .read_exact(&mut body[start..]) + .await + .into_diagnostic()?; + } + request.raw_header.truncate(header_end); + request.raw_header.extend_from_slice(&body); + Ok(body) + } + BodyLength::Chunked => { + let body = read_chunked_body_for_inspection( + client, + request, + header_end, + overflow, + max_body_bytes, + ) + .await?; + normalize_chunked_request_to_content_length(request, header_end, &body)?; + Ok(body) + } + } +} + +fn normalize_chunked_request_to_content_length( + request: &mut L7Request, + header_end: usize, + body: &[u8], +) -> Result<()> { + let header_str = std::str::from_utf8(&request.raw_header[..header_end]) + .map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; + let header_str = header_str + .strip_suffix("\r\n\r\n") + .ok_or_else(|| miette!("HTTP headers missing terminator"))?; + + let mut normalized = Vec::with_capacity(header_str.len() + body.len() + 32); + for (idx, line) in header_str.split("\r\n").enumerate() { + if idx > 0 { + let name = line + .split_once(':') + .map(|(name, _)| name.trim().to_ascii_lowercase()); + if matches!( + name.as_deref(), + Some("transfer-encoding" | "content-length" | "trailer") + ) { + continue; + } + } + normalized.extend_from_slice(line.as_bytes()); + normalized.extend_from_slice(b"\r\n"); + } + normalized.extend_from_slice(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()); + normalized.extend_from_slice(body); + + request.raw_header = normalized; + request.body_length = BodyLength::ContentLength(body.len() as u64); + Ok(()) +} + +async fn read_chunked_body_for_inspection( + client: &mut C, + request: &mut L7Request, + header_end: usize, + overflow: Vec, + max_body_bytes: usize, +) -> Result> { + let mut raw = overflow; + let mut decoded = Vec::new(); + let mut pos = 0usize; + + loop { + let size_line_end = loop { + if let Some(end) = find_crlf(&raw, pos) { + break end; + } + read_more(client, &mut raw, max_body_bytes).await?; + }; + let size_line = std::str::from_utf8(&raw[pos..size_line_end]) + .into_diagnostic() + .map_err(|_| miette!("Invalid UTF-8 in HTTP chunk-size line"))?; + let size_token = size_line + .split(';') + .next() + .map(str::trim) + .unwrap_or_default(); + let chunk_size = usize::from_str_radix(size_token, 16) + .into_diagnostic() + .map_err(|_| miette!("Invalid HTTP chunk size token: {size_token:?}"))?; + pos = size_line_end + 2; + + if decoded.len().saturating_add(chunk_size) > max_body_bytes { + return Err(miette!( + "HTTP request body exceeds {max_body_bytes} byte inspection limit" + )); + } + + if chunk_size == 0 { + loop { + let trailer_end = loop { + if let Some(end) = find_crlf(&raw, pos) { + break end; + } + read_more(client, &mut raw, max_body_bytes).await?; + }; + let trailer_line = &raw[pos..trailer_end]; + pos = trailer_end + 2; + if trailer_line.is_empty() { + request.raw_header.truncate(header_end); + request.raw_header.extend_from_slice(&raw[..pos]); + return Ok(decoded); + } + } + } + + let chunk_end = pos + .checked_add(chunk_size) + .ok_or_else(|| miette!("HTTP chunk size overflow"))?; + let chunk_with_crlf_end = chunk_end + .checked_add(2) + .ok_or_else(|| miette!("HTTP chunk size overflow"))?; + while raw.len() < chunk_with_crlf_end { + read_more(client, &mut raw, max_body_bytes).await?; + } + decoded.extend_from_slice(&raw[pos..chunk_end]); + if raw.get(chunk_end..chunk_with_crlf_end) != Some(&b"\r\n"[..]) { + return Err(miette!("HTTP chunk payload missing terminating CRLF")); + } + pos = chunk_with_crlf_end; + } +} + +async fn read_more( + client: &mut C, + raw: &mut Vec, + max_body_bytes: usize, +) -> Result<()> { + if raw.len() > max_body_bytes.saturating_mul(2).max(max_body_bytes) { + return Err(miette!( + "HTTP chunked request body exceeds inspection framing limit" + )); + } + let mut buf = [0u8; READ_BUF_SIZE]; + let n = client.read(&mut buf).await.into_diagnostic()?; + if n == 0 { + return Err(miette!("HTTP chunked body ended before terminator")); + } + raw.extend_from_slice(&buf[..n]); + Ok(()) +} + +fn find_crlf(buf: &[u8], start: usize) -> Option { + buf.get(start..)? + .windows(2) + .position(|w| w == b"\r\n") + .map(|p| start + p) +} diff --git a/crates/openshell-supervisor-network/src/l7/jsonrpc.rs b/crates/openshell-supervisor-network/src/l7/jsonrpc.rs new file mode 100644 index 000000000..a12f843a2 --- /dev/null +++ b/crates/openshell-supervisor-network/src/l7/jsonrpc.rs @@ -0,0 +1,637 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! JSON-RPC 2.0 over HTTP L7 inspection. + +use miette::Result; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::collections::HashMap; +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::l7::provider::{L7Provider, L7Request}; + +pub const DEFAULT_MAX_BODY_BYTES: usize = 64 * 1024; + +pub struct JsonRpcHttpRequest { + pub request: L7Request, + pub info: JsonRpcRequestInfo, +} + +pub(crate) async fn parse_jsonrpc_http_request( + client: &mut C, + max_body_bytes: usize, + canonicalize_options: crate::l7::path::CanonicalizeOptions, +) -> Result> { + let provider = crate::l7::rest::RestProvider::with_options(canonicalize_options); + let Some(mut request) = provider.parse_request(client).await? else { + return Ok(None); + }; + if jsonrpc_receive_stream_request(&request) { + return Ok(Some(JsonRpcHttpRequest { + request, + info: JsonRpcRequestInfo::receive_stream(), + })); + } + let body = + crate::l7::http::read_body_for_inspection(client, &mut request, max_body_bytes).await?; + let info = parse_jsonrpc_body(&body); + Ok(Some(JsonRpcHttpRequest { request, info })) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JsonRpcRequestInfo { + pub calls: Vec, + pub is_batch: bool, + pub receive_stream: bool, + pub has_response: bool, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JsonRpcCallInfo { + pub method: String, + pub params: HashMap, +} + +impl JsonRpcRequestInfo { + pub(crate) fn receive_stream() -> Self { + Self { + calls: Vec::new(), + is_batch: false, + receive_stream: true, + has_response: false, + error: None, + } + } + + pub(crate) fn params_sha256(&self) -> Option { + if self.is_batch { + if self.calls.is_empty() || self.calls.iter().all(|call| call.params.is_empty()) { + return None; + } + let canonical_params = self + .calls + .iter() + .map(|call| canonical_params_map(&call.params)) + .collect::>(); + return Some(sha256_json(&canonical_params)); + } + + let call = self.calls.first()?; + if call.params.is_empty() { + return None; + } + Some(sha256_json(&canonical_params_map(&call.params))) + } +} + +pub(crate) fn jsonrpc_receive_stream_request(request: &L7Request) -> bool { + request.action.eq_ignore_ascii_case("GET") + && matches!( + request.body_length, + crate::l7::provider::BodyLength::None + | crate::l7::provider::BodyLength::ContentLength(0) + ) + && request_accepts_sse(request) +} + +fn request_accepts_sse(request: &L7Request) -> bool { + let header_end = request + .raw_header + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(request.raw_header.len(), |p| p + 4); + let header = String::from_utf8_lossy(&request.raw_header[..header_end]); + header.lines().skip(1).any(|line| { + let Some((name, value)) = line.split_once(':') else { + return false; + }; + name.trim().eq_ignore_ascii_case("accept") + && value.split(',').any(|part| { + part.split(';').next().is_some_and(|media_type| { + media_type.trim().eq_ignore_ascii_case("text/event-stream") + }) + }) + }) +} +/// Parse a JSON-RPC 2.0 request body and extract the `method` field. +/// +/// Returns an info struct with `method` set on success, or `error` set if the +/// body is not valid JSON-RPC 2.0. +pub fn parse_jsonrpc_body(body: &[u8]) -> JsonRpcRequestInfo { + let Ok(value) = serde_json::from_slice::(body) else { + return JsonRpcRequestInfo { + calls: Vec::new(), + is_batch: false, + receive_stream: false, + has_response: false, + error: Some("invalid JSON".to_string()), + }; + }; + + if let serde_json::Value::Array(items) = value { + if items.is_empty() { + return JsonRpcRequestInfo { + calls: Vec::new(), + is_batch: true, + receive_stream: false, + has_response: false, + error: Some("empty batch".to_string()), + }; + } + let mut calls = Vec::new(); + let mut has_response = false; + for item in &items { + match parse_jsonrpc_message(item) { + Ok(JsonRpcMessageInfo::Call(call)) => calls.push(call), + Ok(JsonRpcMessageInfo::Response) => has_response = true, + Err(error) => { + return JsonRpcRequestInfo { + calls: Vec::new(), + is_batch: true, + receive_stream: false, + has_response: false, + error: Some(format!("batch item invalid: {error}")), + }; + } + } + } + return JsonRpcRequestInfo { + calls, + is_batch: true, + receive_stream: false, + has_response, + error: None, + }; + } + + match parse_jsonrpc_message(&value) { + Ok(JsonRpcMessageInfo::Call(call)) => JsonRpcRequestInfo { + calls: vec![call], + is_batch: false, + receive_stream: false, + has_response: false, + error: None, + }, + Ok(JsonRpcMessageInfo::Response) => JsonRpcRequestInfo { + calls: Vec::new(), + is_batch: false, + receive_stream: false, + has_response: true, + error: None, + }, + Err(error) => JsonRpcRequestInfo { + calls: Vec::new(), + is_batch: false, + receive_stream: false, + has_response: false, + error: Some(error), + }, + } +} + +enum JsonRpcMessageInfo { + Call(JsonRpcCallInfo), + Response, +} + +fn parse_jsonrpc_message( + value: &serde_json::Value, +) -> std::result::Result { + let version = value + .get("jsonrpc") + .and_then(|v| v.as_str()) + .ok_or_else(|| "missing or non-string 'jsonrpc' field".to_string())?; + if version != "2.0" { + return Err(format!("unsupported JSON-RPC version '{version}'")); + } + + let has_method = value.get("method").is_some(); + let has_response_payload = jsonrpc_response_payload_present(value); + if has_method && has_response_payload { + return Err("JSON-RPC message includes both method and result/error".to_string()); + } + + if has_response_payload { + parse_jsonrpc_response(value)?; + return Ok(JsonRpcMessageInfo::Response); + } + + if has_method { + return parse_jsonrpc_call(value).map(JsonRpcMessageInfo::Call); + } + + Err("missing or non-string 'method' field".to_string()) +} + +fn parse_jsonrpc_call(value: &serde_json::Value) -> std::result::Result { + let method = value + .get("method") + .and_then(|m| m.as_str()) + .ok_or_else(|| "missing or non-string 'method' field".to_string())?; + let params = value + .get("params") + .map_or_else(|| Ok(HashMap::new()), flatten_jsonrpc_params)?; + Ok(JsonRpcCallInfo { + method: method.to_string(), + params, + }) +} + +fn jsonrpc_response_payload_present(value: &serde_json::Value) -> bool { + value.get("result").is_some() || value.get("error").is_some() +} + +fn parse_jsonrpc_response(value: &serde_json::Value) -> std::result::Result<(), String> { + let has_result = value.get("result").is_some(); + let has_error = value.get("error").is_some(); + match (has_result, has_error) { + (true, true) => return Err("JSON-RPC response includes both result and error".to_string()), + (false, false) => return Err("JSON-RPC response missing result or error".to_string()), + _ => {} + } + + let id = value + .get("id") + .ok_or_else(|| "JSON-RPC response missing id".to_string())?; + if !(id.is_string() || id.is_number() || id.is_null()) { + return Err("JSON-RPC response id must be string, number, or null".to_string()); + } + + if let Some(error) = value.get("error") + && !error.is_object() + { + return Err("JSON-RPC response error must be an object".to_string()); + } + + Ok(()) +} + +fn flatten_jsonrpc_params( + value: &serde_json::Value, +) -> std::result::Result, String> { + let mut params = HashMap::::new(); + flatten_json_value("", 0, value, &mut params)?; + Ok(params + .into_iter() + .map(|(key, param)| (key, param.value)) + .collect()) +} + +fn canonical_params_map(params: &HashMap) -> BTreeMap { + params + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +fn sha256_json(value: &impl serde::Serialize) -> String { + let encoded = serde_json::to_vec(value).expect("canonical JSON-RPC params should serialize"); + hex::encode(Sha256::digest(&encoded)) +} + +fn flatten_json_value( + prefix: &str, + path_segments: usize, + value: &serde_json::Value, + out: &mut HashMap, +) -> std::result::Result<(), String> { + match value { + serde_json::Value::Object(map) => { + for (key, child) in map { + let next_path_segments = path_segments + 1; + let next = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + flatten_json_value(&next, next_path_segments, child, out)?; + } + } + serde_json::Value::String(s) if !prefix.is_empty() => { + insert_flattened_param(out, prefix, s.clone(), path_segments)?; + } + serde_json::Value::Number(n) if !prefix.is_empty() => { + insert_flattened_param(out, prefix, n.to_string(), path_segments)?; + } + serde_json::Value::Bool(b) if !prefix.is_empty() => { + insert_flattened_param(out, prefix, b.to_string(), path_segments)?; + } + _ => {} + } + Ok(()) +} + +#[derive(Debug, Clone)] +struct FlattenedParam { + value: String, + path_segments: usize, +} + +fn insert_flattened_param( + out: &mut HashMap, + key: &str, + value: String, + path_segments: usize, +) -> std::result::Result<(), String> { + let param = FlattenedParam { + value, + path_segments, + }; + if let Some(existing) = out.get_mut(key) { + if param.path_segments < existing.path_segments { + *existing = param; + } else if param.path_segments == existing.path_segments { + return Err(format!("ambiguous params key collision at '{key}'")); + } + return Ok(()); + } + out.insert(key.to_string(), param); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_method_from_request_body() { + let body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + let info = parse_jsonrpc_body(body); + assert_eq!( + info.calls.first().map(|call| call.method.as_str()), + Some("initialize") + ); + assert_eq!(info.calls.len(), 1); + assert!(!info.is_batch); + assert!(!info.has_response); + assert!(info.error.is_none()); + } + + #[test] + fn parses_jsonrpc_response_body_without_method() { + let body = br#"{"jsonrpc":"2.0","id":1,"result":{"action":"accept","content":{}}}"#; + let info = parse_jsonrpc_body(body); + + assert!(info.calls.is_empty()); + assert!(!info.is_batch); + assert!(info.has_response); + assert!(info.error.is_none()); + assert!(info.params_sha256().is_none()); + } + + #[test] + fn parses_jsonrpc_error_response_body_without_method() { + let body = + br#"{"jsonrpc":"2.0","id":"request-1","error":{"code":-32603,"message":"failed"}}"#; + let info = parse_jsonrpc_body(body); + + assert!(info.calls.is_empty()); + assert!(info.has_response); + assert!(info.error.is_none()); + } + + #[test] + fn flattens_object_params_for_policy_matching() { + let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"submit_report","arguments":{"scope":"workspace/main"}}}"#; + let info = parse_jsonrpc_body(body); + let params = &info.calls.first().expect("single request call").params; + assert_eq!( + params.get("name").map(String::as_str), + Some("submit_report") + ); + assert_eq!( + params.get("arguments.scope").map(String::as_str), + Some("workspace/main") + ); + } + + #[test] + fn allows_literal_dotted_param_keys() { + let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"arguments.scope":"workspace/other","arguments":{"scope":"workspace/main"}}}"#; + let info = parse_jsonrpc_body(body); + let params = &info.calls.first().expect("single request call").params; + + assert!(info.error.is_none()); + assert_eq!( + params.get("arguments.scope").map(String::as_str), + Some("workspace/other") + ); + } + + #[test] + fn recognizes_streamable_http_get_receive_streams() { + let request = L7Request { + action: "GET".to_string(), + target: "/mcp".to_string(), + query_params: HashMap::new(), + raw_header: b"GET /mcp HTTP/1.1\r\nHost: mcp.test\r\nAccept: application/json, text/event-stream\r\n\r\n".to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + + assert!(jsonrpc_receive_stream_request(&request)); + + let info = JsonRpcRequestInfo::receive_stream(); + assert!(info.receive_stream); + assert!(info.error.is_none()); + assert!(info.calls.is_empty()); + assert!(info.params_sha256().is_none()); + } + + #[test] + fn bodyless_get_without_sse_accept_is_not_receive_stream() { + let request = L7Request { + action: "GET".to_string(), + target: "/mcp".to_string(), + query_params: HashMap::new(), + raw_header: b"GET /mcp HTTP/1.1\r\nHost: mcp.test\r\nAccept: application/json\r\n\r\n" + .to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + + assert!(!jsonrpc_receive_stream_request(&request)); + } + + #[test] + fn rejects_requests_missing_jsonrpc_version() { + let body = br#"{"id":1,"method":"tools/list"}"#; + let info = parse_jsonrpc_body(body); + + assert!(info.calls.is_empty()); + assert_eq!( + info.error.as_deref(), + Some("missing or non-string 'jsonrpc' field") + ); + } + + #[test] + fn rejects_batch_items_missing_jsonrpc_version() { + let body = br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"id":2,"method":"tools/call","params":{"name":"read_status"}} + ]"#; + let info = parse_jsonrpc_body(body); + + assert!(info.calls.is_empty()); + assert!(info.is_batch); + assert_eq!( + info.error.as_deref(), + Some("batch item invalid: missing or non-string 'jsonrpc' field") + ); + } + + #[test] + fn rejects_unsupported_jsonrpc_version() { + let body = br#"{"jsonrpc":"1.0","id":1,"method":"tools/list"}"#; + let info = parse_jsonrpc_body(body); + + assert!(info.calls.is_empty()); + assert_eq!( + info.error.as_deref(), + Some("unsupported JSON-RPC version '1.0'") + ); + } + + #[test] + fn detects_flattened_param_collisions() { + let mut params = HashMap::from([( + "arguments.scope".to_string(), + FlattenedParam { + value: "first".to_string(), + path_segments: 2, + }, + )]); + + let error = insert_flattened_param(&mut params, "arguments.scope", "second".to_string(), 2) + .expect_err("duplicate flattened key should be ambiguous"); + + assert!(error.contains("ambiguous params key collision")); + } + + #[test] + fn parses_valid_batch_without_error() { + let body = br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_status"}} + ]"#; + let info = parse_jsonrpc_body(body); + assert!(info.error.is_none()); + assert!(info.is_batch); + assert!(!info.has_response); + assert_eq!(info.calls.len(), 2); + assert_eq!(info.calls[0].method, "tools/list"); + assert_eq!(info.calls[1].method, "tools/call"); + assert_eq!( + info.calls[1].params.get("name").map(String::as_str), + Some("read_status") + ); + } + + #[test] + fn parses_batch_with_calls_and_responses() { + let body = br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"jsonrpc":"2.0","id":2,"result":{"ok":true}} + ]"#; + let info = parse_jsonrpc_body(body); + + assert!(info.error.is_none()); + assert!(info.is_batch); + assert!(info.has_response); + assert_eq!(info.calls.len(), 1); + assert_eq!(info.calls[0].method, "tools/list"); + } + + #[test] + fn rejects_invalid_jsonrpc_response_body() { + let body = + br#"{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-32603,"message":"failed"}}"#; + let info = parse_jsonrpc_body(body); + + assert!(info.calls.is_empty()); + assert!(!info.has_response); + assert_eq!( + info.error.as_deref(), + Some("JSON-RPC response includes both result and error") + ); + } + + #[test] + fn rejects_message_with_method_and_result_or_error() { + let result_body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","result":{}}"#; + let result_info = parse_jsonrpc_body(result_body); + assert!(result_info.calls.is_empty()); + assert_eq!( + result_info.error.as_deref(), + Some("JSON-RPC message includes both method and result/error") + ); + + let error_body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","error":{"code":-32603,"message":"failed"}}"#; + let error_info = parse_jsonrpc_body(error_body); + assert!(error_info.calls.is_empty()); + assert_eq!( + error_info.error.as_deref(), + Some("JSON-RPC message includes both method and result/error") + ); + } + + #[test] + fn rejects_batch_item_with_method_and_result() { + let body = br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"jsonrpc":"2.0","id":2,"method":"initialize","result":{}} + ]"#; + let info = parse_jsonrpc_body(body); + + assert!(info.calls.is_empty()); + assert!(info.is_batch); + assert_eq!( + info.error.as_deref(), + Some("batch item invalid: JSON-RPC message includes both method and result/error") + ); + } + + #[test] + fn params_digest_is_canonical_and_redacted() { + let first = parse_jsonrpc_body( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"submit_report","arguments":{"scope":"workspace/main"}}}"#, + ); + let reordered = parse_jsonrpc_body( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"arguments":{"scope":"workspace/main"},"name":"submit_report"}}"#, + ); + let changed = parse_jsonrpc_body( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"submit_report","arguments":{"scope":"workspace/other"}}}"#, + ); + + let digest = first.params_sha256().expect("params digest"); + assert_eq!(Some(digest.as_str()), reordered.params_sha256().as_deref()); + assert_ne!(Some(digest.as_str()), changed.params_sha256().as_deref()); + assert_eq!(digest.len(), 64); + assert!(digest.chars().all(|c| c.is_ascii_hexdigit())); + assert!(!digest.contains("workspace/main")); + assert!(!digest.contains("submit_report")); + } + + #[test] + fn batch_params_digest_covers_call_params_without_raw_values() { + let batch = parse_jsonrpc_body( + br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"blocked_action"}} + ]"#, + ); + let empty_batch = parse_jsonrpc_body( + br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"jsonrpc":"2.0","id":2,"method":"initialize"} + ]"#, + ); + + let digest = batch.params_sha256().expect("batch params digest"); + assert_eq!(digest.len(), 64); + assert!(digest.chars().all(|c| c.is_ascii_hexdigit())); + assert!(!digest.contains("blocked_action")); + assert!(empty_batch.params_sha256().is_none()); + } +} diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 802058ec2..4aae9d075 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -9,7 +9,9 @@ //! evaluated against OPA policy, and either forwarded or denied. pub mod graphql; +pub(crate) mod http; pub mod inference; +pub mod jsonrpc; pub mod path; pub mod provider; pub mod relay; @@ -25,6 +27,7 @@ pub enum L7Protocol { Websocket, Graphql, Sql, + JsonRpc, } impl L7Protocol { @@ -34,6 +37,7 @@ impl L7Protocol { "websocket" => Some(Self::Websocket), "graphql" => Some(Self::Graphql), "sql" => Some(Self::Sql), + "json-rpc" => Some(Self::JsonRpc), _ => None, } } @@ -76,6 +80,8 @@ pub struct L7EndpointConfig { pub enforcement: EnforcementMode, /// Maximum GraphQL request body bytes to buffer for inspection. pub graphql_max_body_bytes: usize, + /// Maximum JSON-RPC request body bytes to buffer for inspection. + pub json_rpc_max_body_bytes: usize, /// When true, percent-encoded `/` (`%2F`) is preserved in path segments /// rather than rejected at the parser. Needed by upstreams like GitLab /// that embed `%2F` in namespaced project paths. Defaults to false. @@ -110,6 +116,8 @@ pub struct L7RequestInfo { pub query_params: std::collections::HashMap>, /// Parsed GraphQL operation metadata for GraphQL endpoints. pub graphql: Option, + /// Parsed JSON-RPC request metadata for JSON-RPC endpoints. + pub jsonrpc: Option, } /// Parse an L7 endpoint config from a regorus Value (returned by Rego query). @@ -165,6 +173,10 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { .and_then(|v| usize::try_from(v).ok()) .filter(|v| *v > 0) .unwrap_or(graphql::DEFAULT_MAX_BODY_BYTES); + let json_rpc_max_body_bytes = get_object_u64(val, "json_rpc_max_body_bytes") + .and_then(|v| usize::try_from(v).ok()) + .filter(|v| *v > 0) + .unwrap_or(jsonrpc::DEFAULT_MAX_BODY_BYTES); Some(L7EndpointConfig { protocol, @@ -172,6 +184,7 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { tls, enforcement, graphql_max_body_bytes, + json_rpc_max_body_bytes, allow_encoded_slash, websocket_credential_rewrite, request_body_credential_rewrite, @@ -470,6 +483,99 @@ fn validate_graphql_rule( validate_graphql_fields(errors, warnings, loc, rule.get("fields")); } +fn validate_l7_matcher_map( + errors: &mut Vec, + warnings: &mut Vec, + loc: &str, + value: &serde_json::Value, + label: &str, +) { + let Some(matchers) = value.as_object() else { + errors.push(format!("{loc}: expected map of {label} matchers")); + return; + }; + + for (param, matcher) in matchers { + validate_l7_matcher(errors, warnings, &format!("{loc}.{param}"), matcher); + } +} + +fn validate_l7_matcher( + errors: &mut Vec, + warnings: &mut Vec, + loc: &str, + matcher: &serde_json::Value, +) { + if let Some(glob_str) = matcher.as_str() { + if let Some(warning) = check_glob_syntax(glob_str) { + warnings.push(format!("{loc}: {warning}")); + } + return; + } + + let Some(matcher_obj) = matcher.as_object() else { + errors.push(format!("{loc}: expected string glob or object with `any`")); + return; + }; + + let has_any = matcher_obj.get("any").is_some(); + let has_glob = matcher_obj.get("glob").is_some(); + let has_unknown = matcher_obj.keys().any(|k| k != "any" && k != "glob"); + if has_unknown { + errors.push(format!( + "{loc}: unknown matcher keys; only `glob` or `any` are supported" + )); + return; + } + + if has_glob && has_any { + errors.push(format!( + "{loc}: matcher cannot specify both `glob` and `any`" + )); + return; + } + + if !has_glob && !has_any { + errors.push(format!( + "{loc}: object matcher requires `glob` string or non-empty `any` list" + )); + return; + } + + if has_glob { + match matcher_obj.get("glob").and_then(|v| v.as_str()) { + None => errors.push(format!("{loc}.glob: expected glob string")), + Some(g) => { + if let Some(warning) = check_glob_syntax(g) { + warnings.push(format!("{loc}.glob: {warning}")); + } + } + } + return; + } + + let any = matcher_obj.get("any").and_then(|v| v.as_array()); + let Some(any) = any else { + errors.push(format!("{loc}.any: expected array of glob strings")); + return; + }; + + if any.is_empty() { + errors.push(format!("{loc}.any: list must not be empty")); + return; + } + + if any.iter().any(|v| v.as_str().is_none()) { + errors.push(format!("{loc}.any: all values must be strings")); + } + + for item in any.iter().filter_map(|v| v.as_str()) { + if let Some(warning) = check_glob_syntax(item) { + warnings.push(format!("{loc}.any: {warning}")); + } + } +} + fn json_rule_has_graphql_fields(rule: &serde_json::Value) -> bool { rule.get("operation_type") .and_then(|v| v.as_str()) @@ -485,6 +591,48 @@ fn json_rule_has_transport_fields(rule: &serde_json::Value) -> bool { rule.get("method").is_some() || rule.get("path").is_some() || rule.get("query").is_some() } +fn json_rule_has_non_empty_jsonrpc_params(rule: &serde_json::Value) -> bool { + rule.get("params") + .is_some_and(|v| !v.is_null() && !v.as_object().is_some_and(serde_json::Map::is_empty)) +} + +fn json_rule_has_non_empty_path_or_query(rule: &serde_json::Value) -> bool { + rule.get("path") + .and_then(|v| v.as_str()) + .is_some_and(|v| !v.is_empty()) + || rule + .get("query") + .and_then(|v| v.as_object()) + .is_some_and(|v| !v.is_empty()) +} + +fn validate_jsonrpc_rule( + errors: &mut Vec, + warnings: &mut Vec, + loc: &str, + rule: &serde_json::Value, + kind: &str, +) { + let method = rule.get("method").and_then(|v| v.as_str()).unwrap_or(""); + if method.is_empty() { + errors.push(format!( + "{loc}: JSON-RPC {kind} rules must specify method, for example \"*\"" + )); + } else if let Some(warning) = check_glob_syntax(method) { + warnings.push(format!("{loc}.method: {warning}")); + } + + if json_rule_has_non_empty_path_or_query(rule) { + errors.push(format!( + "{loc}: JSON-RPC {kind} rules must use method/params, not path/query" + )); + } + + if let Some(params) = rule.get("params").filter(|v| !v.is_null()) { + validate_l7_matcher_map(errors, warnings, &format!("{loc}.params"), params, "params"); + } +} + fn json_endpoint_has_graphql_policy(ep: &serde_json::Value) -> bool { ep.get("graphql_persisted_queries") .and_then(|v| v.as_object()) @@ -589,6 +737,18 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< errors.push(format!("{loc}: rules and access are mutually exclusive")); } + if protocol == "json-rpc" && !access.is_empty() { + errors.push(format!( + "{loc}: protocol json-rpc does not support access presets; use explicit rules with allow.method such as \"*\"" + )); + } + + if protocol == "json-rpc" && !has_rules { + errors.push(format!( + "{loc}: protocol json-rpc requires explicit rules with allow.method" + )); + } + // protocol requires rules or access if !protocol.is_empty() && !has_rules && access.is_empty() { errors.push(format!( @@ -598,7 +758,7 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< if !protocol.is_empty() && L7Protocol::parse(protocol).is_none() { errors.push(format!( - "{loc}: unknown protocol '{protocol}' (expected rest, websocket, graphql, or sql)" + "{loc}: unknown protocol '{protocol}' (expected rest, websocket, graphql, sql, or json-rpc)" )); } @@ -624,6 +784,18 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< } } + if ep.get("json_rpc_max_body_bytes").is_some() { + let valid_max = ep + .get("json_rpc_max_body_bytes") + .and_then(serde_json::Value::as_u64) + .is_some_and(|v| v > 0); + if !valid_max { + errors.push(format!( + "{loc}: json_rpc_max_body_bytes must be a positive integer" + )); + } + } + if protocol != "graphql" && protocol != "websocket" && (ep.get("persisted_queries").is_some() @@ -635,6 +807,12 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< )); } + if protocol != "json-rpc" && ep.get("json_rpc_max_body_bytes").is_some() { + warnings.push(format!( + "{loc}: JSON-RPC-specific endpoint fields are ignored unless protocol is json-rpc" + )); + } + if ep .get("websocket_credential_rewrite") .and_then(serde_json::Value::as_bool) @@ -754,101 +932,13 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< // Validate query matchers — mirrors allow-side validation exactly if let Some(query) = deny_rule.get("query").filter(|v| !v.is_null()) { - let Some(query_obj) = query.as_object() else { - errors.push(format!( - "{deny_loc}.query: expected map of query matchers" - )); - continue; - }; - - for (param, matcher) in query_obj { - if let Some(glob_str) = matcher.as_str() { - if let Some(warning) = check_glob_syntax(glob_str) { - warnings - .push(format!("{deny_loc}.query.{param}: {warning}")); - } - continue; - } - - let Some(matcher_obj) = matcher.as_object() else { - errors.push(format!( - "{deny_loc}.query.{param}: expected string glob or object with `any`" - )); - continue; - }; - - let has_any = matcher_obj.get("any").is_some(); - let has_glob = matcher_obj.get("glob").is_some(); - let has_unknown = - matcher_obj.keys().any(|k| k != "any" && k != "glob"); - if has_unknown { - errors.push(format!( - "{deny_loc}.query.{param}: unknown matcher keys; only `glob` or `any` are supported" - )); - continue; - } - - if has_glob && has_any { - errors.push(format!( - "{deny_loc}.query.{param}: matcher cannot specify both `glob` and `any`" - )); - continue; - } - - if !has_glob && !has_any { - errors.push(format!( - "{deny_loc}.query.{param}: object matcher requires `glob` string or non-empty `any` list" - )); - continue; - } - - if has_glob { - match matcher_obj.get("glob").and_then(|v| v.as_str()) { - None => { - errors.push(format!( - "{deny_loc}.query.{param}.glob: expected glob string" - )); - } - Some(g) => { - if let Some(warning) = check_glob_syntax(g) { - warnings.push(format!( - "{deny_loc}.query.{param}.glob: {warning}" - )); - } - } - } - continue; - } - - let any = matcher_obj.get("any").and_then(|v| v.as_array()); - let Some(any) = any else { - errors.push(format!( - "{deny_loc}.query.{param}.any: expected array of glob strings" - )); - continue; - }; - - if any.is_empty() { - errors.push(format!( - "{deny_loc}.query.{param}.any: list must not be empty" - )); - continue; - } - - if any.iter().any(|v| v.as_str().is_none()) { - errors.push(format!( - "{deny_loc}.query.{param}.any: all values must be strings" - )); - } - - for item in any.iter().filter_map(|v| v.as_str()) { - if let Some(warning) = check_glob_syntax(item) { - warnings.push(format!( - "{deny_loc}.query.{param}.any: {warning}" - )); - } - } - } + validate_l7_matcher_map( + &mut errors, + &mut warnings, + &format!("{deny_loc}.query"), + query, + "query", + ); } // SQL command validation @@ -883,6 +973,20 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< "{deny_loc}: GraphQL rule fields are ignored unless protocol is graphql or websocket" )); } + + if protocol == "json-rpc" { + validate_jsonrpc_rule( + &mut errors, + &mut warnings, + &deny_loc, + deny_rule, + "deny", + ); + } else if json_rule_has_non_empty_jsonrpc_params(deny_rule) { + errors.push(format!( + "{deny_loc}: params are only valid on protocol json-rpc endpoints" + )); + } } } } @@ -924,101 +1028,13 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< continue; }; - let Some(query_obj) = query.as_object() else { - errors.push(format!( - "{loc}.rules[{rule_idx}].allow.query: expected map of query matchers" - )); - continue; - }; - - for (param, matcher) in query_obj { - if let Some(glob_str) = matcher.as_str() { - if let Some(warning) = check_glob_syntax(glob_str) { - warnings.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}: {warning}" - )); - } - continue; - } - - let Some(matcher_obj) = matcher.as_object() else { - errors.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}: expected string glob or object with `any`" - )); - continue; - }; - - let has_any = matcher_obj.get("any").is_some(); - let has_glob = matcher_obj.get("glob").is_some(); - let has_unknown = matcher_obj.keys().any(|k| k != "any" && k != "glob"); - if has_unknown { - errors.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}: unknown matcher keys; only `glob` or `any` are supported" - )); - continue; - } - - if has_glob && has_any { - errors.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}: matcher cannot specify both `glob` and `any`" - )); - continue; - } - - if !has_glob && !has_any { - errors.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}: object matcher requires `glob` string or non-empty `any` list" - )); - continue; - } - - if has_glob { - match matcher_obj.get("glob").and_then(|v| v.as_str()) { - None => { - errors.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}.glob: expected glob string" - )); - } - Some(g) => { - if let Some(warning) = check_glob_syntax(g) { - warnings.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}.glob: {warning}" - )); - } - } - } - continue; - } - - let any = matcher_obj.get("any").and_then(|v| v.as_array()); - let Some(any) = any else { - errors.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}.any: expected array of glob strings" - )); - continue; - }; - - if any.is_empty() { - errors.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}.any: list must not be empty" - )); - continue; - } - - if any.iter().any(|v| v.as_str().is_none()) { - errors.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}.any: all values must be strings" - )); - } - - for item in any.iter().filter_map(|v| v.as_str()) { - if let Some(warning) = check_glob_syntax(item) { - warnings.push(format!( - "{loc}.rules[{rule_idx}].allow.query.{param}.any: {warning}" - )); - } - } - } + validate_l7_matcher_map( + &mut errors, + &mut warnings, + &format!("{loc}.rules[{rule_idx}].allow.query"), + query, + "query", + ); } } } @@ -1028,6 +1044,19 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< let allow = rule.get("allow").unwrap_or(rule); let rule_loc = format!("{loc}.rules[{rule_idx}].allow"); let allow_has_graphql = json_rule_has_graphql_fields(allow); + if protocol == "json-rpc" { + validate_jsonrpc_rule( + &mut errors, + &mut warnings, + &rule_loc, + allow, + "allow", + ); + } else if json_rule_has_non_empty_jsonrpc_params(allow) { + errors.push(format!( + "{rule_loc}: params are only valid on protocol json-rpc endpoints" + )); + } if websocket_has_graphql_policy && allow .get("method") @@ -1500,6 +1529,241 @@ mod tests { assert!(errors.iter().any(|e| e.contains("mutually exclusive"))); } + #[test] + fn validate_jsonrpc_rejects_access_presets() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "mcp.example.com", + "port": 443, + "path": "/mcp", + "protocol": "json-rpc", + "access": "full" + }], + "binaries": [] + } + } + }); + let (errors, _warnings) = validate_l7_policies(&data); + assert!( + errors.iter().any(|e| { + e.contains("json-rpc") + && e.contains("does not support access presets") + && e.contains("method") + }), + "JSON-RPC access presets should be rejected: {errors:?}" + ); + } + + #[test] + fn validate_jsonrpc_requires_method_rules() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "mcp.example.com", + "port": 443, + "path": "/mcp", + "protocol": "json-rpc", + "rules": [{ + "allow": { + "path": "/mcp" + } + }] + }], + "binaries": [] + } + } + }); + let (errors, _warnings) = validate_l7_policies(&data); + assert!( + errors + .iter() + .any(|e| { e.contains("JSON-RPC allow rules must specify method") }), + "JSON-RPC allow rules without method should be rejected: {errors:?}" + ); + assert!( + errors + .iter() + .any(|e| { e.contains("must use method/params, not path/query") }), + "JSON-RPC allow rules with path/query should be rejected: {errors:?}" + ); + } + + #[test] + fn validate_jsonrpc_fields_rejected_on_non_jsonrpc_endpoints() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "api.example.com", + "port": 443, + "protocol": "rest", + "rules": [{ + "allow": { + "method": "GET", + "path": "/v1/**", + "params": { "name": "read_status" } + } + }], + "deny_rules": [{ + "method": "POST", + "path": "/v1/**", + "params": { "name": "purge_cache" } + }] + }], + "binaries": [] + } + } + }); + let (errors, _warnings) = validate_l7_policies(&data); + assert!( + errors + .iter() + .any(|e| { e.contains("rules[0].allow") && e.contains("params are only valid") }), + "REST allow rules with JSON-RPC fields should be rejected: {errors:?}" + ); + assert!( + errors + .iter() + .any(|e| { e.contains("deny_rules[0]") && e.contains("params are only valid") }), + "REST deny rules with JSON-RPC fields should be rejected: {errors:?}" + ); + } + + #[test] + fn validate_jsonrpc_deny_rules_require_method() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "mcp.example.com", + "port": 443, + "path": "/mcp", + "protocol": "json-rpc", + "rules": [{ + "allow": { + "method": "*" + } + }], + "deny_rules": [{ + "params": { "name": "delete_resource" } + }] + }], + "binaries": [] + } + } + }); + let (errors, _warnings) = validate_l7_policies(&data); + assert!( + errors + .iter() + .any(|e| e.contains("JSON-RPC deny rules must specify method")), + "JSON-RPC deny rules without method should be rejected: {errors:?}" + ); + } + + #[test] + fn validate_jsonrpc_params_reuse_query_matcher_validation() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "mcp.example.com", + "port": 443, + "path": "/mcp", + "protocol": "json-rpc", + "rules": [{ + "allow": { + "method": "tools/call", + "params": { + "name": { "mode": "read-*" }, + "scope": { "any": [] }, + "count": 1 + } + } + }], + "deny_rules": [{ + "method": "tools/delete", + "params": { + "name": { "glob": "delete_*", "any": ["purge_*"] } + } + }] + }], + "binaries": [] + } + } + }); + let (errors, _warnings) = validate_l7_policies(&data); + assert!( + errors + .iter() + .any(|e| e.contains("allow.params.name") && e.contains("unknown matcher keys")), + "JSON-RPC params should reject unknown matcher keys: {errors:?}" + ); + assert!( + errors + .iter() + .any(|e| e.contains("allow.params.scope.any") && e.contains("must not be empty")), + "JSON-RPC params should reject empty any lists: {errors:?}" + ); + assert!( + errors.iter().any(|e| { + e.contains("allow.params.count") && e.contains("expected string glob or object") + }), + "JSON-RPC params should reject non-string/non-object matchers: {errors:?}" + ); + assert!( + errors.iter().any(|e| { + e.contains("deny_rules[0].params.name") && e.contains("matcher cannot specify both") + }), + "JSON-RPC deny params should reject conflicting matcher forms: {errors:?}" + ); + } + + #[test] + fn validate_method_and_params_warn_on_malformed_globs() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "mcp.example.com", + "port": 443, + "path": "/mcp", + "protocol": "json-rpc", + "rules": [{ + "allow": { + "method": "tools/[call", + "params": { + "name": "[bad" + } + } + }] + }], + "binaries": [] + } + } + }); + let (errors, warnings) = validate_l7_policies(&data); + assert!( + errors.is_empty(), + "malformed globs should warn, not error: {errors:?}" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("allow.method") && w.contains("unclosed '['")), + "expected method glob warning, got: {warnings:?}" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("allow.params.name") && w.contains("unclosed '['")), + "expected params glob warning, got: {warnings:?}" + ); + } + #[test] fn validate_protocol_requires_rules_or_access() { let data = serde_json::json!({ diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 830e3461b..5a397911b 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -139,6 +139,7 @@ fn emit_parse_rejection(ctx: &L7EvalContext, detail: &str, engine_type: &str) { fn engine_type_for_protocol(protocol: L7Protocol) -> &'static str { match protocol { L7Protocol::Graphql => "l7-graphql", + L7Protocol::JsonRpc => "l7-jsonrpc", L7Protocol::Websocket => "l7-websocket", L7Protocol::Rest | L7Protocol::Sql => "l7", } @@ -215,6 +216,7 @@ where .into_diagnostic()?; Ok(()) } + L7Protocol::JsonRpc => relay_jsonrpc(config, &engine, client, upstream, ctx).await, } } @@ -338,6 +340,7 @@ where target: redacted_target.clone(), query_params: req.query_params.clone(), graphql: graphql_info.clone(), + jsonrpc: None, }; let websocket_request = crate::l7::rest::request_is_websocket_upgrade(&req.raw_header); if config.protocol == L7Protocol::Websocket && !websocket_request { @@ -382,7 +385,7 @@ where let engine_type = match config.protocol { L7Protocol::Graphql => "l7-graphql", L7Protocol::Websocket => "l7-websocket", - L7Protocol::Rest | L7Protocol::Sql => "l7", + L7Protocol::Rest | L7Protocol::Sql | L7Protocol::JsonRpc => "l7", }; emit_l7_request_log( ctx, @@ -739,6 +742,7 @@ where target: redacted_target.clone(), query_params: req.query_params.clone(), graphql: None, + jsonrpc: None, }; let websocket_request = crate::l7::rest::request_is_websocket_upgrade(&req.raw_header); if config.protocol == L7Protocol::Websocket && !websocket_request { @@ -930,6 +934,178 @@ fn close_if_stale(guard: &PolicyGenerationGuard, ctx: &L7EvalContext) -> bool { true } +async fn relay_jsonrpc( + config: &L7EndpointConfig, + engine: &TunnelPolicyEngine, + client: &mut C, + upstream: &mut U, + ctx: &L7EvalContext, +) -> Result<()> +where + C: AsyncRead + AsyncWrite + Unpin + Send, + U: AsyncRead + AsyncWrite + Unpin + Send, +{ + loop { + if close_if_stale(engine.generation_guard(), ctx) { + return Ok(()); + } + + let parsed = match crate::l7::jsonrpc::parse_jsonrpc_http_request( + client, + config.json_rpc_max_body_bytes, + crate::l7::path::CanonicalizeOptions { + allow_encoded_slash: config.allow_encoded_slash, + ..Default::default() + }, + ) + .await + { + Ok(Some(parsed)) => parsed, + Ok(None) => return Ok(()), + Err(e) => { + if is_benign_connection_error(&e) { + debug!( + host = %ctx.host, + port = ctx.port, + error = %e, + "JSON-RPC L7 connection closed" + ); + } else { + let detail = + parse_rejection_detail(&e.to_string(), ParseRejectionMode::L7Endpoint); + emit_parse_rejection(ctx, &detail, "l7-jsonrpc"); + } + return Ok(()); + } + }; + + let req = parsed.request; + let jsonrpc_info = parsed.info; + + if close_if_stale(engine.generation_guard(), ctx) { + return Ok(()); + } + + let redacted_target = req.target.clone(); + + let request_info = L7RequestInfo { + action: req.action.clone(), + target: redacted_target.clone(), + query_params: req.query_params.clone(), + graphql: None, + jsonrpc: Some(jsonrpc_info.clone()), + }; + + let parse_error_reason = jsonrpc_info + .error + .as_deref() + .map(|e| format!("JSON-RPC request rejected: {e}")); + let response_frame_reason = jsonrpc_info + .has_response + .then(|| JSONRPC_RESPONSE_FRAME_DENY_REASON.to_string()); + let force_deny = parse_error_reason.is_some() || response_frame_reason.is_some(); + let (allowed, reason, jsonrpc_log_info) = if let Some(reason) = parse_error_reason { + (false, reason, jsonrpc_info.clone()) + } else if let Some(reason) = response_frame_reason { + (false, reason, jsonrpc_info.clone()) + } else { + let evaluation = + evaluate_jsonrpc_l7_request_for_log(engine, ctx, &request_info, &jsonrpc_info)?; + (evaluation.allowed, evaluation.reason, evaluation.log_info) + }; + + if close_if_stale(engine.generation_guard(), ctx) { + return Ok(()); + } + + let decision_str = match (allowed, config.enforcement) { + (_, _) if force_deny => "deny", + (true, _) => "allow", + (false, EnforcementMode::Audit) => "audit", + (false, EnforcementMode::Enforce) => "deny", + }; + + { + let (action_id, disposition_id, severity) = match decision_str { + "deny" => (ActionId::Denied, DispositionId::Blocked, SeverityId::Medium), + _ => ( + ActionId::Allowed, + DispositionId::Allowed, + SeverityId::Informational, + ), + }; + let endpoint = format!("{}:{}{}", ctx.host, ctx.port, redacted_target); + let params_sha256 = jsonrpc_log_info + .params_sha256() + .unwrap_or_else(|| "".to_string()); + let policy_version = engine.captured_generation(); + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(action_id) + .disposition(disposition_id) + .severity(severity) + .http_request(HttpRequest::new( + &request_info.action, + OcsfUrl::new("http", &ctx.host, &redacted_target, ctx.port), + )) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, "l7-jsonrpc") + .message(jsonrpc_log_message( + decision_str, + &request_info.action, + &endpoint, + &jsonrpc_log_info, + ¶ms_sha256, + policy_version, + &reason, + )) + .build(); + ocsf_emit!(event); + } + + if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { + let outcome = crate::l7::rest::relay_http_request_with_resolver_guarded( + &req, + client, + upstream, + ctx.secret_resolver.as_deref(), + Some(engine.generation_guard()), + ) + .await?; + match outcome { + RelayOutcome::Reusable => {} + RelayOutcome::Consumed => { + debug!( + host = %ctx.host, + port = ctx.port, + "Upstream connection not reusable, closing JSON-RPC L7 relay" + ); + return Ok(()); + } + RelayOutcome::Upgraded { .. } => { + return Ok(()); + } + } + } else { + crate::l7::rest::RestProvider::default() + .deny_with_redacted_target( + &req, + &ctx.policy_name, + &reason, + client, + Some(&redacted_target), + Some(crate::l7::rest::DenyResponseContext { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + }), + ) + .await?; + return Ok(()); + } + } +} + async fn relay_graphql( config: &L7EndpointConfig, engine: &TunnelPolicyEngine, @@ -1011,6 +1187,7 @@ where target: redacted_target.clone(), query_params: req.query_params.clone(), graphql: Some(graphql_info.clone()), + jsonrpc: None, }; // Malformed or ambiguous GraphQL requests, such as duplicated GET @@ -1159,6 +1336,41 @@ fn graphql_log_summary(info: &crate::l7::graphql::GraphqlRequestInfo) -> String format!("graphql_ops={}", ops.join(";")) } +pub(crate) fn jsonrpc_log_message( + decision: &str, + http_method: &str, + endpoint: &str, + info: &crate::l7::jsonrpc::JsonRpcRequestInfo, + params_sha256: &str, + policy_version: u64, + reason: &str, +) -> String { + let rule_methods = rule_method_names_for_log(info); + format!( + "JSONRPC_L7_REQUEST decision={decision} http_method={http_method} endpoint={endpoint} rule_methods={rule_methods} params_sha256={params_sha256} policy_version={policy_version} reason={reason}" + ) +} + +pub(crate) fn rule_method_names_for_log(info: &crate::l7::jsonrpc::JsonRpcRequestInfo) -> String { + if info.calls.is_empty() { + return "-".to_string(); + } + info.calls + .iter() + .map(|call| call.method.as_str()) + .collect::>() + .join(",") +} + +struct JsonRpcEvaluation { + allowed: bool, + reason: String, + log_info: crate::l7::jsonrpc::JsonRpcRequestInfo, +} + +pub(crate) const JSONRPC_RESPONSE_FRAME_DENY_REASON: &str = + "JSON-RPC response frames are not permitted from client to server"; + /// Check if a miette error represents a benign connection close. /// /// TLS handshake EOF, missing `close_notify`, connection resets, and broken @@ -1184,6 +1396,106 @@ pub fn evaluate_l7_request( engine: &TunnelPolicyEngine, ctx: &L7EvalContext, request: &L7RequestInfo, +) -> Result<(bool, String)> { + if let Some(jsonrpc) = &request.jsonrpc + && jsonrpc.has_response + { + return Ok((false, JSONRPC_RESPONSE_FRAME_DENY_REASON.to_string())); + } + + if let Some(jsonrpc) = &request.jsonrpc + && jsonrpc.is_batch + && !jsonrpc.calls.is_empty() + { + for call in &jsonrpc.calls { + let item_request = jsonrpc_request_for_call(request, call); + let (allowed, reason) = evaluate_l7_request_once(engine, ctx, &item_request)?; + if !allowed { + return Ok((false, reason)); + } + } + return Ok((true, String::new())); + } + + evaluate_l7_request_once(engine, ctx, request) +} + +fn evaluate_jsonrpc_l7_request_for_log( + engine: &TunnelPolicyEngine, + ctx: &L7EvalContext, + request: &L7RequestInfo, + jsonrpc: &crate::l7::jsonrpc::JsonRpcRequestInfo, +) -> Result { + if jsonrpc.has_response { + return Ok(JsonRpcEvaluation { + allowed: false, + reason: JSONRPC_RESPONSE_FRAME_DENY_REASON.to_string(), + log_info: jsonrpc.clone(), + }); + } + + if jsonrpc.is_batch && !jsonrpc.calls.is_empty() { + let mut denied_calls = Vec::new(); + let mut first_denied_reason = None; + for call in &jsonrpc.calls { + let item_request = jsonrpc_request_for_call(request, call); + let (allowed, reason) = evaluate_l7_request_once(engine, ctx, &item_request)?; + if !allowed { + if first_denied_reason.is_none() { + first_denied_reason = Some(reason); + } + denied_calls.push(call.clone()); + } + } + + if denied_calls.is_empty() { + return Ok(JsonRpcEvaluation { + allowed: true, + reason: String::new(), + log_info: jsonrpc.clone(), + }); + } + + return Ok(JsonRpcEvaluation { + allowed: false, + reason: first_denied_reason.unwrap_or_else(|| "request denied by policy".to_string()), + log_info: crate::l7::jsonrpc::JsonRpcRequestInfo { + calls: denied_calls, + is_batch: true, + receive_stream: false, + has_response: false, + error: None, + }, + }); + } + + let (allowed, reason) = evaluate_l7_request_once(engine, ctx, request)?; + Ok(JsonRpcEvaluation { + allowed, + reason, + log_info: jsonrpc.clone(), + }) +} + +fn jsonrpc_request_for_call( + request: &L7RequestInfo, + call: &crate::l7::jsonrpc::JsonRpcCallInfo, +) -> L7RequestInfo { + let mut item_request = request.clone(); + item_request.jsonrpc = Some(crate::l7::jsonrpc::JsonRpcRequestInfo { + calls: vec![call.clone()], + is_batch: false, + receive_stream: false, + has_response: false, + error: None, + }); + item_request +} + +fn evaluate_l7_request_once( + engine: &TunnelPolicyEngine, + ctx: &L7EvalContext, + request: &L7RequestInfo, ) -> Result<(bool, String)> { if engine.is_stale() { return Err(miette!( @@ -1208,6 +1520,16 @@ pub fn evaluate_l7_request( "path": request.target, "query_params": request.query_params.clone(), "graphql": request.graphql.clone(), + "jsonrpc": request.jsonrpc.as_ref().map(|j| { + let call = if j.is_batch { None } else { j.calls.first() }; + serde_json::json!({ + "method": call.map(|call| call.method.as_str()), + "params": call.map(|call| call.params.clone()).unwrap_or_default(), + "receive_stream": j.receive_stream, + "has_response": j.has_response, + "error": j.error, + }) + }), } }); @@ -1521,6 +1843,52 @@ network_policies: (generation_guard, ctx, fixture) } + fn jsonrpc_test_relay_context() -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + let data = r" +network_policies: + mcp_api: + name: mcp_api + endpoints: + - host: mcp.example.test + port: 8000 + path: /mcp + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: initialize + binaries: + - { path: /usr/bin/python3 } +"; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "mcp.example.test".into(), + port: 8000, + binary_path: PathBuf::from("/usr/bin/python3"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .unwrap(); + let config = crate::l7::parse_l7_config(&endpoint_config.unwrap()).unwrap(); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "mcp.example.test".into(), + port: 8000, + policy_name: "mcp_api".into(), + binary_path: "/usr/bin/python3".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + (config, tunnel_engine, ctx) + } + fn authorization_header_count(headers: &str) -> usize { headers .lines() @@ -1841,6 +2209,7 @@ network_policies: target: "/ws".into(), query_params: std::collections::HashMap::new(), graphql: None, + jsonrpc: None, }; let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); @@ -1849,6 +2218,264 @@ network_policies: assert!(reason.contains("WEBSOCKET_TEXT /ws not permitted")); } + #[test] + fn jsonrpc_batch_evaluates_each_call() { + let data = r#" +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: api.example.test + port: 443 + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: "tools/list" + - allow: + method: "tools/call" + params: + name: read_status + deny_rules: + - method: "tools/call" + params: + name: blocked_action + - method: "tools/delete" + binaries: + - { path: /usr/bin/node } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let tunnel_engine = engine + .clone_engine_for_tunnel(engine.current_generation()) + .unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "jsonrpc_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + let mut request = L7RequestInfo { + action: "POST".into(), + target: "/mcp".into(), + query_params: std::collections::HashMap::new(), + graphql: None, + jsonrpc: Some(crate::l7::jsonrpc::parse_jsonrpc_body( + br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_status"}} + ]"#, + )), + }; + + let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); + assert!(allowed, "{reason}"); + + request.jsonrpc = Some(crate::l7::jsonrpc::parse_jsonrpc_body( + br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"jsonrpc":"2.0","id":2,"result":{"ok":true}} + ]"#, + )); + let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); + assert!(!allowed); + assert!(reason.contains("response frames")); + + let jsonrpc = request.jsonrpc.as_ref().expect("jsonrpc request"); + let evaluation = + evaluate_jsonrpc_l7_request_for_log(&tunnel_engine, &ctx, &request, jsonrpc).unwrap(); + assert!(!evaluation.allowed); + assert!(evaluation.log_info.has_response); + assert_eq!( + rule_method_names_for_log(&evaluation.log_info), + "tools/list" + ); + + request.jsonrpc = Some(crate::l7::jsonrpc::parse_jsonrpc_body( + br#"{"jsonrpc":"2.0","id":2,"result":{"ok":true}}"#, + )); + let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); + assert!(!allowed); + assert!(reason.contains("response frames")); + + let jsonrpc = request.jsonrpc.as_ref().expect("jsonrpc response"); + let evaluation = + evaluate_jsonrpc_l7_request_for_log(&tunnel_engine, &ctx, &request, jsonrpc).unwrap(); + assert!(!evaluation.allowed); + assert!(evaluation.log_info.has_response); + assert_eq!(rule_method_names_for_log(&evaluation.log_info), "-"); + + request.jsonrpc = Some(crate::l7::jsonrpc::parse_jsonrpc_body( + br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"blocked_action"}}, + {"jsonrpc":"2.0","id":3,"method":"tools/delete","params":{"name":"purge_cache"}} + ]"#, + )); + let (allowed, _) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); + assert!(!allowed); + + let jsonrpc = request.jsonrpc.as_ref().expect("jsonrpc request"); + let evaluation = + evaluate_jsonrpc_l7_request_for_log(&tunnel_engine, &ctx, &request, jsonrpc).unwrap(); + assert!(!evaluation.allowed); + assert!(evaluation.log_info.is_batch); + assert_eq!( + rule_method_names_for_log(&evaluation.log_info), + "tools/call,tools/delete" + ); + + let full_params_sha256 = jsonrpc.params_sha256().expect("full batch params digest"); + let log_params_sha256 = evaluation + .log_info + .params_sha256() + .expect("logged batch params digest"); + assert_ne!(full_params_sha256, log_params_sha256); + let message = jsonrpc_log_message( + "deny", + "POST", + "api.example.test:443/mcp", + &evaluation.log_info, + &log_params_sha256, + 42, + &evaluation.reason, + ); + assert!(message.contains("rule_methods=tools/call,tools/delete")); + assert!(message.contains("params_sha256=")); + assert!(!message.contains("params_sha256=sha256:")); + assert!(message.contains("policy_version=42")); + assert!(!message.contains("tools/list")); + assert!(!message.contains("blocked_action")); + assert!(!message.contains("purge_cache")); + } + + #[test] + fn jsonrpc_params_policy_matches_literal_dotted_keys() { + let data = r#" +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: api.example.test + port: 443 + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: "tools/call" + params: + arguments.scope: workspace/other + binaries: + - { path: /usr/bin/node } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let tunnel_engine = engine + .clone_engine_for_tunnel(engine.current_generation()) + .unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "jsonrpc_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + let mut request = L7RequestInfo { + action: "POST".into(), + target: "/mcp".into(), + query_params: std::collections::HashMap::new(), + graphql: None, + jsonrpc: Some(crate::l7::jsonrpc::parse_jsonrpc_body( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"arguments.scope":"workspace/other","arguments":{"scope":"workspace/main"}}}"#, + )), + }; + + let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); + assert!(allowed, "{reason}"); + + request.jsonrpc = Some(crate::l7::jsonrpc::parse_jsonrpc_body( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"arguments":{"scope":"workspace/other"}}}"#, + )); + let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); + assert!(allowed, "{reason}"); + } + + #[test] + fn jsonrpc_log_records_digest_not_args() { + let info = crate::l7::jsonrpc::parse_jsonrpc_body( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_resource","arguments":{"scope":"secret-scope"}}}"#, + ); + let params_sha256 = info.params_sha256().expect("params digest"); + let message = jsonrpc_log_message( + "deny", + "POST", + "mcp.example.com:443/mcp", + &info, + ¶ms_sha256, + 42, + "request denied by policy", + ); + + assert!(message.contains("endpoint=mcp.example.com:443/mcp")); + assert!(message.contains("rule_methods=tools/call")); + assert!(message.contains("params_sha256=")); + assert!(!message.contains("params_sha256=sha256:")); + assert!(message.contains("policy_version=42")); + assert!(!message.contains("delete_resource")); + assert!(!message.contains("secret-scope")); + + let batch = crate::l7::jsonrpc::parse_jsonrpc_body( + br#"[ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}, + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_resource"}} + ]"#, + ); + let batch_params_sha256 = batch.params_sha256().expect("batch params digest"); + let batch_message = jsonrpc_log_message( + "allow", + "POST", + "mcp.example.com:443/mcp", + &batch, + &batch_params_sha256, + 43, + "", + ); + + assert!(batch_message.starts_with("JSONRPC_L7_REQUEST ")); + assert!(batch_message.contains("rule_methods=tools/list,tools/call")); + assert!(batch_message.contains("params_sha256=")); + assert!(!batch_message.contains("params_sha256=sha256:")); + assert!(batch_message.contains("policy_version=43")); + assert!(!batch_message.contains("delete_resource")); + + let no_params = crate::l7::jsonrpc::parse_jsonrpc_body( + br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ); + let no_params_sha256 = no_params + .params_sha256() + .unwrap_or_else(|| "".to_string()); + let no_params_message = jsonrpc_log_message( + "allow", + "POST", + "mcp.example.com:443/mcp", + &no_params, + &no_params_sha256, + 44, + "", + ); + assert!(no_params_message.contains("rule_methods=initialize")); + assert!(no_params_message.contains("params_sha256=")); + } + #[tokio::test] async fn route_selected_websocket_upgrade_rejects_invalid_accept_without_forwarding_101() { let data = r#" @@ -1877,6 +2504,7 @@ network_policies: tls: crate::l7::TlsMode::Auto, enforcement: EnforcementMode::Enforce, graphql_max_body_bytes: 0, + json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, allow_encoded_slash: false, websocket_credential_rewrite: true, request_body_credential_rewrite: false, @@ -1980,6 +2608,7 @@ network_policies: tls: crate::l7::TlsMode::Auto, enforcement: EnforcementMode::Enforce, graphql_max_body_bytes: 0, + json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, allow_encoded_slash: false, websocket_credential_rewrite: true, request_body_credential_rewrite: false, @@ -2100,6 +2729,7 @@ network_policies: tls: crate::l7::TlsMode::Auto, enforcement: EnforcementMode::Enforce, graphql_max_body_bytes: 0, + json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, allow_encoded_slash: false, websocket_credential_rewrite: true, request_body_credential_rewrite: false, @@ -2456,4 +3086,116 @@ network_policies: "stale passthrough request must not be forwarded upstream" ); } + + #[tokio::test] + async fn jsonrpc_relay_forwards_allowed_method() { + let (config, tunnel_engine, ctx) = jsonrpc_test_relay_context(); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#; + let request = format!( + "POST /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + app.write_all(request.as_bytes()).await.unwrap(); + app.write_all(body).await.unwrap(); + + let mut upstream_buf = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_buf), + ) + .await + .expect("allowed JSON-RPC request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_buf[..n]); + assert!(upstream_request.starts_with("POST /mcp HTTP/1.1")); + assert!(upstream_request.contains(r#""method":"initialize""#)); + + upstream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 36\r\nConnection: close\r\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}", + ) + .await + .unwrap(); + + let mut response = [0u8; 512]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("upstream response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&response[..n]).contains("200 OK")); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should complete") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn jsonrpc_relay_denies_method_not_in_allow_list() { + let (config, tunnel_engine, ctx) = jsonrpc_test_relay_context(); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_repos"}}"#; + let request = format!( + "POST /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + app.write_all(request.as_bytes()).await.unwrap(); + app.write_all(body).await.unwrap(); + + let mut response = [0u8; 512]; + let n = tokio::time::timeout(std::time::Duration::from_secs(2), app.read(&mut response)) + .await + .expect("relay should respond without reaching upstream") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!( + response.contains("403"), + "tools/call not in allow list must be denied with 403, got: {response:?}" + ); + + let mut upstream_buf = [0u8; 128]; + let n = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_buf), + ) + .await + .unwrap_or(Ok(0)) + .unwrap_or(0); + assert_eq!(n, 0, "denied request must not be forwarded to upstream"); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should complete") + .unwrap() + .unwrap(); + } } diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 3ae46417f..7aa660ce1 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -1646,6 +1646,7 @@ where let header_str = String::from_utf8_lossy(&buf[..header_end]); let status_code = parse_status_code(&header_str).unwrap_or(200); let server_wants_close = parse_connection_close(&header_str); + let event_stream = response_is_event_stream(&header_str); let body_length = parse_body_length(&header_str)?; debug!( @@ -1705,19 +1706,29 @@ where // No explicit framing (no Content-Length, no Transfer-Encoding). // Per RFC 7230 §3.3.3 the body is delimited by connection close. if matches!(body_length, BodyLength::None) { - if server_wants_close { - // Server indicated it will close — read until EOF. + if server_wants_close || event_stream { + // Server indicated it will close, or this is a streaming response + // such as SSE where the body is intentionally delimited by EOF. let before_end = &buf[..header_end - 2]; client.write_all(before_end).await.into_diagnostic()?; - client - .write_all(b"Connection: close\r\n\r\n") - .await - .into_diagnostic()?; + if server_wants_close { + client + .write_all(b"Connection: close\r\n\r\n") + .await + .into_diagnostic()?; + } else { + client.write_all(b"\r\n").await.into_diagnostic()?; + } let overflow = &buf[header_end..]; if !overflow.is_empty() { client.write_all(overflow).await.into_diagnostic()?; + client.flush().await.into_diagnostic()?; + } + if event_stream { + relay_until_eof_without_idle_timeout(upstream, client).await?; + } else { + relay_until_eof(upstream, client).await?; } - relay_until_eof(upstream, client).await?; client.flush().await.into_diagnostic()?; return Ok(RelayOutcome::Consumed); } @@ -1787,6 +1798,19 @@ fn parse_connection_close(headers: &str) -> bool { false } +fn response_is_event_stream(headers: &str) -> bool { + headers.lines().skip(1).any(|line| { + let lower = line.to_ascii_lowercase(); + let Some(value) = lower.strip_prefix("content-type:") else { + return false; + }; + value + .split(';') + .next() + .is_some_and(|mime| mime.trim() == "text/event-stream") + }) +} + fn validate_websocket_response( headers: &str, mode: WebSocketExtensionMode, @@ -1991,7 +2015,10 @@ where loop { match tokio::time::timeout(RELAY_EOF_IDLE_TIMEOUT, reader.read(&mut buf)).await { Ok(Ok(0)) => return Ok(()), - Ok(Ok(n)) => writer.write_all(&buf[..n]).await.into_diagnostic()?, + Ok(Ok(n)) => { + writer.write_all(&buf[..n]).await.into_diagnostic()?; + writer.flush().await.into_diagnostic()?; + } Ok(Err(e)) => return Err(miette::miette!("{e}")), Err(_) => { debug!( @@ -2004,6 +2031,26 @@ where } } +/// Relay all bytes from reader to writer until EOF without an idle timeout. +/// +/// Used for server-sent events, where long idle gaps are part of the protocol +/// and do not mean the response body is complete. +async fn relay_until_eof_without_idle_timeout(reader: &mut R, writer: &mut W) -> Result<()> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let mut buf = [0u8; RELAY_BUF_SIZE]; + loop { + let n = reader.read(&mut buf).await.into_diagnostic()?; + if n == 0 { + return Ok(()); + } + writer.write_all(&buf[..n]).await.into_diagnostic()?; + writer.flush().await.into_diagnostic()?; + } +} + /// Detect if the first bytes look like an HTTP request. /// /// Checks for common HTTP methods at the start of the stream. @@ -3028,6 +3075,19 @@ mod tests { )); } + #[test] + fn test_response_is_event_stream() { + assert!(response_is_event_stream( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n" + )); + assert!(response_is_event_stream( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream; charset=utf-8\r\n\r\n" + )); + assert!(!response_is_event_stream( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n" + )); + } + #[test] fn test_is_bodiless_response() { assert!(is_bodiless_response("HEAD", 200)); @@ -3085,6 +3145,99 @@ mod tests { ); } + #[tokio::test] + async fn relay_response_no_framing_event_stream_reads_until_eof() { + let response = + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\nevent: message\ndata: {}\r\n\r\n"; + + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(4096); + let (mut client_read, mut client_write) = tokio::io::duplex(4096); + + tokio::spawn(async move { + upstream_write.write_all(response).await.unwrap(); + upstream_write.shutdown().await.unwrap(); + }); + + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + relay_response( + "GET", + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + ), + ) + .await + .expect("relay_response should not deadlock"); + + let outcome = result.expect("relay_response should succeed"); + assert!( + matches!(outcome, RelayOutcome::Consumed), + "event stream is consumed by read-until-EOF" + ); + + client_write.shutdown().await.unwrap(); + let mut received = Vec::new(); + client_read.read_to_end(&mut received).await.unwrap(); + let received_str = String::from_utf8_lossy(&received); + assert!(received_str.contains("Content-Type: text/event-stream")); + assert!(received_str.contains("event: message")); + } + + #[tokio::test] + async fn relay_response_no_framing_event_stream_survives_idle_gap() { + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(4096); + let (mut client_read, mut client_write) = tokio::io::duplex(4096); + + let upstream_task = tokio::spawn(async move { + upstream_write + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\n\r\n", + ) + .await + .unwrap(); + upstream_write + .write_all(b"event: first\ndata: {}\r\n\r\n") + .await + .unwrap(); + upstream_write.flush().await.unwrap(); + tokio::time::sleep(RELAY_EOF_IDLE_TIMEOUT + std::time::Duration::from_secs(1)).await; + let _ = upstream_write + .write_all(b"event: second\ndata: {}\r\n\r\n") + .await; + let _ = upstream_write.shutdown().await; + }); + + let result = tokio::time::timeout( + RELAY_EOF_IDLE_TIMEOUT + std::time::Duration::from_secs(3), + relay_response( + "GET", + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + ), + ) + .await + .expect("event stream relay must outlive the generic EOF idle timeout"); + + let outcome = result.expect("relay_response should succeed"); + assert!( + matches!(outcome, RelayOutcome::Consumed), + "event stream is consumed by read-until-EOF" + ); + upstream_task.await.expect("upstream task should complete"); + + client_write.shutdown().await.unwrap(); + let mut received = Vec::new(); + client_read.read_to_end(&mut received).await.unwrap(); + let received_str = String::from_utf8_lossy(&received); + assert!(received_str.contains("event: first")); + assert!( + received_str.contains("event: second"), + "SSE event after idle gap should be forwarded, got: {received_str}" + ); + } + #[tokio::test] async fn relay_response_no_framing_without_connection_close_treats_as_empty() { // Response without Content-Length, TE, or Connection: close. diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index 31aa35509..e1f92e6ec 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -545,6 +545,7 @@ fn inspect_websocket_text_message( target: inspector.target.clone(), query_params: inspector.query_params.clone(), graphql: None, + jsonrpc: None, }; let (allowed, reason) = evaluate_l7_request(inspector.engine, inspector.ctx, &request_info)?; let decision = match (allowed, inspector.enforcement) { @@ -581,6 +582,7 @@ fn inspect_graphql_websocket_message( target: inspector.target.clone(), query_params: inspector.query_params.clone(), graphql: None, + jsonrpc: None, }; emit_websocket_l7_event( host, @@ -602,6 +604,7 @@ fn inspect_graphql_websocket_message( target: inspector.target.clone(), query_params: inspector.query_params.clone(), graphql: Some(graphql.clone()), + jsonrpc: None, }; let parse_error_reason = graphql .error diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 4dd0350ff..d82ad12cc 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -424,6 +424,26 @@ impl OpaEngine { Ok(()) } + /// Reload after startup symlink resolution only when resolution changes + /// the policy data produced from this same proto. + /// + /// This is for the one-time post-startup symlink pass. Generic policy + /// reloads must use [`Self::reload_from_proto_with_pid`] so a new policy + /// snapshot is applied even when it contains no resolvable symlinks. + pub fn reload_from_proto_with_pid_if_symlinks_changed( + &self, + proto: &ProtoSandboxPolicy, + entrypoint_pid: u32, + ) -> Result { + let unresolved_data_json = proto_to_opa_data_json(proto, 0); + let resolved_data_json = proto_to_opa_data_json(proto, entrypoint_pid); + if resolved_data_json == unresolved_data_json { + return Ok(false); + } + self.reload_from_proto_with_pid(proto, entrypoint_pid)?; + Ok(true) + } + /// Current policy generation. Successful reloads increment this value. pub fn current_generation(&self) -> u64 { self.generation.load(Ordering::Acquire) @@ -925,6 +945,24 @@ fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> Opti None } +fn l7_matchers_to_json( + matchers: &std::collections::HashMap, +) -> serde_json::Map { + matchers + .iter() + .map(|(key, matcher)| { + let mut matcher_json = serde_json::json!({}); + if !matcher.glob.is_empty() { + matcher_json["glob"] = matcher.glob.clone().into(); + } + if !matcher.any.is_empty() { + matcher_json["any"] = matcher.any.clone().into(); + } + (key.clone(), matcher_json) + }) + .collect() +} + /// Convert typed proto policy fields to JSON suitable for `engine.add_data_json()`. /// /// The rego rules reference `data.*` directly, so the JSON structure has @@ -1029,29 +1067,18 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St { allow["fields"] = a.fields.clone().into(); } - let query: serde_json::Map = a - .map(|allow| { - allow - .query - .iter() - .map(|(key, matcher)| { - let mut matcher_json = serde_json::json!({}); - if !matcher.glob.is_empty() { - matcher_json["glob"] = - matcher.glob.clone().into(); - } - if !matcher.any.is_empty() { - matcher_json["any"] = - matcher.any.clone().into(); - } - (key.clone(), matcher_json) - }) - .collect() - }) - .unwrap_or_default(); + let query = a.map_or_else(serde_json::Map::new, |allow| { + l7_matchers_to_json(&allow.query) + }); if !query.is_empty() { allow["query"] = query.into(); } + let params = a.map_or_else(serde_json::Map::new, |allow| { + l7_matchers_to_json(&allow.params) + }); + if !params.is_empty() { + allow["params"] = params.into(); + } serde_json::json!({ "allow": allow }) }) .collect(); @@ -1087,23 +1114,14 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if !d.fields.is_empty() { deny["fields"] = d.fields.clone().into(); } - let query: serde_json::Map = d - .query - .iter() - .map(|(key, matcher)| { - let mut matcher_json = serde_json::json!({}); - if !matcher.glob.is_empty() { - matcher_json["glob"] = matcher.glob.clone().into(); - } - if !matcher.any.is_empty() { - matcher_json["any"] = matcher.any.clone().into(); - } - (key.clone(), matcher_json) - }) - .collect(); + let query = l7_matchers_to_json(&d.query); if !query.is_empty() { deny["query"] = query.into(); } + let params = l7_matchers_to_json(&d.params); + if !params.is_empty() { + deny["params"] = params.into(); + } deny }) .collect(); @@ -1141,6 +1159,9 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if e.graphql_max_body_bytes > 0 { ep["graphql_max_body_bytes"] = e.graphql_max_body_bytes.into(); } + if e.json_rpc_max_body_bytes > 0 { + ep["json_rpc_max_body_bytes"] = e.json_rpc_max_body_bytes.into(); + } ep }) .collect(); @@ -1948,6 +1969,58 @@ process: }) } + fn l7_jsonrpc_input(host: &str, port: u16, path: &str, method: &str) -> serde_json::Value { + l7_jsonrpc_input_with_params(host, port, path, method, serde_json::json!({})) + } + + fn l7_jsonrpc_input_with_params( + host: &str, + port: u16, + path: &str, + method: &str, + params: serde_json::Value, + ) -> serde_json::Value { + serde_json::json!({ + "network": { "host": host, "port": port }, + "exec": { + "path": "/usr/bin/curl", + "ancestors": [], + "cmdline_paths": [] + }, + "request": { + "method": "POST", + "path": path, + "query_params": {}, + "jsonrpc": { + "method": method, + "params": params + } + } + }) + } + + fn l7_jsonrpc_response_input(host: &str, port: u16, path: &str) -> serde_json::Value { + serde_json::json!({ + "network": { "host": host, "port": port }, + "exec": { + "path": "/usr/bin/curl", + "ancestors": [], + "cmdline_paths": [] + }, + "request": { + "method": "POST", + "path": path, + "query_params": {}, + "jsonrpc": { + "method": null, + "params": {}, + "has_response": true, + "error": null + } + } + }) + } + fn l7_graphql_input(host: &str, operations: serde_json::Value) -> serde_json::Value { serde_json::json!({ "network": { "host": host, "port": 443 }, @@ -2015,6 +2088,21 @@ process: val == regorus::Value::from(true) } + fn eval_l7_raw_data(data: serde_json::Value, input: serde_json::Value) -> bool { + let mut engine = regorus::Engine::new(); + engine + .add_policy("policy.rego".into(), TEST_POLICY.into()) + .unwrap(); + engine + .add_data_json(&data.to_string()) + .expect("add raw data json"); + engine.set_input_json(&input.to_string()).unwrap(); + let val = engine + .eval_rule("data.openshell.sandbox.allow_request".into()) + .unwrap(); + val == regorus::Value::from(true) + } + #[test] fn l7_get_allowed_by_rules() { let engine = l7_engine(); @@ -2451,6 +2539,24 @@ network_policies: assert!(eval_l7(&engine, &input)); } + #[test] + fn l7_rest_request_ignores_null_jsonrpc_metadata() { + let engine = l7_engine(); + let mut input = l7_input_with_query( + "api.query.com", + 8080, + "GET", + "/download", + serde_json::json!({ + "tag": ["foo-a"], + }), + ); + input["request"]["graphql"] = serde_json::Value::Null; + input["request"]["jsonrpc"] = serde_json::Value::Null; + + assert!(eval_l7(&engine, &input)); + } + #[test] fn l7_query_missing_required_key_denied() { let engine = l7_engine(); @@ -2494,6 +2600,7 @@ network_policies: operation_type: String::new(), operation_name: String::new(), fields: Vec::new(), + params: std::collections::HashMap::new(), }), }], ..Default::default() @@ -2542,6 +2649,364 @@ network_policies: assert!(!eval_l7(&engine, &deny_input)); } + #[test] + fn l7_method_from_proto_is_enforced() { + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "jsonrpc_proto".to_string(), + NetworkPolicyRule { + name: "jsonrpc_proto".to_string(), + endpoints: vec![NetworkEndpoint { + host: "mcp.proto.com".to_string(), + port: 8000, + path: "/mcp".to_string(), + protocol: "json-rpc".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "initialize".to_string(), + path: String::new(), + command: String::new(), + query: std::collections::HashMap::new(), + operation_type: String::new(), + operation_name: String::new(), + fields: Vec::new(), + params: std::collections::HashMap::new(), + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }, + ); + + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: Some(ProtoFs { + include_workdir: true, + read_only: vec![], + read_write: vec![], + }), + landlock: Some(openshell_core::proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + process: Some(ProtoProc { + run_as_user: "sandbox".to_string(), + run_as_group: "sandbox".to_string(), + }), + network_policies, + }; + + let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); + let allow_input = l7_jsonrpc_input("mcp.proto.com", 8000, "/mcp", "initialize"); + assert!(eval_l7(&engine, &allow_input)); + + let deny_input = l7_jsonrpc_input("mcp.proto.com", 8000, "/mcp", "tools/list"); + assert!(!eval_l7(&engine, &deny_input)); + } + + #[test] + fn l7_jsonrpc_endpoint_ignores_rest_shaped_allow_rules() { + let data = serde_json::json!({ + "network_policies": { + "jsonrpc_rest_bypass": { + "name": "jsonrpc_rest_bypass", + "endpoints": [{ + "host": "mcp.rest-bypass.test", + "ports": [8000], + "path": "/mcp", + "protocol": "json-rpc", + "rules": [{ + "allow": { + "method": "POST", + "path": "**" + } + }] + }], + "binaries": [{ "path": "/usr/bin/curl" }] + } + } + }); + let input = l7_jsonrpc_input("mcp.rest-bypass.test", 8000, "/mcp", "tools/list"); + assert!( + !eval_l7_raw_data(data, input), + "REST-shaped method/path rules must not authorize JSON-RPC endpoints" + ); + } + + #[test] + fn l7_jsonrpc_receive_stream_get_is_allowed_for_matching_endpoint() { + let data = r#" +network_policies: + jsonrpc_stream: + name: jsonrpc_stream + endpoints: + - host: mcp.stream.test + port: 8000 + path: /mcp + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: initialize + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml"); + let allow_input = serde_json::json!({ + "network": { "host": "mcp.stream.test", "port": 8000 }, + "exec": { + "path": "/usr/bin/curl", + "ancestors": [], + "cmdline_paths": [] + }, + "request": { + "method": "GET", + "path": "/mcp", + "query_params": {}, + "jsonrpc": { + "method": null, + "params": {}, + "receive_stream": true, + "error": null + } + } + }); + assert!(eval_l7(&engine, &allow_input)); + + let deny_input = serde_json::json!({ + "network": { "host": "mcp.stream.test", "port": 8000 }, + "exec": { + "path": "/usr/bin/curl", + "ancestors": [], + "cmdline_paths": [] + }, + "request": { + "method": "GET", + "path": "/other", + "query_params": {}, + "jsonrpc": { + "method": null, + "params": {}, + "receive_stream": true, + "error": null + } + } + }); + assert!(!eval_l7(&engine, &deny_input)); + + let bodyless_get_without_receive_stream = serde_json::json!({ + "network": { "host": "mcp.stream.test", "port": 8000 }, + "exec": { + "path": "/usr/bin/curl", + "ancestors": [], + "cmdline_paths": [] + }, + "request": { + "method": "GET", + "path": "/mcp", + "query_params": {}, + "jsonrpc": { + "method": null, + "params": {}, + "error": null + } + } + }); + assert!(!eval_l7(&engine, &bodyless_get_without_receive_stream)); + } + + #[test] + fn l7_jsonrpc_response_post_is_denied_for_matching_endpoint() { + let data = r#" +network_policies: + jsonrpc_response: + name: jsonrpc_response + endpoints: + - host: mcp.response.test + port: 8000 + path: /mcp + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: initialize + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml"); + let response_input = l7_jsonrpc_response_input("mcp.response.test", 8000, "/mcp"); + assert!(!eval_l7(&engine, &response_input)); + + let mut mixed_input = l7_jsonrpc_input_with_params( + "mcp.response.test", + 8000, + "/mcp", + "initialize", + serde_json::json!({}), + ); + mixed_input["request"]["jsonrpc"]["has_response"] = serde_json::json!(true); + assert!(!eval_l7(&engine, &mixed_input)); + + let deny_input = l7_jsonrpc_response_input("mcp.response.test", 8000, "/other"); + assert!(!eval_l7(&engine, &deny_input)); + } + + #[test] + fn l7_jsonrpc_orphaned_progress_notification_is_denied() { + let data = r#" +network_policies: + jsonrpc_progress: + name: jsonrpc_progress + endpoints: + - host: mcp.progress.test + port: 8000 + path: /mcp + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: initialize + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml"); + let progress_input = l7_jsonrpc_input_with_params( + "mcp.progress.test", + 8000, + "/mcp", + "notifications/progress", + serde_json::json!({ + "progressToken": "orphaned", + "progress": 0.5 + }), + ); + + assert!(!eval_l7(&engine, &progress_input)); + } + + #[test] + fn l7_method_rules_require_post() { + let data = r#" +network_policies: + jsonrpc_post: + name: jsonrpc_post + endpoints: + - host: mcp.post.test + port: 8000 + path: /mcp + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: initialize + deny_rules: + - method: tools/delete + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml"); + + let mut post_input = l7_jsonrpc_input_with_params( + "mcp.post.test", + 8000, + "/mcp", + "initialize", + serde_json::json!({}), + ); + assert!(eval_l7(&engine, &post_input)); + + post_input["request"]["method"] = serde_json::json!("PUT"); + assert!(!eval_l7(&engine, &post_input)); + + let mut get_with_method = l7_jsonrpc_input_with_params( + "mcp.post.test", + 8000, + "/mcp", + "initialize", + serde_json::json!({}), + ); + get_with_method["request"]["method"] = serde_json::json!("GET"); + assert!(!eval_l7(&engine, &get_with_method)); + } + + #[test] + fn l7_jsonrpc_params_rules_filter_tools_call() { + let data = r#" +network_policies: + jsonrpc_params: + name: jsonrpc_params + endpoints: + - host: mcp.params.test + port: 8000 + path: /mcp + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: tools/call + params: + name: read_status + - allow: + method: tools/call + params: + name: submit_report + arguments.scope: workspace/main + deny_rules: + - method: tools/call + params: + name: blocked_action + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("engine from yaml"); + + let read_status = l7_jsonrpc_input_with_params( + "mcp.params.test", + 8000, + "/mcp", + "tools/call", + serde_json::json!({"name": "read_status"}), + ); + assert!(eval_l7(&engine, &read_status)); + + let submit_report = l7_jsonrpc_input_with_params( + "mcp.params.test", + 8000, + "/mcp", + "tools/call", + serde_json::json!({ + "name": "submit_report", + "arguments.scope": "workspace/main" + }), + ); + assert!(eval_l7(&engine, &submit_report)); + + let blocked_without_args = l7_jsonrpc_input_with_params( + "mcp.params.test", + 8000, + "/mcp", + "tools/call", + serde_json::json!({"name": "blocked_action"}), + ); + assert!(!eval_l7(&engine, &blocked_without_args)); + + let blocked_with_args = l7_jsonrpc_input_with_params( + "mcp.params.test", + 8000, + "/mcp", + "tools/call", + serde_json::json!({ + "name": "blocked_action", + "arguments.reason": "test" + }), + ); + assert!(!eval_l7(&engine, &blocked_with_args)); + } + #[test] fn l7_no_request_on_l4_only_endpoint() { // L4-only endpoint should not match L7 allow_request @@ -5118,9 +5583,15 @@ network_policies: // Hot-reload with real PID — symlinks resolved let our_pid = std::process::id(); + let before_reload_generation = engine.current_generation(); engine .reload_from_proto_with_pid(&proto, our_pid) .expect("reload with PID"); + assert_eq!( + engine.current_generation(), + before_reload_generation + 1, + "symlink expansion should update policy generation" + ); // Now the resolved path should be ALLOWED let decision = engine.evaluate_network(&input_resolved).unwrap(); @@ -5131,6 +5602,55 @@ network_policies: ); } + #[test] + fn reload_from_proto_with_pid_skips_noop_generation_change() { + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "python".to_string(), + NetworkPolicyRule { + name: "python".to_string(), + endpoints: vec![NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/python*".to_string(), + ..Default::default() + }], + }, + ); + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: Some(ProtoFs { + include_workdir: true, + read_only: vec![], + read_write: vec![], + }), + landlock: Some(openshell_core::proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + process: Some(ProtoProc { + run_as_user: "sandbox".to_string(), + run_as_group: "sandbox".to_string(), + }), + network_policies, + }; + + let engine = OpaEngine::from_proto(&proto).expect("initial load"); + let generation = engine.current_generation(); + let reloaded = engine + .reload_from_proto_with_pid_if_symlinks_changed(&proto, std::process::id()) + .expect("noop symlink resolution with PID"); + + assert!(!reloaded, "glob-only policy should not require a reload"); + assert_eq!( + engine.current_generation(), + generation, + "no-op symlink resolution must not invalidate in-flight L7 guards" + ); + } + #[test] fn l7_head_allowed_where_get_is_allowed() { let engine = l7_engine(); diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 2fce25389..57c3dd57e 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -1088,6 +1088,7 @@ fn network_endpoint_from_json( operation_type: String::new(), operation_name: String::new(), fields: Vec::new(), + params: HashMap::new(), }), }) .collect(); @@ -1102,6 +1103,7 @@ fn network_endpoint_from_json( operation_type: String::new(), operation_name: String::new(), fields: Vec::new(), + params: HashMap::new(), }) .collect(); @@ -1125,6 +1127,7 @@ fn network_endpoint_from_json( persisted_queries: String::new(), graphql_persisted_queries: HashMap::new(), graphql_max_body_bytes: 0, + json_rpc_max_body_bytes: 0, path: String::new(), }) } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 691382469..8b45a5bf1 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -13,7 +13,7 @@ use openshell_core::denial::DenialEvent; use openshell_core::net::{is_always_blocked_ip, is_internal_ip, is_link_local_ip}; use openshell_core::policy::ProxyPolicy; use openshell_core::provider_credentials::ProviderCredentialState; -use openshell_core::secrets::{SecretResolver, rewrite_header_line_checked}; +use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked}; use openshell_ocsf::{ ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, @@ -185,7 +185,7 @@ impl ProxyHandle { /// The proxy uses OPA for network decisions with process-identity binding /// via `/proc/net/tcp`. All connections are evaluated through OPA policy. #[allow(clippy::too_many_arguments)] - pub async fn start_with_bind_addr( + pub(crate) async fn start_with_bind_addr( policy: &ProxyPolicy, bind_addr: Option, opa_engine: Arc, @@ -428,6 +428,26 @@ fn emit_forward_success_activity(tx: Option<&ActivitySender>, l7_activity_pendin ); } +fn forward_l7_hard_deny_reason(request_info: &crate::l7::L7RequestInfo) -> Option { + request_info + .graphql + .as_ref() + .and_then(|info| info.error.as_deref()) + .map(|error| format!("GraphQL request rejected: {error}")) + .or_else(|| { + request_info.jsonrpc.as_ref().and_then(|info| { + info.error + .as_deref() + .map(|error| format!("JSON-RPC request rejected: {error}")) + .or_else(|| { + info.has_response.then(|| { + crate::l7::relay::JSONRPC_RESPONSE_FRAME_DENY_REASON.to_string() + }) + }) + }) + }) +} + /// Emit a denial event to the aggregator channel (if configured). /// Used by `handle_tcp_connection` which owns `Option`. fn emit_denial( @@ -575,6 +595,7 @@ async fn handle_tcp_connection( ) .await?; if let InferenceOutcome::Denied { reason } = outcome { + emit_activity(&activity_tx, true, "forward_policy"); let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) @@ -2892,16 +2913,14 @@ fn rewrite_forward_request( path: &str, secret_resolver: Option<&SecretResolver>, request_body_credential_rewrite: bool, -) -> Result, openshell_core::secrets::UnresolvedPlaceholderError> { +) -> Result, secrets::UnresolvedPlaceholderError> { let header_end = raw[..used] .windows(4) .position(|w| w == b"\r\n\r\n") .map_or(used, |p| p + 4); let websocket_upgrade = crate::l7::rest::request_is_websocket_upgrade(&raw[..header_end]); let upstream_path = match secret_resolver { - Some(resolver) => { - openshell_core::secrets::rewrite_target_for_eval(path, resolver)?.resolved - } + Some(resolver) => secrets::rewrite_target_for_eval(path, resolver)?.resolved, None => path.to_string(), }; @@ -2994,10 +3013,10 @@ fn rewrite_forward_request( output.len() }; let output_str = String::from_utf8_lossy(&output[..scan_end]); - if output_str.contains(openshell_core::secrets::PLACEHOLDER_PREFIX_PUBLIC) - || output_str.contains(openshell_core::secrets::PROVIDER_ALIAS_MARKER_PUBLIC) + if output_str.contains(secrets::PLACEHOLDER_PREFIX_PUBLIC) + || output_str.contains(secrets::PROVIDER_ALIAS_MARKER_PUBLIC) { - return Err(openshell_core::secrets::UnresolvedPlaceholderError { location: "header" }); + return Err(secrets::UnresolvedPlaceholderError { location: "header" }); } } @@ -3566,18 +3585,71 @@ async fn handle_forward_proxy( } else { None }; + let jsonrpc = if l7_config.config.protocol == crate::l7::L7Protocol::JsonRpc { + let header_end = forward_request_bytes + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(forward_request_bytes.len(), |p| p + 4); + let header_str = std::str::from_utf8(&forward_request_bytes[..header_end]) + .map_err(|_| miette::miette!("Forward JSON-RPC headers contain invalid UTF-8"))?; + let body_length = crate::l7::rest::parse_body_length(header_str)?; + let mut jsonrpc_request = crate::l7::provider::L7Request { + action: method.to_string(), + target: path.clone(), + query_params: query_params.clone(), + raw_header: forward_request_bytes, + body_length, + }; + if crate::l7::jsonrpc::jsonrpc_receive_stream_request(&jsonrpc_request) { + forward_request_bytes = jsonrpc_request.raw_header; + Some(crate::l7::jsonrpc::JsonRpcRequestInfo::receive_stream()) + } else { + let body = match crate::l7::http::read_body_for_inspection( + client, + &mut jsonrpc_request, + l7_config.config.json_rpc_max_body_bytes, + ) + .await + { + Ok(body) => body, + Err(e) => { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .message(format!("FORWARD_JSONRPC_L7 request rejected: {e}")) + .build(); + ocsf_emit!(event); + emit_activity_simple(activity_tx, true, "l7_parse_rejection"); + respond( + client, + &build_json_error_response( + 400, + "Bad Request", + "invalid_jsonrpc_request", + &format!("JSON-RPC request rejected before policy evaluation: {e}"), + ), + ) + .await?; + return Ok(()); + } + }; + forward_request_bytes = jsonrpc_request.raw_header; + Some(crate::l7::jsonrpc::parse_jsonrpc_body(&body)) + } + } else { + None + }; let request_info = crate::l7::L7RequestInfo { action: method.to_string(), target: path.clone(), query_params, graphql, + jsonrpc, }; - let parse_error_reason = request_info - .graphql - .as_ref() - .and_then(|info| info.error.as_deref()) - .map(|error| format!("GraphQL request rejected: {error}")); + let parse_error_reason = forward_l7_hard_deny_reason(&request_info); let force_deny = parse_error_reason.is_some(); let (allowed, reason) = parse_error_reason.map_or_else( || { @@ -3618,16 +3690,39 @@ async fn handle_forward_proxy( SeverityId::Informational, ), }; - let engine_type = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { - "l7-graphql" - } else { - "l7" - }; - let message_prefix = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { - "FORWARD_GRAPHQL_L7" - } else { - "FORWARD_L7" + let engine_type = match l7_config.config.protocol { + crate::l7::L7Protocol::Graphql => "l7-graphql", + crate::l7::L7Protocol::JsonRpc => "l7-jsonrpc", + _ => "l7", }; + let log_message = request_info.jsonrpc.as_ref().map_or_else( + || { + let message_prefix = + if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { + "FORWARD_GRAPHQL_L7" + } else { + "FORWARD_L7" + }; + format!( + "{message_prefix} {decision_str} {method} {host_lc}:{port}{path} reason={reason}" + ) + }, + |jsonrpc_info| { + let endpoint = format!("{host_lc}:{port}{path}"); + let params_sha256 = jsonrpc_info + .params_sha256() + .unwrap_or_else(|| "".to_string()); + crate::l7::relay::jsonrpc_log_message( + decision_str, + method, + &endpoint, + jsonrpc_info, + ¶ms_sha256, + tunnel_engine.captured_generation(), + &reason, + ) + }, + ); let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(action_id) @@ -3644,9 +3739,7 @@ async fn handle_forward_proxy( .with_cmd_line(&cmdline_str), ) .firewall_rule(policy_str, engine_type) - .message(format!( - "{message_prefix} {decision_str} {method} {host_lc}:{port}{path} reason={reason}" - )) + .message(log_message) .build(); ocsf_emit!(event); } @@ -4262,6 +4355,7 @@ mod tests { tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, + json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, allow_encoded_slash: false, websocket_credential_rewrite, request_body_credential_rewrite: false, @@ -4420,6 +4514,52 @@ network_policies: assert_eq!(event.deny_group, "unknown"); } + #[test] + fn forward_l7_hard_deny_reason_includes_jsonrpc_errors() { + let request_info = crate::l7::L7RequestInfo { + action: "POST".to_string(), + target: "/mcp".to_string(), + query_params: std::collections::HashMap::new(), + graphql: None, + jsonrpc: Some(crate::l7::jsonrpc::JsonRpcRequestInfo { + calls: Vec::new(), + is_batch: false, + receive_stream: false, + has_response: false, + error: Some("missing or non-string 'jsonrpc' field".to_string()), + }), + }; + + let reason = forward_l7_hard_deny_reason(&request_info).expect("JSON-RPC parse error"); + + assert_eq!( + reason, + "JSON-RPC request rejected: missing or non-string 'jsonrpc' field" + ); + } + + #[test] + fn forward_l7_hard_deny_reason_includes_jsonrpc_response_frames() { + let request_info = crate::l7::L7RequestInfo { + action: "POST".to_string(), + target: "/mcp".to_string(), + query_params: std::collections::HashMap::new(), + graphql: None, + jsonrpc: Some(crate::l7::jsonrpc::JsonRpcRequestInfo { + calls: Vec::new(), + is_batch: false, + receive_stream: false, + has_response: true, + error: None, + }), + }; + + let reason = + forward_l7_hard_deny_reason(&request_info).expect("JSON-RPC response hard deny"); + + assert_eq!(reason, crate::l7::relay::JSONRPC_RESPONSE_FRAME_DENY_REASON); + } + #[test] fn forward_l7_allowed_activity_is_deferred_until_after_ssrf() { let (tx, mut rx) = mpsc::channel(4); @@ -4978,6 +5118,7 @@ network_policies: tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, + json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false, @@ -4991,6 +5132,7 @@ network_policies: tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, + json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false, diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index b98923051..fd9a0b636 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -140,14 +140,22 @@ pub async fn run_networking( attempt = attempt, "Container filesystem accessible, resolving policy binary symlinks" ); - match resolve_engine.reload_from_proto_with_pid(&resolve_proto, pid) { - Ok(()) => { + match resolve_engine + .reload_from_proto_with_pid_if_symlinks_changed(&resolve_proto, pid) + { + Ok(true) => { info!( pid = pid, "Policy binary symlink resolution complete \ (check logs above for per-binary results)" ); } + Ok(false) => { + info!( + pid = pid, + "Policy binary symlink resolution found no additional paths" + ); + } Err(e) => { warn!( "Failed to rebuild OPA engine with symlink resolution \ diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 59f72c9f7..c530e196a 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -155,10 +155,10 @@ Each endpoint defines a reachable destination and optional inspection rules. | `host` | string | Yes | Hostname or IP address. Supports a `*` wildcard inside the first DNS label only: `*.example.com`, `**.example.com`, and intra-label patterns like `*-aiplatform.googleapis.com` are accepted; bare `*`/`**`, TLD wildcards (`*.com`), and wildcards outside the first label are rejected at load time. | | `port` | integer | Yes | TCP port number. | | `path` | string | No | Optional HTTP path glob used to select between L7 endpoints that share the same host and port. Empty means all paths. Use this when REST and GraphQL live under the same host, such as `/repos/**` and `/graphql`. | -| `protocol` | string | No | Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, or `graphql` for GraphQL-over-HTTP operation inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Omit for TCP passthrough. | +| `protocol` | string | No | Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, or `json-rpc` for sandbox-to-server JSON-RPC-over-HTTP method and params inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Omit for TCP passthrough. | | `tls` | string | No | TLS handling mode. The proxy auto-detects TLS by peeking the first bytes of each connection and terminates it for inspected HTTPS traffic, so this field is optional in most cases. Set to `skip` to disable auto-detection for edge cases such as client-certificate mTLS or non-standard protocols. The values `terminate` and `passthrough` are deprecated and log a warning; they are still accepted for backward compatibility but have no effect on behavior. | | `enforcement` | string | No | `enforce` actively blocks disallowed requests. `audit` logs violations but allows traffic through. | -| `access` | string | No | Access preset. One of `read-only`, `read-write`, or `full`. Mutually exclusive with `rules`. | +| `access` | string | No | Access preset. One of `read-only`, `read-write`, or `full`. Mutually exclusive with `rules`. Not valid on `protocol: json-rpc`; JSON-RPC endpoints must use explicit `rules` with `method`. | | `rules` | list of rule objects | No | Fine-grained protocol-specific allow rules. Mutually exclusive with `access`. | | `deny_rules` | list of deny rule objects | No | L7 deny rules that block specific requests even when allowed by `access` or `rules`. Deny rules take precedence over allow rules. | | `allowed_ips` | list of string | No | CIDR or IP allowlist for SSRF override. Exact user-declared hostname endpoints may resolve to RFC 1918 private addresses without this field, but wildcard, hostless, and policy-advisor-proposed endpoints still require `allowed_ips` for private resolved IPs. Entries overlapping loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), or unspecified (`0.0.0.0`) are rejected at load time. | @@ -168,18 +168,21 @@ Each endpoint defines a reachable destination and optional inspection rules. | `persisted_queries` | string | No | GraphQL hash-only behavior for `protocol: graphql` and GraphQL-over-WebSocket operation policy. Default is `deny`; use `allow_registered` only with `graphql_persisted_queries`. | | `graphql_persisted_queries` | map | No | Trusted GraphQL persisted-query registry keyed by hash or saved-query ID. Values contain `operation_type`, optional `operation_name`, and optional root `fields`. | | `graphql_max_body_bytes` | integer | No | Maximum GraphQL-over-HTTP request body bytes buffered for inspection. Defaults to `65536`. | +| `json_rpc` | object | No | JSON-RPC endpoint options. For `protocol: json-rpc`, `json_rpc.max_body_bytes` sets the maximum JSON-RPC-over-HTTP request body bytes buffered for inspection. Defaults to `65536`. | Credential rewrite recognizes the canonical `openshell:resolve:env:KEY` placeholder form and whole-token provider-shaped aliases such as `provider-OPENSHELL-RESOLVE-ENV-API_TOKEN` when the referenced environment key exists in the configured provider credentials. #### Access Levels -The `access` field accepts one of the following values: +The `access` field accepts one of the following values on REST, WebSocket, and GraphQL endpoints. JSON-RPC endpoints reject `access` because HTTP method/path presets cannot authorize JSON-RPC safely; use explicit `rules` with `method` instead. -| Value | REST expansion | WebSocket expansion | GraphQL expansion | -|---|---|---|---| -| `full` | All methods and paths. | WebSocket upgrade and all inspected client text-message paths. | All operation types. | -| `read-only` | `GET`, `HEAD`, `OPTIONS`. | WebSocket upgrade handshake only. | `query` operations. | -| `read-write` | `GET`, `HEAD`, `OPTIONS`, `POST`, `PUT`, `PATCH`. | WebSocket upgrade handshake and client text messages. | `query` and `mutation` operations. | +| Value | REST expansion | WebSocket expansion | GraphQL expansion | JSON-RPC behavior | +|---|---|---|---|---| +| `full` | All methods and paths. | WebSocket upgrade and all inspected client text-message paths. | All operation types. | Rejected. | +| `read-only` | `GET`, `HEAD`, `OPTIONS`. | WebSocket upgrade handshake only. | `query` operations. | Rejected. | +| `read-write` | `GET`, `HEAD`, `OPTIONS`, `POST`, `PUT`, `PATCH`. | WebSocket upgrade handshake and client text messages. | `query` and `mutation` operations. | Rejected. | + +For JSON-RPC endpoints, configure explicit `rules` with `method` and optional `params`. #### Allow Rule Objects @@ -274,6 +277,42 @@ rules: Do not combine `method`, `path`, or `query` with `operation_type`, `operation_name`, or `fields` inside the same WebSocket rule. When a WebSocket endpoint has GraphQL operation policy, use GraphQL rules for client messages instead of a raw `WEBSOCKET_TEXT` allow rule. +##### JSON-RPC Allow Rule (`protocol: json-rpc`) + +JSON-RPC allow rules match sandbox-to-server JSON-RPC-over-HTTP request objects by RPC method and optional params. They apply to single JSON-RPC requests and batch requests. For a batch, OpenShell evaluates each call independently. Client-to-server JSON-RPC response frames in POST bodies are denied. Server-to-client messages on HTTP response bodies or MCP SSE streams are relayed but are not currently parsed for policy enforcement. + +| Field | Type | Required | Description | +|---|---|---|---| +| `method` | string | Yes | JSON-RPC method name such as `initialize`, `tools/list`, `tools/*`, or glob, `*`, which matches any JSON-RPC method. | +| `params` | map | No | Params matchers keyed by flattened object-param path. Use dot-separated keys for nested object params, such as `arguments.scope`. Matcher value can be a glob string or an object with `any`. Strings, numbers, and booleans are converted to strings; arrays, `null`, and non-object top-level params do not produce matcher keys. Literal `.` characters in JSON-RPC params object keys are allowed; when a literal key and a flattened nested path produce the same matcher key, the literal key takes precedence. | + +Example JSON-RPC allow rules: + +```yaml showLineNumbers={false} +endpoints: + - host: mcp.example.com + port: 443 + path: /mcp + protocol: json-rpc + enforcement: enforce + json_rpc: + max_body_bytes: 131072 + rules: + - allow: + method: initialize + - allow: + method: tools/list + - allow: + method: tools/call + params: + name: read_status + - allow: + method: tools/call + params: + name: submit_report + arguments.scope: workspace/main +``` + #### Deny Rule Objects Blocks specific operations on endpoints that otherwise have broad access. Deny rules are evaluated after allow rules and take precedence: if a request matches any deny rule, it is blocked regardless of what the allow rules or access preset permit. @@ -356,6 +395,33 @@ endpoints: operation_name: Admin* ``` +##### JSON-RPC Deny Rule (`protocol: json-rpc`) + +JSON-RPC deny rules use the same field names as JSON-RPC allow rules, but they appear directly under each `deny_rules` entry instead of under an `allow` wrapper. Deny rules take precedence over allow rules. In a batch request, one denied call denies the full batch. + +| Field | Type | Required | Description | +|---|---|---|---| +| `method` | string | Yes | JSON-RPC method name or glob to deny. A value without glob metacharacters matches that exact method name. In glob patterns, `*` matches any character sequence, so `method: "*"` denies any JSON-RPC method rather than a literal method named `*`. To deny a literal method named `*`, escape the glob character as `method: "\\*"`. | +| `params` | map | No | Params matchers keyed by flattened object-param path. Omit to deny every call matching `method`. Strings, numbers, and booleans are converted to strings; arrays, `null`, and non-object top-level params do not produce matcher keys. | + +Example JSON-RPC deny rules: + +```yaml showLineNumbers={false} +endpoints: + - host: mcp.example.com + port: 443 + path: /mcp + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: tools/* + deny_rules: + - method: tools/call + params: + name: delete_resource +``` + ### Binary Object Identifies an executable that is permitted to use the associated endpoints. diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 406ed12b8..d3ebe2173 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -148,7 +148,7 @@ The following steps outline the hot-reload policy update workflow. To inspect a stored sandbox-authored revision instead of the current effective policy, pass `--rev `. -5. Edit the YAML: add or adjust `network_policies` entries, binaries, `access`, or `rules`. +5. Edit the YAML: add or adjust `network_policies` entries, binaries, `access`, `rules`, or protocol-specific matchers such as GraphQL operation fields and JSON-RPC `method` / `params` rules. 6. Push the updated policy when you need a full replacement. Exit codes: 0 = loaded, 1 = validation failed, 124 = timeout. @@ -173,7 +173,7 @@ Use `openshell policy update` when you want to merge network policy changes into - remove one endpoint or one named rule without rewriting the rest of the file. - preview a merged result locally with `--dry-run` before you send it to the gateway. -Use `openshell policy set` instead when you want to replace the full policy, update static sections, or make broader edits that are easier to express in YAML. +Use `openshell policy set` instead when you want to replace the full policy, update static sections, or make broader edits that are easier to express in YAML. Use full YAML for GraphQL and JSON-RPC rule shapes. ### Update Commands @@ -210,6 +210,7 @@ This is the practical difference: Current constraints: - `--add-allow` and `--add-deny` work on `protocol: rest` and `protocol: websocket` endpoints. +- GraphQL and JSON-RPC fine-grained rules require full policy YAML applied with `openshell policy set`. - `--add-deny` requires the endpoint to already have an allow base, either an `access` preset or explicit allow `rules`. - `protocol: sql` is not a practical incremental workflow today. OpenShell does not do full SQL parsing, and SQL enforcement is not meaningfully supported yet. @@ -228,7 +229,7 @@ Each segment has a fixed meaning: | `host` | Yes | Destination hostname. | | `port` | Yes | Destination port, `1` through `65535`. | | `access` | No | Access preset for L7 endpoints: `read-only`, `read-write`, or `full`. Incremental updates expand presets into protocol-specific method/path rules for REST and WebSocket endpoints. | -| `protocol` | No | L7 inspection mode: `rest`, `websocket`, or `sql`. `sql` is audit-only and not a recommended workflow today. | +| `protocol` | No | L7 inspection mode accepted by `openshell policy update`: `rest`, `websocket`, or `sql`. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql` and `json-rpc`. | | `enforcement` | No | Enforcement mode for inspected traffic: `enforce` or `audit`. | | `options` | No | Comma-separated endpoint options. Use `websocket-credential-rewrite` with `protocol: websocket` or REST compatibility endpoints that perform a WebSocket upgrade. Use `request-body-credential-rewrite` only with `protocol: rest`. | @@ -548,7 +549,7 @@ For an end-to-end walkthrough that combines this policy with a GitHub credential - { path: /usr/bin/gh } ``` -Endpoints with `protocol: rest` enable HTTP request inspection and can opt in to supported text request body credential rewrite. Endpoints with `protocol: websocket` validate WebSocket upgrades and inspect client text messages on the upgraded request path. WebSocket endpoints can also classify GraphQL-over-WebSocket operation messages with the same operation rules used by GraphQL-over-HTTP. Endpoints with `protocol: graphql` parse GraphQL-over-HTTP payloads before evaluating rules. The endpoint-level `path` field lets these protocols share `api.github.com:443` without treating GraphQL payloads as plain REST `POST /graphql` requests. +Endpoints with `protocol: rest` enable HTTP request inspection and can opt in to supported text request body credential rewrite. Endpoints with `protocol: websocket` validate WebSocket upgrades and inspect client text messages on the upgraded request path. WebSocket endpoints can also classify GraphQL-over-WebSocket operation messages with the same operation rules used by GraphQL-over-HTTP. Endpoints with `protocol: graphql` parse GraphQL-over-HTTP payloads before evaluating rules. Endpoints with `protocol: json-rpc` parse JSON-RPC-over-HTTP request bodies and evaluate `method` and optional params rules. The endpoint-level `path` field lets these protocols share `api.github.com:443` without treating GraphQL payloads as plain REST `POST /graphql` requests. @@ -579,6 +580,51 @@ REST rules can also constrain query parameter values: `query` matchers are case-sensitive and run on decoded values. If a request has duplicate keys (for example, `tag=a&tag=b`), every value for that key must match the configured glob(s). +### JSON-RPC matching + +JSON-RPC endpoints use `protocol: json-rpc`. The proxy parses sandbox-to-server JSON-RPC-over-HTTP request bodies, evaluates the JSON-RPC `method` field against rule `method`, and can match object params through dot-separated `params` keys. + +JSON-RPC policy enforcement is directional. It applies to HTTP request bodies sent by the sandboxed process to the configured endpoint. JSON-RPC responses and server-to-client messages carried on response bodies or MCP SSE streams are relayed but are not currently parsed for policy enforcement. + +JSON-RPC endpoint policies currently require full policy YAML applied with `openshell policy set`; the incremental `openshell policy update --add-endpoint` parser does not accept `json-rpc` as a protocol. + +```yaml showLineNumbers={false} + mcp_server: + name: mcp_server + endpoints: + - host: mcp.example.com + port: 443 + path: /mcp + protocol: json-rpc + enforcement: enforce + json_rpc: + max_body_bytes: 131072 + rules: + - allow: + method: initialize + - allow: + method: tools/list + - allow: + method: tools/call + params: + name: read_status + - allow: + method: tools/call + params: + name: submit_report + arguments.scope: workspace/main + deny_rules: + - method: tools/call + params: + name: delete_resource + binaries: + - { path: /usr/bin/python3 } +``` + +`json_rpc.max_body_bytes` controls how many JSON-RPC-over-HTTP request body bytes OpenShell buffers for inspection. It defaults to `65536`. + +`params` matchers are case-sensitive and use the same string glob or `{ any: [...] }` matcher syntax as REST query parameters. They match scalar leaf values from object params: strings, numbers, and booleans are converted to strings, and nested JSON object params are flattened with dot-separated keys before matching. Arrays, `null`, and non-object top-level params do not produce matcher keys. Literal `.` characters in JSON-RPC params object keys are allowed; when a literal key and a flattened nested path produce the same matcher key, the literal key takes precedence. This is useful for controls such as matching MCP `tools/call` by `params.name`, but it is not a complete MCP payload policy for rich nested content. For batch requests, OpenShell evaluates each JSON-RPC call independently and denies the whole batch if any call is denied. + ### GraphQL matching GraphQL endpoints use `protocol: graphql`. The proxy parses GraphQL-over-HTTP `GET` and `POST` requests, classifies each operation, and evaluates rules against the operation type, optional operation name, and selected root fields. diff --git a/e2e/mcp-conformance.sh b/e2e/mcp-conformance.sh new file mode 100755 index 000000000..8224b3502 --- /dev/null +++ b/e2e/mcp-conformance.sh @@ -0,0 +1,446 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=e2e/support/gateway-common.sh disable=SC1091 +source "${ROOT}/e2e/support/gateway-common.sh" +CONFORMANCE_DIR="${OPENSHELL_MCP_CONFORMANCE_DIR:-${ROOT}/.cache/mcp-conformance}" +# Pinned after v0.1.16 because that tag has an upstream scenario-name mismatch: +# the runner exposes `tools_call`, while the bundled client only accepts +# `tools-call`. This commit registers both names in the client and keeps the +# runner's canonical `tools_call` scenario name. +CONFORMANCE_REF="${OPENSHELL_MCP_CONFORMANCE_REF:-b9041ea41b0188581803459dbae71bc7e02fd995}" +CLIENT_IMAGE="${OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE:-openshell-mcp-conformance-client:local}" +SCENARIOS="${OPENSHELL_MCP_CONFORMANCE_SCENARIOS:-}" +SPEC_VERSION="${OPENSHELL_MCP_CONFORMANCE_SPEC_VERSION:-2025-11-25}" +TIMEOUT_MS="${OPENSHELL_MCP_CONFORMANCE_TIMEOUT_MS:-900000}" +FORCE_REBUILD="${OPENSHELL_MCP_CONFORMANCE_FORCE_REBUILD:-0}" +DOCKER_PULL="${OPENSHELL_MCP_CONFORMANCE_DOCKER_PULL:-0}" +CLIENT_IMAGE_REF_LABEL="org.openshell.mcp-conformance.ref" +CLIENT_IMAGE_DOCKERFILE_LABEL="org.openshell.mcp-conformance.dockerfile" +CLIENT_IMAGE_DOCKERIGNORE_LABEL="org.openshell.mcp-conformance.dockerignore" +RUN_SCENARIOS_COMMAND="__openshell_mcp_run_scenarios" +CLIENT_SANDBOX_MANAGED=0 +HOST_BRIDGE_PID="" +HOST_BRIDGE_LOG="" +HOST_BRIDGE_TOKEN="" +RUNNER_CONTAINER_IP="" +RUNNER_CONTAINER="" + +# Static default scenarios for the pinned CONFORMANCE_REF and default +# SPEC_VERSION. To refresh this list after changing either value, list the +# scenarios from the built client image: +# +# docker run --rm openshell-mcp-conformance-client:local \ +# ./node_modules/.bin/tsx src/index.ts list --client --spec-version 2025-11-25 +# +# Then confirm each scenario has a compatible handler in the pinned +# examples/clients/typescript/everything-client.ts. Keep auth/OAuth scenarios +# and the slow sse-retry scenario opt-in unless intentionally broadening the +# default MCP e2e coverage. +DEFAULT_SCENARIOS=( + initialize + tools_call + elicitation-sep1034-client-defaults +) + +require_command() { + local name=$1 + if ! command -v "${name}" >/dev/null 2>&1; then + echo "ERROR: ${name} is required to run MCP conformance e2e tests." >&2 + exit 2 + fi +} + +is_commit_ref() { + [[ "$1" =~ ^[0-9a-fA-F]{40}$ ]] +} + +checkout_conformance() { + mkdir -p "$(dirname "${CONFORMANCE_DIR}")" + + if [ ! -e "${CONFORMANCE_DIR}" ]; then + git init "${CONFORMANCE_DIR}" + git -C "${CONFORMANCE_DIR}" remote add origin \ + https://github.com/modelcontextprotocol/conformance.git + fi + + if [ ! -d "${CONFORMANCE_DIR}/.git" ]; then + echo "ERROR: ${CONFORMANCE_DIR} exists but is not a git checkout." >&2 + echo " Set OPENSHELL_MCP_CONFORMANCE_DIR to another path or remove the directory." >&2 + exit 2 + fi + + if is_commit_ref "${CONFORMANCE_REF}"; then + local current_head="" + current_head="$(git -C "${CONFORMANCE_DIR}" rev-parse HEAD 2>/dev/null || true)" + if [ "${current_head}" = "${CONFORMANCE_REF}" ] \ + && git -C "${CONFORMANCE_DIR}" diff --quiet \ + && git -C "${CONFORMANCE_DIR}" diff --cached --quiet; then + echo "Using cached MCP conformance checkout ${CONFORMANCE_REF}." >&2 + return + fi + fi + + git -C "${CONFORMANCE_DIR}" fetch --depth 1 origin "${CONFORMANCE_REF}" + git -C "${CONFORMANCE_DIR}" checkout --force --detach FETCH_HEAD +} + +docker_image_label() { + local image=$1 + local label=$2 + + docker image inspect \ + --format "{{ index .Config.Labels \"${label}\" }}" \ + "${image}" 2>/dev/null || true +} + +openshell_bin() { + if [ -n "${OPENSHELL_BIN:-}" ]; then + printf '%s\n' "${OPENSHELL_BIN}" + return + fi + + local target_dir + target_dir="$(e2e_cargo_target_dir "${ROOT}")" + printf '%s\n' "${target_dir}/debug/openshell" +} + +start_host_bridge() { + local port=$1 + local openshell runner_ip + HOST_BRIDGE_LOG="${ROOT}/.cache/mcp-conformance/host-bridge.log" + mkdir -p "$(dirname "${HOST_BRIDGE_LOG}")" + + if ! openshell="$(openshell_bin)"; then + return 1 + fi + if ! runner_ip="$(runner_container_ip)"; then + return 1 + fi + + RUNNER_CONTAINER_IP="${runner_ip}" + HOST_BRIDGE_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" + OPENSHELL_BIN="${openshell}" \ + OPENSHELL_MCP_CONFORMANCE_RUNNER_IP="${runner_ip}" \ + OPENSHELL_MCP_CONFORMANCE_BRIDGE_TOKEN="${HOST_BRIDGE_TOKEN}" \ + python3 "${ROOT}/e2e/mcp-conformance/host-bridge.py" \ + "${port}" "${ROOT}" "${HOST_BRIDGE_LOG}" & + HOST_BRIDGE_PID=$! + + local deadline + deadline=$((SECONDS + ${OPENSHELL_MCP_CONFORMANCE_HOST_BRIDGE_START_TIMEOUT_SECONDS:-10})) + until python3 - "${port}" <<'PY' +import socket +import sys + +try: + with socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=0.2): + pass +except OSError: + raise SystemExit(1) +PY + do + if ! kill -0 "${HOST_BRIDGE_PID}" 2>/dev/null; then + echo "ERROR: MCP conformance host bridge exited before becoming ready (see ${HOST_BRIDGE_LOG})." >&2 + return 1 + fi + if [ "${SECONDS}" -ge "${deadline}" ]; then + echo "ERROR: MCP conformance host bridge did not become ready on 127.0.0.1:${port} (see ${HOST_BRIDGE_LOG})." >&2 + return 1 + fi + sleep 0.1 + done +} + +# Resolve the hostname the runner container uses to reach the host bridge. +# In CI, e2e/with-docker-gateway.sh connects the job container (which hosts the +# bridge) to the e2e Docker network with the host.openshell.internal alias. On +# local Docker Desktop, host.docker.internal reaches the host. On local Linux, +# the runner container is started with --add-host ...:host-gateway. +host_bridge_hostname() { + if [ -n "${OPENSHELL_MCP_CONFORMANCE_HOST_BRIDGE_HOSTNAME:-}" ]; then + printf '%s\n' "${OPENSHELL_MCP_CONFORMANCE_HOST_BRIDGE_HOSTNAME}" + return + fi + + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + printf '%s\n' "host.openshell.internal" + elif [ "$(uname -s)" = "Darwin" ]; then + printf '%s\n' "host.docker.internal" + else + printf '%s\n' "host.openshell.internal" + fi +} + +runner_container_ip() { + local network ip + + network="${OPENSHELL_E2E_DOCKER_NETWORK_NAME:-${OPENSHELL_E2E_NETWORK_NAME:-}}" + if [ -z "${network}" ]; then + echo "ERROR: no e2e Docker network resolved for the MCP conformance runner container." >&2 + return 1 + fi + if [ -z "${RUNNER_CONTAINER}" ]; then + echo "ERROR: MCP conformance runner container has not been started." >&2 + return 1 + fi + + ip="$(docker inspect \ + --format "{{with index .NetworkSettings.Networks \"${network}\"}}{{.IPAddress}}{{end}}" \ + "${RUNNER_CONTAINER}")" + if [ -z "${ip}" ]; then + echo "ERROR: failed to resolve MCP conformance runner IP on Docker network ${network}." >&2 + return 1 + fi + printf '%s\n' "${ip}" +} + +stop_host_bridge() { + if [ -n "${HOST_BRIDGE_PID}" ] && kill -0 "${HOST_BRIDGE_PID}" 2>/dev/null; then + kill "${HOST_BRIDGE_PID}" 2>/dev/null || true + wait "${HOST_BRIDGE_PID}" 2>/dev/null || true + fi + HOST_BRIDGE_PID="" + HOST_BRIDGE_TOKEN="" + RUNNER_CONTAINER_IP="" +} + +build_client_image() { + local conformance_head dockerfile_hash dockerignore_hash + local image_ref image_dockerfile image_dockerignore + local -a pull_args=() + + conformance_head="$(git -C "${CONFORMANCE_DIR}" rev-parse HEAD)" + dockerfile_hash="$(git -C "${ROOT}" hash-object "${ROOT}/e2e/mcp-conformance/Dockerfile.client")" + dockerignore_hash="$(git -C "${ROOT}" hash-object "${ROOT}/e2e/mcp-conformance/Dockerfile.client.dockerignore")" + + image_ref="$(docker_image_label "${CLIENT_IMAGE}" "${CLIENT_IMAGE_REF_LABEL}")" + image_dockerfile="$(docker_image_label "${CLIENT_IMAGE}" "${CLIENT_IMAGE_DOCKERFILE_LABEL}")" + image_dockerignore="$(docker_image_label "${CLIENT_IMAGE}" "${CLIENT_IMAGE_DOCKERIGNORE_LABEL}")" + if [ "${FORCE_REBUILD}" != "1" ] \ + && [ "${image_ref}" = "${conformance_head}" ] \ + && [ "${image_dockerfile}" = "${dockerfile_hash}" ] \ + && [ "${image_dockerignore}" = "${dockerignore_hash}" ]; then + echo "Using cached MCP conformance client image ${CLIENT_IMAGE} (${conformance_head})." >&2 + return + fi + + if [ "${DOCKER_PULL}" = "1" ]; then + pull_args=(--pull) + fi + + docker build "${pull_args[@]}" \ + --label "${CLIENT_IMAGE_REF_LABEL}=${conformance_head}" \ + --label "${CLIENT_IMAGE_DOCKERFILE_LABEL}=${dockerfile_hash}" \ + --label "${CLIENT_IMAGE_DOCKERIGNORE_LABEL}=${dockerignore_hash}" \ + -f "${ROOT}/e2e/mcp-conformance/Dockerfile.client" \ + -t "${CLIENT_IMAGE}" \ + "${CONFORMANCE_DIR}" +} + +create_client_sandbox() { + if [ -n "${OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX:-}" ]; then + echo "Using existing MCP conformance client sandbox ${OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX}." >&2 + return + fi + + local sandbox_name policy_file openshell + sandbox_name="openshell-mcp-client-$$" + policy_file="$(mktemp "${TMPDIR:-/tmp}/openshell-mcp-conformance-base-policy.XXXXXX.yaml")" + openshell="$(openshell_bin)" + + # The upstream runner binds its per-scenario test server with listen(0), and + # the port can be outside the OS ephemeral range. Create the reusable sandbox + # with a harmless placeholder; the client wrapper installs the exact policy + # for each scenario URL before executing the TypeScript client. + python3 "${ROOT}/e2e/mcp-conformance/render-policy.py" \ + "http://192.0.2.1:1/" "${policy_file}" \ + "${ROOT}/e2e/mcp-conformance/policy-template.yaml" >/dev/null + + echo "Creating MCP conformance client sandbox ${sandbox_name}..." >&2 + if ! "${openshell}" sandbox create \ + --name "${sandbox_name}" \ + --from "${CLIENT_IMAGE}" \ + --policy "${policy_file}" \ + --no-tty \ + -- true; then + rm -f "${policy_file}" + return 1 + fi + rm -f "${policy_file}" + + export OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX="${sandbox_name}" + export OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT="${OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT:-1}" + CLIENT_SANDBOX_MANAGED=1 +} + +cleanup_client_sandbox() { + if [ "${CLIENT_SANDBOX_MANAGED}" != "1" ]; then + return + fi + + local openshell + openshell="$(openshell_bin)" + echo "Deleting MCP conformance client sandbox ${OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX}..." >&2 + "${openshell}" sandbox delete "${OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX}" >/dev/null 2>&1 || true +} + +# Start the upstream conformance runner in a plain Docker container on the e2e +# network. The runner runs node (and the bundled MCP test server) off the host +# for isolation, but unlike an OpenShell sandbox it has an ordinary, +# externally-routable network address: its listen(0) test server is reachable +# from the client sandbox, and it can call the host bridge back directly. +create_runner_container() { + local network + local -a add_host_args=() + + network="${OPENSHELL_E2E_DOCKER_NETWORK_NAME:-${OPENSHELL_E2E_NETWORK_NAME:-}}" + if [ -z "${network}" ]; then + echo "ERROR: no e2e Docker network resolved for the MCP conformance runner container." >&2 + return 1 + fi + + RUNNER_CONTAINER="openshell-mcp-runner-$$" + + # On local Linux the host bridge runs on the host, so map the bridge hostnames + # to the Docker host gateway. CI (job-container network alias) and Docker + # Desktop resolve these names without an explicit mapping. + if [ "${GITHUB_ACTIONS:-}" != "true" ] && [ "$(uname -s)" != "Darwin" ]; then + add_host_args=(--add-host "host.openshell.internal:host-gateway" --add-host "host.docker.internal:host-gateway") + fi + + echo "Starting MCP conformance runner container ${RUNNER_CONTAINER} on Docker network ${network}..." >&2 + if ! docker run -d --rm \ + --name "${RUNNER_CONTAINER}" \ + --network "${network}" \ + "${add_host_args[@]}" \ + "${CLIENT_IMAGE}" \ + sleep infinity >/dev/null; then + RUNNER_CONTAINER="" + return 1 + fi + + if ! docker cp "${ROOT}/e2e/mcp-conformance/runner-shim.mjs" "${RUNNER_CONTAINER}:/tmp/openshell-mcp-runner-shim.mjs" \ + || ! docker cp "${ROOT}/e2e/mcp-conformance/expected-failures.yml" "${RUNNER_CONTAINER}:/tmp/expected-failures.yml"; then + return 1 + fi +} + +cleanup_runner_container() { + if [ -n "${RUNNER_CONTAINER}" ]; then + docker rm -f "${RUNNER_CONTAINER}" >/dev/null 2>&1 || true + fi + RUNNER_CONTAINER="" +} + +scenario_list_for_args() { + local scenario_list + local -a scenario_args=("$@") + + if [ "${#scenario_args[@]}" -gt 0 ]; then + scenario_list="${scenario_args[*]}" + elif [ -n "${SCENARIOS}" ]; then + scenario_list="${SCENARIOS}" + else + scenario_list="${DEFAULT_SCENARIOS[*]}" + fi + + printf '%s\n' "${scenario_list}" +} + +run_scenarios_in_runner_container() { + local bridge_host bridge_port scenario scenario_list + local -a passed=() + local -a failed=() + + scenario_list="$(scenario_list_for_args "$@")" + if [ -z "${scenario_list}" ]; then + echo "ERROR: no MCP conformance scenarios resolved." >&2 + return 2 + fi + + bridge_port="$(e2e_pick_port)" + create_client_sandbox || return 1 + create_runner_container || return 1 + start_host_bridge "${bridge_port}" || return 1 + + bridge_host="$(host_bridge_hostname)" + echo "MCP conformance host bridge callback: http://${bridge_host}:${bridge_port}/run" >&2 + + for scenario in ${scenario_list}; do + echo "=== MCP conformance: ${scenario} ===" + # shellcheck disable=SC2016 + if docker exec \ + --env "MCP_CONFORMANCE_HOST_BRIDGE_URL=http://${bridge_host}:${bridge_port}/run" \ + --env "MCP_CONFORMANCE_HOST_BRIDGE_TOKEN=${HOST_BRIDGE_TOKEN}" \ + --env "MCP_CONFORMANCE_RUNNER_IP=${RUNNER_CONTAINER_IP}" \ + "${RUNNER_CONTAINER}" \ + sh -c 'cd /opt/mcp-conformance && exec node dist/index.js client --command "node /tmp/openshell-mcp-runner-shim.mjs" --scenario "$1" --spec-version "$2" --expected-failures "$3" --timeout "$4"' \ + sh "${scenario}" "${SPEC_VERSION}" "/tmp/expected-failures.yml" "${TIMEOUT_MS}" \ + }" + echo "Failed (${#failed[@]}): ${failed[*]:-}" + + if [ "${#failed[@]}" -ne 0 ]; then + return 1 + fi +} + +cleanup_scenario_resources() { + cleanup_runner_container + stop_host_bridge + cleanup_client_sandbox +} + +run_scenarios_with_client_sandbox() { + # Tear down the runner container, host bridge, and client sandbox on any exit, + # including Ctrl-C / SIGTERM. The cleanups are no-ops if their resource was + # never created, so an early failure is safe too. + trap cleanup_scenario_resources EXIT + trap 'exit 130' INT TERM + + run_scenarios_in_runner_container "$@" +} + +run_scenarios_under_gateway() { + export OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE="${CLIENT_IMAGE}" + export OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE="${OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE:-${CLIENT_IMAGE}}" + + "${ROOT}/e2e/with-docker-gateway.sh" bash "${BASH_SOURCE[0]}" "${RUN_SCENARIOS_COMMAND}" "$@" +} + +main() { + cd "${ROOT}" + + if [ "${1:-}" = "${RUN_SCENARIOS_COMMAND}" ]; then + shift + run_scenarios_with_client_sandbox "$@" + return + fi + + # git fetches and pins the upstream conformance repo (and hashes it for image + # caching); docker builds and runs the runner/client containers; python3 runs + # the host bridge and renders policies. The conformance runner itself (node, + # npm, tsx) now runs inside the container image, not on the host. + require_command git + require_command docker + require_command python3 + + echo "MCP conformance spec version: ${SPEC_VERSION}" >&2 + checkout_conformance + build_client_image + run_scenarios_under_gateway "$@" +} + +main "$@" diff --git a/e2e/mcp-conformance/Dockerfile.client b/e2e/mcp-conformance/Dockerfile.client new file mode 100644 index 000000000..e40e23df5 --- /dev/null +++ b/e2e/mcp-conformance/Dockerfile.client @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM public.ecr.aws/docker/library/node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates iproute2 \ + && rm -rf /var/lib/apt/lists/* + +ARG SANDBOX_UID=1000660000 +ARG SANDBOX_GID=1000660000 + +# Match the sandbox user expected by OpenShell policies and supervisor setup. +# The UID/GID are intentionally outside Debian's default login.defs range. +RUN groupadd -K "GID_MAX=${SANDBOX_GID}" -g "${SANDBOX_GID}" sandbox \ + && useradd -K "UID_MAX=${SANDBOX_UID}" --no-log-init -m -u "${SANDBOX_UID}" -g sandbox sandbox + +WORKDIR /opt/mcp-conformance + +COPY . . +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +RUN npm run build +RUN chown -R sandbox:sandbox /opt/mcp-conformance /home/sandbox + +USER sandbox +CMD ["sleep", "infinity"] diff --git a/e2e/mcp-conformance/Dockerfile.client.dockerignore b/e2e/mcp-conformance/Dockerfile.client.dockerignore new file mode 100644 index 000000000..d0ef9a556 --- /dev/null +++ b/e2e/mcp-conformance/Dockerfile.client.dockerignore @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +.git +dist +node_modules +.openshell-mcp-conformance-*.stamp diff --git a/e2e/mcp-conformance/README.md b/e2e/mcp-conformance/README.md new file mode 100644 index 000000000..5f0659323 --- /dev/null +++ b/e2e/mcp-conformance/README.md @@ -0,0 +1,71 @@ +# MCP Conformance E2E + +This directory contains the OpenShell wrapper for the upstream +`modelcontextprotocol/conformance` runner. + +The workflow checks out and builds the upstream conformance repository, then +runs its CLI in client mode. To keep the untrusted upstream node runner off the +host, the wrapper runs it inside a plain Docker container on the e2e Docker +network (not an OpenShell sandbox, which is egress-only and could not accept the +client's inbound connection). The upstream runner starts a real MCP test server +and invokes its client command — `runner-shim.mjs` — with that server URL. + +`runner-shim.mjs` stands in for the MCP client: instead of speaking MCP itself, +it posts the server URL back to the host bridge (`host-bridge.py`) over HTTP. The +host bridge runs `client-through-openshell.sh`, which runs the upstream +TypeScript `everything-client` inside an OpenShell client sandbox for each +scenario, so the MCP traffic crosses the sandbox proxy. A single Docker-backed +OpenShell e2e gateway and one reusable client sandbox serve the whole scenario +list. The runner deliberately has no gateway credentials; keeping the privileged +client launch on `host-bridge.py` is the trust boundary. The harness gives the +runner a per-run bridge capability and gives the bridge the runner container IP. +The bridge only accepts requests with that capability, only renders server URLs +whose host is the runner container IP, only forwards the MCP conformance +scenario environment allowlist, and starts the client wrapper with a small host +environment allowlist instead of inheriting token-bearing host environment +variables. It does not use the HTTP peer source address as the runner identity, +because Docker NAT can make legitimate callbacks appear to come from a gateway +address. + +The upstream runner reports its test server URL as `localhost`. The runner +container has an ordinary, externally-routable address on the e2e network, so +`runner-shim.mjs` rewrites `localhost` to that container's IP — which the client +sandbox can reach through its egress proxy. The runner container reaches the host +bridge at `host.openshell.internal` (the alias `e2e/with-docker-gateway.sh` +attaches to the CI job container on the e2e network), at `host.docker.internal` +on local Docker Desktop, or via `--add-host ...:host-gateway` on local Linux. + +The generated policy allows valid JSON-RPC requests to the conformance server +with `method: "*"`. That keeps OpenShell deny-by-default at the network +boundary while allowing the upstream scenarios to exercise MCP behavior. The +policy body lives in `policy-template.yaml`; the wrapper renders its host, port, +and path placeholders from the upstream server URL. + +The upstream `everything-client` has a few handler names that do not line up +with released-spec scenario names. The wrapper maps those names when forwarding +`MCP_CONFORMANCE_SCENARIO` into the sandbox, but it does not patch the upstream +checkout. + +When enabling broader upstream suites, add scenarios that OpenShell does not yet +support through the JSON-RPC proxy to `expected-failures.yml`. The upstream +runner treats listed failures as allowed and treats stale entries as failures. +The default run uses a static scenario list in `e2e/mcp-conformance.sh`. To +refresh it after changing the pinned upstream ref or default spec, list the +scenarios from the built client image: + +```shell +docker run --rm openshell-mcp-conformance-client:local \ + ./node_modules/.bin/tsx src/index.ts list --client --spec-version 2025-11-25 +``` + +Then confirm each scenario has a compatible handler in the pinned +`examples/clients/typescript/everything-client.ts`. The default list skips +opt-in scenarios, including auth/OAuth flows and the slow `sse-retry` scenario. +Set `OPENSHELL_MCP_CONFORMANCE_SCENARIOS=sse-retry` or pass `sse-retry` as an +argument to run it explicitly. + +The wrapper caches the pinned upstream checkout, the local conformance runner +build, and the Docker client image. Set +`OPENSHELL_MCP_CONFORMANCE_FORCE_REBUILD=1` to refresh those build artifacts, or +`OPENSHELL_MCP_CONFORMANCE_DOCKER_PULL=1` to pull the client image base during a +rebuild. diff --git a/e2e/mcp-conformance/client-through-openshell.sh b/e2e/mcp-conformance/client-through-openshell.sh new file mode 100755 index 000000000..f236c91a3 --- /dev/null +++ b/e2e/mcp-conformance/client-through-openshell.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs the upstream MCP conformance client in an OpenShell-managed sandbox. +# +# The modelcontextprotocol/conformance runner runs in a separate container and +# posts the URL of its MCP test server to a host bridge, which invokes this +# script with that URL. The parent harness creates one reusable conformance +# client sandbox for the whole scenario list before this script is invoked. This +# wrapper verifies the active gateway is reachable, applies the per-scenario +# server policy to that sandbox, and runs the upstream TypeScript +# everything-client inside it so its MCP traffic crosses the sandbox proxy. + +set -euo pipefail + +usage() { + echo "usage: $0 " >&2 +} + +if [ "$#" -ne 1 ]; then + usage + exit 2 +fi + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + +# shellcheck source=e2e/support/gateway-common.sh disable=SC1091 +source "${ROOT}/e2e/support/gateway-common.sh" +if [ -z "${OPENSHELL_BIN:-}" ]; then + TARGET_DIR="$(e2e_cargo_target_dir "${ROOT}")" + OPENSHELL_BIN="${TARGET_DIR}/debug/openshell" +fi + +require_active_gateway() { + local status_output + + if ! status_output="$("${OPENSHELL_BIN}" status 2>&1)"; then + echo "ERROR: no reachable active OpenShell gateway for MCP conformance." >&2 + echo " Run e2e/mcp-conformance.sh so it starts one shared Docker-backed gateway." >&2 + echo "=== openshell status output ===" >&2 + printf '%s\n' "${status_output}" >&2 + echo "=== end openshell status output ===" >&2 + exit 2 + fi +} + +SERVER_URL="$1" +CLIENT_SANDBOX="${OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX:?set OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX to the reusable conformance client sandbox name}" +POLICY_TEMPLATE="${ROOT}/e2e/mcp-conformance/policy-template.yaml" +POLICY_WAIT="${OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT:-0}" +POLICY_WAIT_TIMEOUT="${OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT_TIMEOUT:-60}" +CLIENT_TIMEOUT_SECONDS="${OPENSHELL_MCP_CONFORMANCE_CLIENT_TIMEOUT_SECONDS:-120}" + +require_active_gateway + +POLICY_FILE="$(mktemp "${TMPDIR:-/tmp}/openshell-mcp-conformance-policy.XXXXXX.yaml")" +trap 'rm -f "${POLICY_FILE}"' EXIT + +CLIENT_SERVER_URL="$(python3 "${ROOT}/e2e/mcp-conformance/render-policy.py" "${SERVER_URL}" "${POLICY_FILE}" "${POLICY_TEMPLATE}")" + +ENV_ARGS=() +CLIENT_SCENARIO="${MCP_CONFORMANCE_SCENARIO:-}" +case "${CLIENT_SCENARIO}" in + elicitation-sep1034-client-defaults) + CLIENT_SCENARIO="elicitation-defaults" + ;; + sse-retry) + CLIENT_SCENARIO="tools_call" + ;; +esac + +# These environment variables are set by the upstream conformance test runner +# before it invokes the configured client command. Forward them into the +# sandbox because the sandboxed TypeScript client depends on them to select the +# scenario and read scenario-specific context. +for NAME in MCP_CONFORMANCE_SCENARIO MCP_CONFORMANCE_CONTEXT MCP_CONFORMANCE_PROTOCOL_VERSION; do + if [ "${NAME}" = "MCP_CONFORMANCE_SCENARIO" ] && [ -n "${CLIENT_SCENARIO}" ]; then + ENV_ARGS+=(--env "MCP_CONFORMANCE_SCENARIO=${CLIENT_SCENARIO}") + elif [ -n "${!NAME+x}" ]; then + ENV_ARGS+=(--env "${NAME}=${!NAME}") + fi +done + +POLICY_SET_COMMAND=( + "${OPENSHELL_BIN}" policy set "${CLIENT_SANDBOX}" + --policy "${POLICY_FILE}" +) +if [ "${POLICY_WAIT}" = "1" ]; then + POLICY_SET_COMMAND+=(--wait --timeout "${POLICY_WAIT_TIMEOUT}") +fi +"${POLICY_SET_COMMAND[@]}" + +# shellcheck disable=SC2016 +SANDBOX_COMMAND=( + "${OPENSHELL_BIN}" sandbox exec + --name "${CLIENT_SANDBOX}" + --no-tty + --timeout "${CLIENT_TIMEOUT_SECONDS}" + "${ENV_ARGS[@]}" + -- + sh -c 'cd /opt/mcp-conformance && exec ./node_modules/.bin/tsx examples/clients/typescript/everything-client.ts "$1"' + sh "${CLIENT_SERVER_URL}" +) + +"${SANDBOX_COMMAND[@]}" +""" + +import hmac +import json +import os +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from ipaddress import ip_address +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +PORT = int(sys.argv[1]) +ROOT = Path(sys.argv[2]) +LOG_PATH = Path(sys.argv[3]) +TIMEOUT = ( + int(os.environ.get("OPENSHELL_MCP_CONFORMANCE_CLIENT_TIMEOUT_SECONDS", "120")) + 30 +) +REQUEST_BODY_TIMEOUT_SECONDS = 10 +MAX_REQUEST_BODY_BYTES = 256 * 1024 +TOKEN_HEADER = "x-openshell-mcp-conformance-token" +BRIDGE_TOKEN = os.environ["OPENSHELL_MCP_CONFORMANCE_BRIDGE_TOKEN"] +RUNNER_IP = os.environ["OPENSHELL_MCP_CONFORMANCE_RUNNER_IP"] +ALLOWED_CONFORMANCE_ENV = frozenset( + { + "MCP_CONFORMANCE_SCENARIO", + "MCP_CONFORMANCE_CONTEXT", + "MCP_CONFORMANCE_PROTOCOL_VERSION", + } +) +HOST_ENV_ALLOWLIST = frozenset( + { + "CARGO_HOME", + "CARGO_TARGET_DIR", + "HOME", + "LANG", + "LC_ALL", + "MISE_CACHE_DIR", + "MISE_CONFIG_DIR", + "MISE_DATA_DIR", + "MISE_STATE_DIR", + "OPENSHELL_BIN", + "OPENSHELL_GATEWAY", + "OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX", + "OPENSHELL_MCP_CONFORMANCE_CLIENT_TIMEOUT_SECONDS", + "OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT", + "OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT_TIMEOUT", + "OPENSHELL_PROVISION_TIMEOUT", + "PATH", + "RUSTUP_HOME", + "TMP", + "TEMP", + "TMPDIR", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + } +) + + +def log(message: str) -> None: + with LOG_PATH.open("a", encoding="utf-8") as fh: + fh.write(message + "\n") + + +def canonical_ip(value: str): + parsed = ip_address(value) + return getattr(parsed, "ipv4_mapped", None) or parsed + + +def captured_text(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + + +def subprocess_env( + payload_env: dict[str, str], expected_server_host: str +) -> dict[str, str]: + env = {name: os.environ[name] for name in HOST_ENV_ALLOWLIST if name in os.environ} + env.update(payload_env) + env["OPENSHELL_MCP_CONFORMANCE_EXPECTED_SERVER_HOST"] = expected_server_host + return env + + +class Handler(BaseHTTPRequestHandler): + def setup(self) -> None: + super().setup() + self.connection.settimeout(REQUEST_BODY_TIMEOUT_SECONDS) + + def send_json(self, status: int, body: dict[str, object]) -> None: + encoded = json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(encoded))) + self.send_header("connection", "close") + self.end_headers() + self.wfile.write(encoded) + + def reject(self, status: int, detail: str) -> None: + self.close_connection = True + log(f"rejecting bridge request from {self.client_address[0]}: {detail}") + self.send_json(status, {"error": "invalid_bridge_request", "detail": detail}) + + def bridge_token_valid(self) -> bool: + supplied = self.headers.get(TOKEN_HEADER, "") + return hmac.compare_digest(supplied, BRIDGE_TOKEN) + + def request_body_length(self) -> int | None: + raw_length = self.headers.get("content-length") + if raw_length is None: + self.reject(411, "content-length is required") + return None + try: + length = int(raw_length) + except ValueError: + self.reject(400, "content-length must be an integer") + return None + if length < 0: + self.reject(400, "content-length must not be negative") + return None + if length > MAX_REQUEST_BODY_BYTES: + self.reject(413, "request body is too large") + return None + return length + + def read_request_payload(self, length: int) -> dict[str, Any] | None: + try: + raw_body = self.rfile.read(length) + except TimeoutError: + self.reject(408, "timed out reading request body") + return None + if len(raw_body) != length: + self.reject(400, "request body ended before content-length") + return None + try: + payload = json.loads(raw_body) + except json.JSONDecodeError as err: + self.reject(400, str(err)) + return None + if not isinstance(payload, dict): + self.reject(400, "request body must be a JSON object") + return None + return payload + + def do_POST(self) -> None: + if self.path != "/run": + self.send_response(404) + self.end_headers() + return + + if not self.bridge_token_valid(): + self.reject(403, "invalid bridge capability") + return + + length = self.request_body_length() + if length is None: + return + + payload = self.read_request_payload(length) + if payload is None: + return + + server_url = payload.get("server_url") + if not isinstance(server_url, str): + self.reject(400, "server_url must be a string") + return + + parsed = urlparse(server_url) + if parsed.scheme not in {"http", "https"}: + self.reject(403, "server_url scheme must be http or https") + return + + try: + target_ip = canonical_ip(parsed.hostname or "") + expected_ip = canonical_ip(RUNNER_IP) + except ValueError: + self.reject(403, "server_url host must match the runner container IP") + return + + if target_ip != expected_ip: + self.reject(403, "server_url host must match the runner container IP") + return + + payload_env = payload.get("env", {}) + if not isinstance(payload_env, dict): + self.reject(400, "env must be an object") + return + if any(name not in ALLOWED_CONFORMANCE_ENV for name in payload_env): + self.reject(403, "env contains unsupported keys") + return + if any(not isinstance(value, str) for value in payload_env.values()): + self.reject(400, "env values must be strings") + return + + env = subprocess_env(payload_env, str(expected_ip)) + log(f"running client for {server_url}") + try: + result = subprocess.run( + ["bash", "e2e/mcp-conformance/client-through-openshell.sh", server_url], + cwd=ROOT, + env=env, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=TIMEOUT, + ) + log(f"client exited {result.returncode} for {server_url}") + body = { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + except subprocess.TimeoutExpired as err: + body = { + "exit_code": 124, + "stdout": captured_text(err.stdout), + "stderr": captured_text(err.stderr) + + f"\nhost bridge timed out after {TIMEOUT}s\n", + } + self.send_json(200, body) + + def log_message(self, format: str, *args: Any) -> None: + log(format % args) + + +def main() -> None: + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + log(f"host bridge listening on {PORT}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/e2e/mcp-conformance/policy-template.yaml b/e2e/mcp-conformance/policy-template.yaml new file mode 100644 index 000000000..9554dc5fa --- /dev/null +++ b/e2e/mcp-conformance/policy-template.yaml @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /bin + - /usr + - /lib + - /lib64 + - /proc + - /sys + - /dev/urandom + - /etc + - /opt + - /var/log + read_write: + - /sandbox + - /tmp + - /dev/null + - /home/sandbox + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + mcp_conformance: + name: mcp_conformance + endpoints: + - ${host_spec} +${port_spec} + path: ${path} + protocol: json-rpc + enforcement: enforce + allowed_ips: + - "10.0.0.0/8" + - "172.16.0.0/12" + - "192.168.0.0/16" + - "fc00::/7" + json_rpc: + max_body_bytes: 131072 + rules: + - allow: + method: "*" + binaries: + - path: /bin/sh + - path: /usr/bin/env + - path: /usr/local/bin/node + - path: /usr/bin/node + - path: /opt/mcp-conformance/node_modules/.bin/* diff --git a/e2e/mcp-conformance/render-policy.py b/e2e/mcp-conformance/render-policy.py new file mode 100644 index 000000000..6f7a0db92 --- /dev/null +++ b/e2e/mcp-conformance/render-policy.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Render the OpenShell client policy for a conformance server URL. + +Parses the server URL into host/port/path, substitutes them into +policy-template.yaml, writes the rendered policy, and prints the (possibly +rewritten) URL the client should connect to. Used both to seed the reusable +client sandbox with a placeholder policy and to install the per-scenario policy +in client-through-openshell.sh. + +Usage: render-policy.py +""" + +import json +import os +import string +import sys +from ipaddress import ip_address +from pathlib import Path +from urllib.parse import urlparse, urlunparse + +raw_url, policy_file, policy_template = sys.argv[1:4] +parsed = urlparse(raw_url) + +if parsed.scheme not in ("http", "https"): + raise SystemExit(f"unsupported conformance server URL scheme: {parsed.scheme!r}") + +host = parsed.hostname +if not host: + raise SystemExit(f"conformance server URL is missing a host: {raw_url}") + +expected_host = os.environ.get("OPENSHELL_MCP_CONFORMANCE_EXPECTED_SERVER_HOST") +if expected_host: + try: + actual_ip = ip_address(host) + actual_ip = getattr(actual_ip, "ipv4_mapped", None) or actual_ip + expected_ip = ip_address(expected_host) + expected_ip = getattr(expected_ip, "ipv4_mapped", None) or expected_ip + except ValueError as err: + raise SystemExit( + "conformance server URL host must be an IP address when " + "OPENSHELL_MCP_CONFORMANCE_EXPECTED_SERVER_HOST is set" + ) from err + if actual_ip != expected_ip: + raise SystemExit( + f"conformance server URL host {actual_ip} does not match expected " + f"runner host {expected_ip}" + ) + +target_host = ( + "host.openshell.internal" if host in {"localhost", "127.0.0.1", "::1"} else host +) +port = parsed.port or (443 if parsed.scheme == "https" else 80) +path = parsed.path or "/" +netloc_host = ( + f"[{target_host}]" + if ":" in target_host and not target_host.startswith("[") + else target_host +) +netloc = f"{netloc_host}:{port}" +rewritten = urlunparse( + (parsed.scheme, netloc, path, parsed.params, parsed.query, parsed.fragment) +) + +template = string.Template(Path(policy_template).read_text(encoding="utf-8")) +policy = template.substitute( + host_spec=f"host: {json.dumps(target_host)}", + port_spec=f" port: {port}", + path=json.dumps(path), +) +Path(policy_file).write_text(policy, encoding="utf-8") + +print(rewritten) diff --git a/e2e/mcp-conformance/runner-shim.mjs b/e2e/mcp-conformance/runner-shim.mjs new file mode 100644 index 000000000..41a4423f8 --- /dev/null +++ b/e2e/mcp-conformance/runner-shim.mjs @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Client command for the upstream MCP conformance runner, executed inside the +// runner container. +// +// Why this indirection exists: the conformance runner runs untrusted upstream +// node off the host, so it deliberately has no openshell binary and no gateway +// credentials. But the MCP client under test must run inside an OpenShell +// sandbox so its traffic crosses the policy-enforced proxy (the whole point of +// the e2e). This script bridges that gap without widening the runner's +// privileges: instead of running the MCP client itself, it posts the test +// server URL to the host bridge, which holds the gateway credentials and runs +// the real client in a sandbox, then returns its result. The runner can only +// ask "run a client against this URL" — it cannot touch the gateway control +// plane. + +import http from 'node:http'; +import os from 'node:os'; +import process from 'node:process'; + +// Resolve the routable IPv4 address of the runner container on the e2e Docker +// network. The conformance runner reports its test server URL as localhost; the +// client sandbox connects from a different container, so localhost must be +// rewritten to this container's network address. +function runnerAddress() { + if (process.env.MCP_CONFORMANCE_RUNNER_IP) { + return process.env.MCP_CONFORMANCE_RUNNER_IP; + } + for (const addrs of Object.values(os.networkInterfaces())) { + for (const addr of addrs ?? []) { + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address; + } + } + } + throw new Error('failed to resolve runner container IPv4 address'); +} + +function rewriteServerUrl(rawUrl) { + const url = new URL(rawUrl); + if (['localhost', '127.0.0.1', '[::1]', '::1'].includes(url.hostname)) { + url.hostname = runnerAddress(); + } + return url.toString(); +} + +const bridgeUrl = process.env.MCP_CONFORMANCE_HOST_BRIDGE_URL; +if (!bridgeUrl) { + throw new Error('MCP_CONFORMANCE_HOST_BRIDGE_URL is required'); +} +const bridgeToken = process.env.MCP_CONFORMANCE_HOST_BRIDGE_TOKEN; +if (!bridgeToken) { + throw new Error('MCP_CONFORMANCE_HOST_BRIDGE_TOKEN is required'); +} +const parsedBridgeUrl = new URL(bridgeUrl); +if (parsedBridgeUrl.protocol !== 'http:') { + throw new Error(`bridge URL must use http:, got ${parsedBridgeUrl.protocol}`); +} +const serverUrl = process.argv[2]; +if (!serverUrl) { + throw new Error('usage: node runner-shim.mjs '); +} + +// POST the rewritten server URL to the host bridge, which runs the real MCP +// client inside an OpenShell sandbox and returns its result. The runner is a +// plain container with ordinary egress, so this is a direct HTTP call. +function postJson(url, payload) { + const body = Buffer.from(JSON.stringify(payload), 'utf8'); + const timeoutMs = Number.parseInt(process.env.MCP_CONFORMANCE_HOST_BRIDGE_TIMEOUT_MS ?? '600000', 10); + + function snippet(raw) { + return raw.length > 4096 ? `${raw.slice(0, 4096)}...` : raw; + } + + return new Promise((resolve, reject) => { + const request = http.request({ + hostname: url.hostname, + port: url.port || 80, + path: `${url.pathname}${url.search}`, + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': body.length, + 'x-openshell-mcp-conformance-token': bridgeToken, + }, + agent: false, + }, (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + const statusCode = response.statusCode ?? 0; + try { + const parsed = JSON.parse(raw); + if (statusCode < 200 || statusCode >= 300) { + reject(new Error(`bridge callback failed (HTTP ${statusCode}): ${snippet(raw)}`)); + return; + } + resolve(parsed); + } catch (error) { + reject(new Error(`bridge returned invalid JSON (HTTP ${statusCode}): ${snippet(raw) || error.message}`)); + } + }); + }); + request.on('error', reject); + request.setTimeout(timeoutMs, () => { + request.destroy(new Error(`bridge callback timed out after ${timeoutMs}ms to ${url.toString()}`)); + }); + request.end(body); + }); +} + +const forwardedEnv = {}; +for (const name of [ + 'MCP_CONFORMANCE_SCENARIO', + 'MCP_CONFORMANCE_CONTEXT', + 'MCP_CONFORMANCE_PROTOCOL_VERSION', +]) { + if (process.env[name] !== undefined) { + forwardedEnv[name] = process.env[name]; + } +} + +const body = await postJson(parsedBridgeUrl, { + server_url: rewriteServerUrl(serverUrl), + env: forwardedEnv, +}); +if (typeof body.exit_code !== 'number') { + const raw = JSON.stringify(body); + throw new Error(`bridge returned JSON without numeric exit_code: ${raw.length > 4096 ? `${raw.slice(0, 4096)}...` : raw}`); +} +if (body.stdout) { + process.stdout.write(body.stdout); +} +if (body.stderr) { + process.stderr.write(body.stderr); +} +process.exit(body.exit_code); diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 083c622df..2f61f2d86 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -97,6 +97,11 @@ name = "forward_proxy_graphql_l7" path = "tests/forward_proxy_graphql_l7.rs" required-features = ["e2e-host-gateway"] +[[test]] +name = "forward_proxy_jsonrpc_l7" +path = "tests/forward_proxy_jsonrpc_l7.rs" +required-features = ["e2e-host-gateway"] + [[test]] name = "gpu_device_selection" path = "tests/gpu_device_selection.rs" diff --git a/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs b/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs new file mode 100644 index 000000000..a0f0be4b8 --- /dev/null +++ b/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs @@ -0,0 +1,530 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! E2E tests for JSON-RPC L7 inspection across both proxy entry points. +//! +//! The upstream server deliberately does not implement JSON-RPC. `OpenShell` +//! parses and enforces JSON-RPC before forwarding, so any HTTP server that +//! accepts POST /mcp is enough to prove allowed requests reach upstream +//! and denied requests are stopped by the sandbox proxy. + +#![cfg(feature = "e2e")] + +use std::io::Write; + +use openshell_e2e::harness::container::ContainerHttpServer; +use openshell_e2e::harness::sandbox::SandboxGuard; +use tempfile::NamedTempFile; + +const TEST_SERVER_ALIAS: &str = "jsonrpc-l7.openshell.test"; + +async fn start_test_server() -> Result { + let script = r#"from http.server import BaseHTTPRequestHandler, HTTPServer + +class Handler(BaseHTTPRequestHandler): + def read_body(self): + if self.headers.get("Transfer-Encoding", "").lower() == "chunked": + data = b"" + while True: + size_line = self.rfile.readline() + if not size_line: + break + size = int(size_line.split(b";", 1)[0].strip(), 16) + if size == 0: + while self.rfile.readline().strip(): + pass + break + data += self.rfile.read(size) + self.rfile.read(2) + return data + return self.rfile.read(int(self.headers.get("Content-Length", "0"))) + + def do_GET(self): + self.send_response(200) + self.end_headers() + + def do_POST(self): + self.read_body() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"jsonrpc":"2.0","id":1,"result":{}}') + + def log_message(self, format, *args): + pass + +HTTPServer(("0.0.0.0", 8000), Handler).serve_forever() +"#; + + ContainerHttpServer::start_python(TEST_SERVER_ALIAS, script).await +} + +fn write_jsonrpc_policy(host: &str, port: u16) -> Result { + let mut file = NamedTempFile::new().map_err(|e| format!("create temp policy file: {e}"))?; + let policy = format!( + r#"version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + read_write: + - /sandbox + - /tmp + - /dev/null + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + test_jsonrpc_l7: + name: test_jsonrpc_l7 + endpoints: + - host: {host} + port: {port} + path: /mcp + protocol: json-rpc + enforcement: enforce + allowed_ips: + - "10.0.0.0/8" + - "172.0.0.0/8" + - "192.168.0.0/16" + - "fc00::/7" + json_rpc: + max_body_bytes: 65536 + rules: + - allow: + method: initialize + - allow: + method: tools/list + - allow: + method: tools/call + params: + name: read_status + - allow: + method: tools/call + params: + name: submit_report + arguments.scope: workspace/main + deny_rules: + - method: tools/call + params: + name: blocked_action + binaries: + - path: /usr/bin/python* + - path: /usr/local/bin/python* + - path: /sandbox/.uv/python/*/bin/python* +"# + ); + file.write_all(policy.as_bytes()) + .map_err(|e| format!("write temp policy file: {e}"))?; + file.flush() + .map_err(|e| format!("flush temp policy file: {e}"))?; + Ok(file) +} + +fn write_jsonrpc_default_audit_policy(host: &str, port: u16) -> Result { + let mut file = NamedTempFile::new().map_err(|e| format!("create temp policy file: {e}"))?; + let policy = format!( + r#"version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + read_write: + - /sandbox + - /tmp + - /dev/null + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + test_jsonrpc_l7_audit: + name: test_jsonrpc_l7_audit + endpoints: + - host: {host} + port: {port} + path: /mcp + protocol: json-rpc + allowed_ips: + - "10.0.0.0/8" + - "100.64.0.0/10" + - "172.0.0.0/8" + - "198.18.0.0/15" + - "192.168.0.0/16" + - "fc00::/7" + json_rpc: + max_body_bytes: 65536 + rules: + - allow: + method: initialize + binaries: + - path: /usr/bin/python* + - path: /usr/local/bin/python* + - path: /sandbox/.uv/python/*/bin/python* +"# + ); + file.write_all(policy.as_bytes()) + .map_err(|e| format!("write temp policy file: {e}"))?; + file.flush() + .map_err(|e| format!("flush temp policy file: {e}"))?; + Ok(file) +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn jsonrpc_l7_enforces_method_and_params_rules_on_forward_and_connect_paths() { + let server = start_test_server().await.expect("start test server"); + let policy = write_jsonrpc_policy(&server.host, server.port).expect("write custom policy"); + let policy_path = policy + .path() + .to_str() + .expect("temp policy path should be utf-8") + .to_string(); + + let script = format!( + r#" +import json +import os +import socket +import time +import urllib.error +import urllib.parse +import urllib.request + +HOST = {host:?} +PORT = {port} +DETAILS = {{ + "debug_target": {{"host": HOST, "port": PORT}}, + "debug_proxy_env": {{ + "http_proxy": os.environ.get("http_proxy"), + "https_proxy": os.environ.get("https_proxy"), + "HTTP_PROXY": os.environ.get("HTTP_PROXY"), + "HTTPS_PROXY": os.environ.get("HTTPS_PROXY"), + "NO_PROXY": os.environ.get("NO_PROXY"), + "no_proxy": os.environ.get("no_proxy"), + }}, +}} + +def text(data): + return data.decode(errors="replace") + +def selected_headers(headers): + return {{ + key.lower(): value + for key, value in headers.items() + if key.lower() in ("content-type", "content-length", "server") + }} + +def record_http_error(label, error, request_body): + response_body = error.read() + DETAILS[f"{{label}}_request"] = request_body + DETAILS[f"{{label}}_response"] = {{ + "status": error.code, + "reason": str(error.reason), + "headers": selected_headers(error.headers), + "body": text(response_body), + }} + return error.code + +def post_jsonrpc(label, method, params=None, req_id=1): + body = {{"jsonrpc": "2.0", "id": req_id, "method": method}} + if params is not None: + body["params"] = params + encoded = json.dumps(body).encode() + request = urllib.request.Request( + f"http://{{HOST}}:{{PORT}}/mcp", + data=encoded, + headers={{"Content-Type": "application/json"}}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + response.read() + return response.status + except urllib.error.HTTPError as error: + return record_http_error(label, error, body) + +def post_jsonrpc_batch(label, requests): + encoded = json.dumps(requests).encode() + request = urllib.request.Request( + f"http://{{HOST}}:{{PORT}}/mcp", + data=encoded, + headers={{"Content-Type": "application/json"}}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + response.read() + return response.status + except urllib.error.HTTPError as error: + return record_http_error(label, error, requests) + +def post_invalid_json(label): + encoded = b"not valid json {{" + request = urllib.request.Request( + f"http://{{HOST}}:{{PORT}}/mcp", + data=encoded, + headers={{"Content-Type": "application/json", "Content-Length": str(len(encoded))}}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + response.read() + return response.status + except urllib.error.HTTPError as error: + return record_http_error(label, error, text(encoded)) + +def proxy_parts(*names): + proxy_url = next((os.environ.get(name) for name in names if os.environ.get(name)), None) + parsed = urllib.parse.urlparse(proxy_url) + return parsed.hostname, parsed.port or 80 + +def read_until(sock, marker): + data = b"" + while marker not in data: + chunk = sock.recv(4096) + if not chunk: + break + data += chunk + return data + +def read_response(sock): + response = read_until(sock, b"\r\n\r\n") + headers, _, body = response.partition(b"\r\n\r\n") + content_length = 0 + for line in headers.split(b"\r\n")[1:]: + if line.lower().startswith(b"content-length:"): + content_length = int(line.split(b":", 1)[1].strip()) + break + while len(body) < content_length: + chunk = sock.recv(4096) + if not chunk: + break + body += chunk + return response, body + +def status_code(response, label): + parts = response.split() + if len(parts) < 2: + DETAILS[f"{{label}}_raw"] = response.decode(errors="replace") + raise RuntimeError(f"{{label}}: malformed HTTP response: {{response!r}}") + try: + return int(parts[1]) + except ValueError as error: + DETAILS[f"{{label}}_raw"] = response.decode(errors="replace") + raise RuntimeError(f"{{label}}: non-numeric HTTP status: {{response!r}}") from error + +def record_raw_response(label, response, body=b""): + code = status_code(response, label) + if code != 200: + DETAILS[f"{{label}}_raw"] = text(response) + if body: + DETAILS[f"{{label}}_body"] = text(body) + return code + +def connect_http_status(label, request): + proxy_host, proxy_port = proxy_parts("HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy") + target = f"{{HOST}}:{{PORT}}" + + last_error = None + for attempt in range(5): + try: + with socket.create_connection((proxy_host, proxy_port), timeout=15) as sock: + sock.sendall( + f"CONNECT {{target}} HTTP/1.1\r\nHost: {{target}}\r\n\r\n".encode() + ) + connect_response = read_until(sock, b"\r\n\r\n") + connect_code = record_raw_response(f"{{label}}_connect", connect_response) + if connect_code != 200: + return connect_code + sock.sendall(request) + sock.shutdown(socket.SHUT_WR) + response, body = read_response(sock) + return record_raw_response(f"{{label}}_response", response, body) + except (OSError, RuntimeError) as error: + last_error = error + DETAILS[f"{{label}}_attempt_{{attempt + 1}}_error"] = str(error) + time.sleep(0.2) + + raise RuntimeError(f"{{label}}: failed after 5 attempts: {{last_error}}") + +def connect_jsonrpc_status(method, params, label): + target = f"{{HOST}}:{{PORT}}" + body = {{"jsonrpc": "2.0", "id": 1, "method": method}} + if params is not None: + body["params"] = params + encoded = json.dumps(body).encode() + request = ( + f"POST /mcp HTTP/1.1\r\n" + f"Host: {{target}}\r\n" + f"Content-Type: application/json\r\n" + f"Content-Length: {{len(encoded)}}\r\n" + f"Connection: close\r\n" + f"\r\n" + ).encode() + encoded + return connect_http_status(label, request) + +results = {{ + # forward proxy — method-only allow rules + "forward_method_initialize_allowed": post_jsonrpc("forward_method_initialize_allowed", "initialize", {{"protocolVersion": "2025-11-25", "capabilities": {{}}}}), + "forward_method_tools_list_allowed": post_jsonrpc("forward_method_tools_list_allowed", "tools/list"), + + # forward proxy — params allow rules + "forward_tools_call_params_name_no_args_allowed": post_jsonrpc("forward_tools_call_params_name_no_args_allowed", "tools/call", {{"name": "read_status"}}), + "forward_tools_call_params_nested_args_allowed": post_jsonrpc("forward_tools_call_params_nested_args_allowed", "tools/call", {{"name": "submit_report", "arguments": {{"scope": "workspace/main", "title": "test"}}}}), + + # forward proxy — params denied + "forward_tools_call_params_name_no_args_denied": post_jsonrpc("forward_tools_call_params_name_no_args_denied", "tools/call", {{"name": "blocked_action"}}), + "forward_tools_call_params_name_with_args_denied": post_jsonrpc("forward_tools_call_params_name_with_args_denied", "tools/call", {{"name": "blocked_action", "arguments": {{"reason": "test"}}}}), + + # forward proxy — batch: all requests allowed + "forward_batch_all_allowed": post_jsonrpc_batch("forward_batch_all_allowed", [ + {{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}}, + {{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {{"name": "read_status"}}}}, + ]), + + # forward proxy — batch: one denied request causes full batch denial + "forward_batch_one_denied": post_jsonrpc_batch("forward_batch_one_denied", [ + {{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}}, + {{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {{"name": "blocked_action"}}}}, + ]), + + # forward proxy — invalid JSON body fails closed before generic rules apply + "forward_invalid_json_denied": post_invalid_json("forward_invalid_json_denied"), + + # CONNECT path — representative allowed and denied cases + "connect_method_initialize_allowed": connect_jsonrpc_status("initialize", {{"protocolVersion": "2025-11-25", "capabilities": {{}}}}, "connect_method_initialize_allowed"), + "connect_method_tools_list_allowed": connect_jsonrpc_status("tools/list", None, "connect_method_tools_list_allowed"), + "connect_tools_call_params_name_no_args_allowed": connect_jsonrpc_status("tools/call", {{"name": "read_status"}}, "connect_tools_call_params_name_no_args_allowed"), + "connect_tools_call_params_nested_args_allowed": connect_jsonrpc_status("tools/call", {{"name": "submit_report", "arguments": {{"scope": "workspace/main"}}}}, "connect_tools_call_params_nested_args_allowed"), + "connect_tools_call_params_name_no_args_denied": connect_jsonrpc_status("tools/call", {{"name": "blocked_action"}}, "connect_tools_call_params_name_no_args_denied"), + "connect_tools_call_params_name_with_args_denied": connect_jsonrpc_status("tools/call", {{"name": "blocked_action", "arguments": {{"reason": "test"}}}}, "connect_tools_call_params_name_with_args_denied"), +}} +results.update(DETAILS) +print(json.dumps(results, sort_keys=True)) +"#, + host = server.host, + port = server.port, + ); + + let guard = SandboxGuard::create(&["--policy", &policy_path, "--", "python3", "-c", &script]) + .await + .expect("sandbox create"); + + for (key, expected) in [ + // forward proxy — allowed + ("forward_method_initialize_allowed", 200), + ("forward_method_tools_list_allowed", 200), + ("forward_tools_call_params_name_no_args_allowed", 200), + ("forward_tools_call_params_nested_args_allowed", 200), + // forward proxy — params denied + ("forward_tools_call_params_name_no_args_denied", 403), + ("forward_tools_call_params_name_with_args_denied", 403), + // forward proxy — batch + ("forward_batch_all_allowed", 200), + ("forward_batch_one_denied", 403), + // forward proxy — parse error + ("forward_invalid_json_denied", 403), + // CONNECT path — allowed + ("connect_method_initialize_allowed", 200), + ("connect_method_tools_list_allowed", 200), + ("connect_tools_call_params_name_no_args_allowed", 200), + ("connect_tools_call_params_nested_args_allowed", 200), + // CONNECT path — params denied + ("connect_tools_call_params_name_no_args_denied", 403), + ("connect_tools_call_params_name_with_args_denied", 403), + ] { + let expected_fragment = format!(r#""{key}": {expected}"#); + assert!( + guard.create_output.contains(&expected_fragment), + "expected {key}={expected}, got:\n{}", + guard.create_output + ); + } +} + +#[tokio::test] +async fn jsonrpc_forward_proxy_hard_denies_response_frames_in_default_audit_mode() { + let server = start_test_server().await.expect("start test server"); + let policy = + write_jsonrpc_default_audit_policy(&server.host, server.port).expect("write custom policy"); + let policy_path = policy + .path() + .to_str() + .expect("temp policy path should be utf-8") + .to_string(); + + let script = format!( + r#" +import json +import urllib.error +import urllib.request + +HOST = {host:?} +PORT = {port} + +def post_jsonrpc(body): + encoded = json.dumps(body).encode() + request = urllib.request.Request( + f"http://{{HOST}}:{{PORT}}/mcp", + data=encoded, + headers={{"Content-Type": "application/json"}}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + response.read() + return response.status + except urllib.error.HTTPError as error: + error.read() + return error.code + +results = {{ + "forward_unknown_method_audited": post_jsonrpc({{"jsonrpc": "2.0", "id": 1, "method": "unknown/method"}}), + "forward_response_frame_hard_denied": post_jsonrpc({{"jsonrpc": "2.0", "id": 1, "result": {{}}}}), +}} +print(json.dumps(results, sort_keys=True)) +"#, + host = server.host, + port = server.port, + ); + + let guard = SandboxGuard::create(&["--policy", &policy_path, "--", "python3", "-c", &script]) + .await + .expect("sandbox create"); + + for (key, expected) in [ + ("forward_unknown_method_audited", 200), + ("forward_response_frame_hard_denied", 403), + ] { + let expected_fragment = format!(r#""{key}": {expected}"#); + assert!( + guard.create_output.contains(&expected_fragment), + "expected {key}={expected}, got:\n{}", + guard.create_output + ); + } +} diff --git a/mise.toml b/mise.toml index 04e040421..fc5ba340a 100644 --- a/mise.toml +++ b/mise.toml @@ -65,7 +65,7 @@ DOCKER_BUILDKIT = "1" [vars] # Python paths to include in formatting/linting -python_paths = "python/ tasks/scripts/*.py deploy/sbom/*.py" +python_paths = "python/ tasks/scripts/*.py e2e/mcp-conformance/*.py deploy/sbom/*.py" [task_config] includes = ["tasks/*.toml"] diff --git a/proto/sandbox.proto b/proto/sandbox.proto index ef0b0540f..b91045e2f 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -128,6 +128,9 @@ message NetworkEndpoint { // Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless // they are converted through an explicit user-authored policy path. bool advisor_proposed = 18; + // Maximum JSON-RPC-over-HTTP request body bytes to buffer for inspection. + // Defaults to 65536 when unset. + uint32 json_rpc_max_body_bytes = 19; } // Trusted GraphQL operation classification. @@ -144,7 +147,8 @@ message GraphqlOperation { // Mirrors L7Allow — same fields, same matching semantics, inverted effect. // Deny rules are evaluated after allow rules and take precedence. message L7DenyRule { - // HTTP method (REST): GET, POST, etc. or "*" for any. + // Protocol method: HTTP method (REST/WebSocket), JSON-RPC method name, or + // "*" for any when supported by the protocol. string method = 1; // URL path glob pattern (REST): "/repos/*/pulls/*/reviews", "**" for any. string path = 2; @@ -160,6 +164,10 @@ message L7DenyRule { // GraphQL root field globs. Deny rules match when any selected root field // matches any configured glob. repeated string fields = 7; + reserved 8; + // JSON-RPC params matcher map. Dot-separated keys select nested params + // fields, e.g. "arguments.scope". + map params = 9; } // An L7 policy rule (allow-only). @@ -169,7 +177,8 @@ message L7Rule { // Allowed action definition for L7 rules. message L7Allow { - // HTTP method (REST): GET, POST, etc. or "*" for any. + // Protocol method: HTTP method (REST/WebSocket), JSON-RPC method name, or + // "*" for any when supported by the protocol. string method = 1; // URL path glob pattern (REST): "/repos/**", "**" for any. string path = 2; @@ -186,6 +195,10 @@ message L7Allow { // GraphQL root field globs. Allow rules match only when every selected root // field matches one of the configured globs. Omit to match all fields. repeated string fields = 7; + reserved 8; + // JSON-RPC params matcher map. Dot-separated keys select nested params + // fields, e.g. "arguments.scope". + map params = 9; } // Query value matcher for one query parameter key. diff --git a/tasks/test.toml b/tasks/test.toml index 3ee4b6ab5..e3d73ba3b 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -29,8 +29,8 @@ run = "tasks/scripts/test-packaging-assets.sh" hide = true [e2e] -description = "Run all end-to-end tests (Rust + Python)" -depends = ["e2e:rust", "e2e:python"] +description = "Run all end-to-end tests (Rust + Python + MCP)" +depends = ["e2e:rust", "e2e:python", "e2e:mcp"] ["e2e:gpu"] description = "Run Docker GPU end-to-end tests" @@ -71,6 +71,14 @@ run = [ "e2e/with-docker-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker --test websocket_conformance", ] +["e2e:mcp"] +description = "Run MCP conformance e2e scenarios against one Docker-backed gateway (static defaults for spec 2025-11-25; set OPENSHELL_MCP_CONFORMANCE_SCENARIOS for a focused subset)" +run = "bash e2e/mcp-conformance.sh" + +["e2e:nodejs"] +description = "Alias for e2e:mcp" +depends = ["e2e:mcp"] + ["e2e:python"] description = "Run Python e2e tests against a Docker-backed gateway (E2E_PARALLEL=N or 'auto'; default 5)" depends = ["python:proto"]