diff --git a/crates/openshell-core/src/endpoint_path.rs b/crates/openshell-core/src/endpoint_path.rs index 5f9b37d75b..f06663e39e 100644 --- a/crates/openshell-core/src/endpoint_path.rs +++ b/crates/openshell-core/src/endpoint_path.rs @@ -68,9 +68,46 @@ pub fn matches(pattern: &str, path: &str) -> bool { EndpointPathPattern::new(pattern).matches(path) } +/// Select the most specific matching endpoint, retaining the last match on ties. +/// +/// Specificity counts non-asterisk characters in the endpoint's path selector. +/// Callers filter candidates for protocol and connection eligibility first. +#[must_use] +pub fn select_most_specific<'a, T: 'a>( + endpoints: impl IntoIterator, + path: &str, + selector: impl Fn(&T) -> &str, +) -> Option<&'a T> { + endpoints + .into_iter() + .filter(|endpoint| matches(selector(endpoint), path)) + .max_by_key(|endpoint| selector(endpoint).chars().filter(|c| *c != '*').count()) +} + #[cfg(test)] mod tests { - use super::{EndpointPathPattern, matches}; + use super::{EndpointPathPattern, matches, select_most_specific}; + + #[test] + fn route_selection_preserves_specificity_and_ties() { + let endpoints = [ + ("rest", ""), + ("graphql", "/unrelated/exact"), + ("json-rpc", "/services/**"), + ("mcp", "/services/tools/**"), + ("mcp-last", "/services/tools/**"), + ]; + for (path, protocol) in [ + ("/public", "rest"), + ("/services", "json-rpc"), + ("/services/tools/read", "mcp-last"), + ("/unrelated/exact", "graphql"), + ] { + let selected = select_most_specific(&endpoints, path, |endpoint| endpoint.1).unwrap(); + assert_eq!(selected.0, protocol, "route {path}"); + } + assert!(select_most_specific(&endpoints[1..], "/public", |endpoint| endpoint.1).is_none()); + } #[test] fn matches_canonical_endpoint_patterns() { diff --git a/crates/openshell-prover/tests/runtime_parity.rs b/crates/openshell-prover/tests/runtime_parity.rs index 81b4557cb4..6279f397d5 100644 --- a/crates/openshell-prover/tests/runtime_parity.rs +++ b/crates/openshell-prover/tests/runtime_parity.rs @@ -49,6 +49,40 @@ fn runtime_engine_with_identity(policy: &str, require_binary_identity: bool) -> }), ) .expect("runtime endpoint path matcher should register"); + engine + .add_extension( + "openshell.selected_l7_protocol".into(), + 2, + Box::new(|params: Vec| { + // These fixtures use validated endpoint modes. Share route + // matching, ranking, and tie behavior with the runtime without + // depending on the supervisor's networking implementation. + let configs = params[0] + .as_array()? + .iter() + .filter_map(|config| { + let protocol = config["protocol"].as_string().ok()?.to_ascii_lowercase(); + if !matches!( + protocol.as_str(), + "rest" | "websocket" | "graphql" | "sql" | "json-rpc" | "mcp" + ) { + return None; + } + let path = config["path"] + .as_string() + .map_or("", |value| value.as_ref()); + Some((protocol, path)) + }) + .collect::>(); + let selected = openshell_core::endpoint_path::select_most_specific( + &configs, + params[1].as_string()?, + |config| config.1, + ); + Ok(selected.map_or(Value::Undefined, |config| Value::from(config.0.as_str()))) + }), + ) + .expect("runtime route protocol selector should register"); engine .add_policy("sandbox-policy.rego".into(), SANDBOX_POLICY_REGO.into()) .expect("runtime Rego should compile"); diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 3c98c7c2e9..91d8e222e7 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -231,9 +231,27 @@ _policy_allowed_l7_endpoints(policy) := [ep | some ep ep := policy.endpoints[_] endpoint_matches_l7_request(ep, input.network, input.request) + endpoint_uses_selected_request_protocol(ep) request_allowed_for_endpoint(input.request, ep) ] +# HTTP authorization uses the same parser selection as the relay. A sibling +# endpoint can contribute an allow only when it uses the selected protocol; +# all matching endpoints of that protocol retain additive allow semantics. +# The native selector shares config parsing and path ranking with Rust. +_selected_l7_request_protocol := openshell.selected_l7_protocol(_matching_endpoint_configs, object.get(input.request, "path", "")) + +endpoint_uses_selected_request_protocol(endpoint) if { + object.get(input.request, "command", "") == "" + lower(endpoint.protocol) == _selected_l7_request_protocol +} + +# SQL commands do not use HTTP request-path routing. +endpoint_uses_selected_request_protocol(_) if { + command := object.get(input.request, "command", "") + command != "" +} + # Forwarding and credential selection share endpoint admission. A deny rule # on any matching endpoint removes every otherwise admitted endpoint. _admitted_l7_endpoints := {ep | diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index b4acb5e52f..1b7d5d5e77 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -1662,14 +1662,11 @@ where } } -fn select_l7_config_for_path<'a>( +pub(crate) fn select_l7_config_for_path<'a>( configs: &'a [L7EndpointConfig], path: &str, ) -> Option<&'a L7EndpointConfig> { - configs - .iter() - .filter(|config| config.matches_path(path)) - .max_by_key(|config| config.path_specificity()) + openshell_core::endpoint_path::select_most_specific(configs, path, |config| &config.path) } fn emit_l7_request_log( @@ -10565,6 +10562,157 @@ network_policies: let _ = tokio::time::timeout(std::time::Duration::from_secs(1), relay).await; } + #[tokio::test] + async fn route_protocol_confines_rpc_calls_beside_broad_rest() { + for protocol in ["json-rpc", "mcp"] { + let rule = if protocol == "mcp" { + "{ method: tools/call, tool: read_status }" + } else { + "{ method: status.get }" + }; + let yaml = format!( + r#" +version: 1 +network_policies: + rest_api: + endpoints: + - host: "*.routes.test" + port: 8000 + protocol: rest + enforcement: enforce + access: read-write + binaries: [{{ path: /usr/bin/python3 }}] + rpc_api: + endpoints: + - host: api.routes.test + port: 8000 + path: /services/** + protocol: {protocol} + enforcement: enforce + rules: [{{ allow: {rule} }}] + binaries: [{{ path: /usr/bin/python3 }}] +"# + ); + for (path, body, expected) in [ + ( + "/services", + if protocol == "mcp" { + r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_status","arguments":{}}}"# + } else { + r#"{"jsonrpc":"2.0","id":1,"method":"status.get"}"# + }, + "200 OK", + ), + ( + "/services/catalog", + if protocol == "mcp" { + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_status","arguments":{}}}"# + } else { + r#"{"jsonrpc":"2.0","id":2,"method":"status.set"}"# + }, + "403 Forbidden", + ), + ("/public", "{}", "200 OK"), + ] { + let proto = openshell_policy::parse_sandbox_policy(&yaml).unwrap(); + let engine = OpaEngine::from_proto(&proto).unwrap(); + let input = NetworkInput { + host: "api.routes.test".into(), + port: 8000, + binary_path: PathBuf::from("/usr/bin/python3"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoints, generation) = engine + .query_endpoint_configs_with_generation(&input) + .unwrap(); + let configs = endpoints + .iter() + .filter_map(crate::l7::parse_l7_config) + .collect::>(); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: input.host, + port: 8000, + request_default_port: Some(8000), + policy_name: "rpc_api".into(), + binary_path: "/usr/bin/python3".into(), + ..Default::default() + }; + let (mut app, mut client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_route_selection( + &configs, + tunnel_engine, + &mut client, + &mut relay_upstream, + &ctx, + ) + .await + }); + // An accepting upstream makes a policy bypass produce a 200. + let server = tokio::spawn(async move { + let mut received = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let n = upstream.read(&mut buffer).await.unwrap(); + if n == 0 { + return received; + } + received.extend_from_slice(&buffer[..n]); + if let Some(head) = + received.windows(4).position(|window| window == b"\r\n\r\n") + { + let headers = std::str::from_utf8(&received[..head]).unwrap(); + let length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap(); + if received.len() >= head + 4 + length { + upstream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap(); + return received; + } + } + } + }); + app.write_all(format!( + "POST {path} HTTP/1.1\r\nHost: api.routes.test:8000\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nMCP-Protocol-Version: 2025-11-25\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() + ).as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + app.read_to_end(&mut response), + ) + .await + .expect("relay must answer") + .unwrap(); + assert!( + String::from_utf8_lossy(&response).starts_with(&format!("HTTP/1.1 {expected}")), + "{protocol} {path}: {}", + String::from_utf8_lossy(&response) + ); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(5), relay) + .await + .unwrap() + .unwrap() + .unwrap(); + let received = server.await.unwrap(); + assert_eq!( + received.is_empty(), + expected == "403 Forbidden", + "{protocol} {path}: denied calls must not reach upstream" + ); + } + } + } + /// A `tools/call` that no JSON-RPC-family fixture below allows. const UNALLOWED_TOOL_CALL: &[u8] = br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_resource","arguments":{}}}"#; diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 2d1a73a949..776a34f5e9 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -54,6 +54,32 @@ fn new_regorus_engine() -> Result { }), ) .map_err(|_| miette::miette!("failed to register endpoint path matcher"))?; + engine + .add_extension( + "openshell.selected_l7_protocol".into(), + 2, + Box::new(|params: Vec| { + let configs = params[0] + .as_array()? + .iter() + .filter_map(crate::l7::parse_l7_config) + .collect::>(); + let path = params[1].as_string()?; + let protocol = + crate::l7::relay::select_l7_config_for_path(&configs, path).map(|config| { + match config.protocol { + crate::l7::L7Protocol::Rest => "rest", + crate::l7::L7Protocol::Websocket => "websocket", + crate::l7::L7Protocol::Graphql => "graphql", + crate::l7::L7Protocol::Sql => "sql", + crate::l7::L7Protocol::JsonRpc => "json-rpc", + crate::l7::L7Protocol::Mcp => "mcp", + } + }); + Ok(protocol.map_or(regorus::Value::Undefined, regorus::Value::from)) + }), + ) + .map_err(|_| miette::miette!("failed to register L7 route protocol selector"))?; Ok(engine) } @@ -6533,6 +6559,170 @@ network_policies: ); } + #[test] + fn l7_route_protocol_confines_sibling_allows_from_proto() { + for protocol in ["json-rpc", "mcp"] { + for broad_protocol in ["rest", "json-rpc", "mcp"] { + if protocol == broad_protocol { + continue; + } + for split_policies in [false, true] { + let narrow_allow = if protocol == "mcp" { + "{ method: tools/call, tool: read_status }" + } else { + "{ method: tools/list }" + }; + let broad_rules = if broad_protocol == "rest" { + "access: read-write" + } else { + "rules: [{ allow: { method: tools/call } }]" + }; + let broad = format!( + " - host: \"*.routes.test\"\n port: 8000\n protocol: {broad_protocol}\n enforcement: enforce\n {broad_rules}\n" + ); + let narrow = format!( + " - host: api.routes.test\n port: 8000\n path: /services/*\n protocol: {protocol}\n enforcement: enforce\n rules: [{{ allow: {narrow_allow} }}]\n" + ); + let binary = " binaries: [{ path: /usr/bin/curl }]\n"; + let yaml = if split_policies { + format!( + "version: 1\nnetwork_policies:\n broad:\n endpoints:\n{broad}{binary} narrow:\n endpoints:\n{narrow}{binary}" + ) + } else { + format!( + "version: 1\nnetwork_policies:\n api:\n endpoints:\n{broad}{narrow}{binary}" + ) + }; + let mut proto = openshell_policy::parse_sandbox_policy(&yaml).unwrap(); + for policy in proto.network_policies.values_mut() { + for endpoint in &mut policy.endpoints { + endpoint.token_grant_owner = format!("owner-{}", endpoint.protocol); + } + } + let engine = OpaEngine::from_proto(&proto).unwrap_or_else(|error| { + panic!("{protocol} / {broad_protocol} split={split_policies}: {error:?}") + }); + let request = |allowed| { + if protocol == "mcp" { + l7_jsonrpc_input_with_params( + "api.routes.test", + 8000, + "/services/catalog", + "tools/call", + serde_json::json!({"name": if allowed { "read_status" } else { "write_status" }}), + ) + } else { + l7_jsonrpc_input( + "api.routes.test", + 8000, + "/services/catalog", + if allowed { "tools/list" } else { "tools/call" }, + ) + } + }; + assert!( + eval_l7(&engine, &request(true)), + "configured {protocol} call must remain allowed" + ); + assert!( + !eval_l7(&engine, &request(false)), + "{broad_protocol} sibling must not admit excluded {protocol} call; split_policies={split_policies}" + ); + assert_eq!( + eval_token_grant_owners(&engine, &request(true)), + [format!("owner-{protocol}")], + "cross-protocol siblings must not contribute credentials" + ); + assert!(eval_token_grant_owners(&engine, &request(false)).is_empty()); + if broad_protocol == "rest" { + assert!( + eval_l7( + &engine, + &l7_input("api.routes.test", 8000, "POST", "/public") + ), + "REST requests outside the RPC route must retain broad access" + ); + } + } + } + } + } + + #[test] + fn l7_route_protocol_preserves_same_protocol_union_and_global_denies() { + let yaml = r#" +version: 1 +network_policies: + api: + endpoints: + - host: api.routes.test + port: 8000 + protocol: json-rpc + enforcement: enforce + rules: + - allow: { method: status.get } + - host: api.routes.test + port: 8000 + path: /services/** + protocol: rest + enforcement: enforce + access: read-write + deny_rules: + - { method: POST, path: /services/private/** } + - host: api.routes.test + port: 8000 + path: /services/private/** + protocol: json-rpc + enforcement: enforce + rules: + - allow: { method: status.set } + binaries: [{ path: /usr/bin/curl }] +"#; + let proto = openshell_policy::parse_sandbox_policy(yaml).unwrap(); + let engine = OpaEngine::from_proto(&proto).unwrap(); + // The most-specific route returns to JSON-RPC after an intermediate + // REST route. Both JSON-RPC endpoints still contribute allow rules, + // while the REST endpoint's explicit deny remains authoritative. + let request = l7_jsonrpc_input( + "api.routes.test", + 8000, + "/services/private/data", + "status.get", + ); + assert!(!eval_l7(&engine, &request)); + let mut without_deny = proto; + without_deny + .network_policies + .get_mut("api") + .unwrap() + .endpoints[1] + .deny_rules + .clear(); + let engine = OpaEngine::from_proto(&without_deny).unwrap(); + assert!( + eval_l7(&engine, &request), + "broader same-protocol endpoint must still admit request" + ); + assert!(eval_l7( + &engine, + &l7_jsonrpc_input( + "api.routes.test", + 8000, + "/services/private/data", + "status.set" + ) + )); + assert!(!eval_l7( + &engine, + &l7_jsonrpc_input( + "api.routes.test", + 8000, + "/services/private/data", + "status.delete" + ) + )); + } + #[test] fn l7_jsonrpc_endpoint_ignores_rest_shaped_allow_rules() { let data = serde_json::json!({ diff --git a/docs/how-it-works/policies/network-rules.mdx b/docs/how-it-works/policies/network-rules.mdx index 66405e5e5b..7089c7a904 100644 --- a/docs/how-it-works/policies/network-rules.mdx +++ b/docs/how-it-works/policies/network-rules.mdx @@ -115,6 +115,12 @@ it. For a protocol in which the server sends first, such as SMTP, set Use an inspected endpoint when the request, not only the destination, determines the risk, such as allowing reads but not writes on an API. +When inspected endpoints overlap, the most specific matching endpoint `path` +selects the request protocol. Allow rules from matching endpoints using that +protocol combine. Rules using another protocol cannot authorize the request; +for example, a broader REST rule cannot allow a method excluded by a JSON-RPC +route. Matching deny rules still take precedence over allows. + ### Enforcement The `enforcement` field on an inspected endpoint decides what happens when a diff --git a/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs b/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs index b46dac1313..a5d7c4849d 100644 --- a/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs +++ b/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs @@ -91,6 +91,16 @@ network_policies: test_jsonrpc_l7: name: test_jsonrpc_l7 endpoints: + - host: {host} + port: {port} + protocol: rest + enforcement: enforce + access: read-write + allowed_ips: + - "10.0.0.0/8" + - "172.0.0.0/8" + - "192.168.0.0/16" + - "fc00::/7" - host: {host} port: {port} path: /rpc @@ -357,6 +367,12 @@ def raw_jsonrpc_status(method, params, label): return raw_http_status(label, request) results = {{ + # The broad REST endpoint still permits requests outside the RPC route. + "rest_route_allowed": raw_http_status("rest_route_allowed", ( + f"POST /api/status HTTP/1.1\r\n" + f"Host: {{HOST}}:{{PORT}}\r\n" + f"Content-Length: 0\r\nConnection: close\r\n\r\n" + ).encode()), # 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"), @@ -365,6 +381,7 @@ results = {{ "forward_method_tools_call_allowed": post_jsonrpc("forward_method_tools_call_allowed", "tools/call", {{"name": "read_status"}}), "forward_method_tools_call_with_unmatched_params_allowed": post_jsonrpc("forward_method_tools_call_with_unmatched_params_allowed", "tools/call", {{"name": "blocked_action", "arguments": {{"scope": "ignored"}}}}), "forward_method_tools_delete_denied": post_jsonrpc("forward_method_tools_delete_denied", "tools/delete", {{"name": "purge_cache"}}), + "forward_unlisted_method_denied": post_jsonrpc("forward_unlisted_method_denied", "admin.delete"), # forward proxy — batch: all requests allowed "forward_batch_all_allowed": post_jsonrpc_batch("forward_batch_all_allowed", [ @@ -387,6 +404,7 @@ results = {{ "raw_method_tools_call_allowed": raw_jsonrpc_status("tools/call", {{"name": "read_status"}}, "raw_method_tools_call_allowed"), "raw_method_tools_call_with_unmatched_params_allowed": raw_jsonrpc_status("tools/call", {{"name": "blocked_action", "arguments": {{"scope": "ignored"}}}}, "raw_method_tools_call_with_unmatched_params_allowed"), "raw_method_tools_delete_denied": raw_jsonrpc_status("tools/delete", {{"name": "purge_cache"}}, "raw_method_tools_delete_denied"), + "raw_unlisted_method_denied": raw_jsonrpc_status("admin.delete", None, "raw_unlisted_method_denied"), }} results.update(DETAILS) print(json.dumps(results, sort_keys=True)) @@ -400,6 +418,7 @@ print(json.dumps(results, sort_keys=True)) .expect("sandbox create"); for (key, expected) in [ + ("rest_route_allowed", 200), // forward proxy — allowed ("forward_method_initialize_allowed", 200), ("forward_method_tools_list_allowed", 200), @@ -410,6 +429,7 @@ print(json.dumps(results, sort_keys=True)) ), // forward proxy — method denied ("forward_method_tools_delete_denied", 403), + ("forward_unlisted_method_denied", 403), // forward proxy — batch ("forward_batch_all_allowed", 200), ("forward_batch_one_denied", 403), @@ -422,6 +442,7 @@ print(json.dumps(results, sort_keys=True)) ("raw_method_tools_call_with_unmatched_params_allowed", 200), // raw socket path — method denied ("raw_method_tools_delete_denied", 403), + ("raw_unlisted_method_denied", 403), ] { let expected_fragment = format!(r#""{key}": {expected}"#); assert!( diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 80690c45d1..13814c952b 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -474,7 +474,7 @@ The policy needs to go somewhere. Determine which mode applies: 2. **Check for conflicts**: - Does a policy with the same key already exist? If so, ask the user whether to **replace** it, **merge** new endpoints/binaries into it, or use a different key. - - Does an existing endpoint selector overlap the new selector? Compatible overlaps are allowed and can intentionally aggregate allow and deny rules. Reject or revise equally specific overlaps that disagree on connection or request-processing metadata, including TLS, destination constraints, protocol/parser behavior, enforcement, or credential handling. A more-specific path selector may override broader request-processing metadata. + - Does an existing endpoint selector overlap the new selector? Compatible overlaps are allowed and can intentionally aggregate allow and deny rules. Reject or revise equally specific overlaps that disagree on connection or request-processing metadata, including TLS, destination constraints, protocol/parser behavior, enforcement, or credential handling. A more-specific path selector may override broader request-processing metadata. Its selected protocol limits which matching endpoints contribute allow rules; same-protocol allows combine, and matching denies retain precedence. A broader REST allow does not authorize a request routed to JSON-RPC inspection. Preserve the documented restrictions on mixing MCP with other protocols. - If the sandbox uses an attached provider credential, confirm the provider profile also declares the intended host, port, and path. A sandbox policy allow cannot expand the profile's static credential binding.