diff --git a/.agents/skills/generate-sandbox-policy/SKILL.md b/.agents/skills/generate-sandbox-policy/SKILL.md index 20e562064c..f6a540d3de 100644 --- a/.agents/skills/generate-sandbox-policy/SKILL.md +++ b/.agents/skills/generate-sandbox-policy/SKILL.md @@ -42,7 +42,11 @@ For this tier, default to: - `access: read-only` when the user says "read", "browse", "view", "query", "fetch" - `access: read-write` when the user says "read-write", "create", "update" (but not "delete") - `access: full` when the user says "full access", "everything", "unrestricted" -- L4-only (no `protocol`) when the user says "just allow it", "pass through", "no inspection" +- L4-only (omit `protocol`, or use explicit `protocol: tcp`) when the user says + "just allow it", "pass through", "no inspection". Prefer omission unless the + user wants the transport intent stated explicitly. Explicit TCP requires a + valid DNS hostname; omit `protocol` for a legacy hostless `allowed_ips` + proxy endpoint. ### Moderate Tier (host + partial path knowledge) @@ -188,7 +192,7 @@ Follow this decision tree based on the detail tier and user intent: ``` Is L7 inspection needed? ├─ No (user wants pass-through / "just allow it") -│ └─ Generate L4-only policy (no protocol, no tls, no rules/access) +│ └─ Generate L4-only policy (no protocol, or protocol: tcp with a DNS hostname; no tls/rules/access) │ └─ Yes (user wants method/path control) │ @@ -376,7 +380,10 @@ Before presenting the policy to the user, verify correctness **and** flag breadt ### Hard Errors (would block sandbox startup) - [ ] `rules` and `access` are NOT both present on the same endpoint -- [ ] If `protocol` is set, either `rules` or `access` is also present +- [ ] If an L7 `protocol` is set, either `rules` or `access` is also present; + `protocol: tcp` is L4-only and must not contain either field +- [ ] Every `protocol: tcp` endpoint has a valid DNS hostname; it is not + hostless, an IP literal, a trailing-dot name, or a malformed DNS selector - [ ] If `tls: terminate` is set, `protocol` is also set - [ ] `rules` list is not empty when present - [ ] If `protocol: sql`, `enforcement` is not `enforce` @@ -408,14 +415,14 @@ Evaluate the generated policy for overly broad access and **include warnings in | Condition | Warning to show | |-----------|----------------| -| **L4-only** (no `protocol`) | "This policy allows all HTTP methods and paths without inspection. The proxy will only check host:port and binary identity. Consider adding `protocol: rest` with a preset if you want method-level control." | +| **L4-only** (no `protocol`, or `protocol: tcp`) | "This policy allows all HTTP methods and paths without inspection. The proxy will only check host:port and binary identity. Consider adding `protocol: rest` with a preset if you want method-level control." | | **`access: full`** | "This policy allows all HTTP methods (including DELETE) on all paths. If you don't need DELETE, `read-write` is safer. If you only need to read, `read-only` is the most restrictive option." | | **`access: full` + `enforcement: audit`** | "Full access in audit mode provides no actual restriction — all traffic flows through. This is effectively a monitoring-only policy." | | **`access: read-write`** when user hasn't confirmed write need | "This policy allows POST, PUT, and PATCH on all paths. If you only need to read data, `read-only` is more restrictive." | | **Wildcard binary** (`*` or `**` in binary path) | "This policy allows any binary matching the glob pattern. A compromised or unexpected binary in that directory could use this policy. Consider listing specific binary paths." | | **`**` path glob** on all explicit rules | "All rules use `**` path patterns, which match any URL path. This is equivalent to a preset — consider using `access: read-only` (or similar) for clarity, or narrowing paths if you know the API structure." | | **Multiple broad endpoints** in one policy | "This policy grants the same broad access to N different hosts. If any of these hosts needs tighter restrictions later, you'll need to split the policy." | -| **Hostless `allowed_ips`** (no `host` field) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted. Consider adding a `host` field to restrict which domains can use this allowlist." | +| **Hostless `allowed_ips`** (no `host` field and no `protocol: tcp`) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted through the legacy proxy. Consider adding a `host` field to restrict which domains can use this allowlist." | | **Broad CIDR** in `allowed_ips` (e.g., `10.0.0.0/8`) | "This `allowed_ips` entry covers a very broad range. Consider narrowing to a specific subnet (e.g., `10.0.5.0/24`) to minimize exposure." | | **`on_error: fail_open`** | "This middleware can be bypassed when it is unavailable, rejects configuration, returns an invalid result, or exceeds its body limit. Use `fail_closed` unless availability is more important than this control." | | **Broad middleware host selector** | "This middleware attaches independently of the admitting network rule to every matching destination, then runs only for operation bindings its implementation advertises. Narrow `endpoints.include` or add exclusions if the attachment is not required for every matching host." | diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 2cd5881ab9..fd23808fa4 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -385,6 +385,11 @@ Notes: - The sandbox name defaults to the last-used sandbox. - `--add-endpoint` options are comma-separated: `allowed-ip=`, `websocket-credential-rewrite`, `request-body-credential-rewrite`, and `allow-uninspected-credentials`. The last option is a security-sensitive exception for provider-credentialed L4-only, `tls: skip`, or otherwise uninspectable traffic. +- `protocol` accepts `tcp` for explicit L4-only host/port policy. It has the + same payload-handling behavior as omitting the protocol, but it requires a + valid DNS hostname and rejects hostless `allowed_ips` or literal-IP + selectors. It cannot be combined with `access`, `rules`, or L7 enforcement + options. - `--add-allow` and `--add-deny` operate on REST and WebSocket endpoints. Use full YAML for JSON-RPC, MCP, SQL, or other policy structure. - `--wait` cannot be combined with `--dry-run`. - Use `policy set` when replacing the full policy or changing static sections. diff --git a/Cargo.lock b/Cargo.lock index c30f890914..cf15ebb610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,7 +127,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -138,7 +138,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1204,6 +1204,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1728,7 +1734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2187,6 +2193,25 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2784,6 +2809,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -3446,7 +3501,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3624,6 +3679,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -3975,6 +4034,7 @@ dependencies = [ name = "openshell-policy" version = "0.0.0" dependencies = [ + "hickory-proto", "miette", "openshell-core", "prost-types", @@ -5650,7 +5710,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5720,7 +5780,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls 0.23.38", @@ -5730,7 +5790,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6210,6 +6270,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -6260,7 +6336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6757,7 +6833,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6793,7 +6869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7790,7 +7866,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 150df10d69..c484ec95b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,6 +116,7 @@ glob = "0.3" # Utilities futures = "0.3" bytes = "1" +hickory-proto = "0.26.1" pin-project-lite = "0.2" tokio-stream = "0.1" protoc-bin-vendored = "3.2.0" diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 698f88a80a..9842d74748 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -63,10 +63,10 @@ socket inode. CONNECT and absolute-form forward HTTP are explicit-proxy adapters over the same egress pipeline. Each adapter normalizes its request into an egress intent, and -the shared authorization result carries the process evidence used by destination -validation and relay selection. During the compatibility migration, endpoint -state is hydrated at the adapters' existing policy query points; it is not yet -one atomic, generation-consistent authorization result. Destination validation +the shared authorization result carries the process evidence and endpoint +metadata used by destination validation and relay selection. Network action, +matched policy, endpoint configuration, and exact-host authorization are +evaluated as one atomic snapshot from one policy generation. Destination validation returns an unopened connector so adapters retain their existing response and upstream-dial timing. CONNECT prepares a generation-pinned relay context before entering shared TLS-terminated or plaintext HTTP relays; non-HTTP traffic uses @@ -74,6 +74,12 @@ the shared raw byte relay after the existing adapter gates. Forward HTTP retains its guarded single-request relay while sharing authorization, request context, policy-pinning, and destination boundaries. Adapter-specific response and OCSF event shapes remain at the protocol boundary. +Policy authors may use `protocol: tcp` as an explicit spelling of the existing +L4 passthrough behavior. Explicit TCP endpoints require a valid DNS hostname; +hostless `allowed_ips` and literal-IP selectors remain available only to the +legacy forward-proxy path when `protocol` is omitted. The egress intent reserves +a transparent TCP adapter and a policy-DNS-pinned address, but DNS serving and +transparent TCP capture are not active yet. Provider credential placeholders are resolved through the live provider state for each HTTP request, after destination and L7 policy admission. A static diff --git a/crates/openshell-cli/src/policy_update.rs b/crates/openshell-cli/src/policy_update.rs index e21054f46d..d6ba02e40c 100644 --- a/crates/openshell-cli/src/policy_update.rs +++ b/crates/openshell-cli/src/policy_update.rs @@ -327,9 +327,14 @@ fn parse_add_endpoint_spec(spec: &str) -> Result { "--add-endpoint access segment must be one of read-only, read-write, or full; got '{access}' in '{spec}'" )); } - if !protocol.is_empty() && !matches!(protocol, "rest" | "websocket" | "sql") { + if !protocol.is_empty() && !matches!(protocol, "tcp" | "rest" | "websocket" | "sql") { return Err(miette!( - "--add-endpoint protocol segment must be 'rest', 'websocket', or 'sql'; got '{protocol}' in '{spec}'" + "--add-endpoint protocol segment must be 'tcp', 'rest', 'websocket', or 'sql'; got '{protocol}' in '{spec}'" + )); + } + if protocol == "tcp" && (!access.is_empty() || !enforcement.is_empty()) { + return Err(miette!( + "--add-endpoint protocol 'tcp' does not support access or enforcement in '{spec}'" )); } if !enforcement.is_empty() && !matches!(enforcement, "enforce" | "audit") { @@ -547,6 +552,26 @@ mod tests { assert_eq!(endpoint.enforcement, "enforce"); } + #[test] + fn parse_add_endpoint_accepts_explicit_tcp_protocol() { + let plan = build_policy_update_plan( + &["database.example.com:5432::tcp".to_string()], + &[], + &[], + &[], + &[], + &[], + None, + ) + .expect("plan should build"); + + let PolicyMergeOp::AddRule { rule, .. } = &plan.preview_operations[0] else { + panic!("expected add-rule preview"); + }; + assert_eq!(rule.endpoints[0].protocol, "tcp"); + assert!(rule.endpoints[0].access.is_empty()); + } + #[test] fn parse_add_endpoint_enables_websocket_credential_rewrite() { let plan = build_policy_update_plan( @@ -837,6 +862,26 @@ mod tests { ); } + #[test] + fn parse_add_endpoint_rejects_l7_fields_with_tcp() { + let error = build_policy_update_plan( + &["database.example.com:5432::tcp:enforce".to_string()], + &[], + &[], + &[], + &[], + &[], + None, + ) + .expect_err("TCP must reject L7 enforcement"); + + assert!( + error + .to_string() + .contains("does not support access or enforcement") + ); + } + #[test] fn parse_remove_endpoint_rejects_out_of_range_port() { let error = build_policy_update_plan( diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 23175b3bf4..6a4b7f643c 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -921,7 +921,7 @@ impl PodmanComputeDriver { &self, sandbox_id: &str, container_id: &str, - ) -> Result<(), ComputeDriverError> { + ) -> Result, ComputeDriverError> { let timeout = Duration::from_secs(u64::from(self.config.stop_timeout_secs)) + STOP_COMPLETION_TIMEOUT_HEADROOM; let deadline = tokio::time::Instant::now() + timeout; @@ -933,7 +933,7 @@ impl PodmanComputeDriver { .await .map_err(ComputeDriverError::from)?; if matches!(inspect.state.status.as_str(), "exited" | "stopped") { - return Ok(()); + return Ok(inspect.state.finished_at); } let now = tokio::time::Instant::now(); @@ -955,9 +955,12 @@ impl PodmanComputeDriver { .ok_or(ComputeDriverError::NotFound)?; let container_id = container.id; if container.state == "stopping" { - return self + let finished_at = self .wait_for_container_stopped(sandbox_id, &container_id) - .await; + .await?; + self.lifecycle_event_fences + .record_previous_exit(sandbox_id, finished_at.as_deref()); + return Ok(()); } if container.state != "running" { return Ok(()); @@ -974,8 +977,19 @@ impl PodmanComputeDriver { // event from the previous run can arrive after the gateway has moved // the same sandbox to Starting, causing it to regress to Error. Wait // for the terminal container state before allowing a restart. - self.wait_for_container_stopped(sandbox_id, &container_id) - .await + let finished_at = self + .wait_for_container_stopped(sandbox_id, &container_id) + .await?; + + // Record the completed run before returning the stop RPC. The server + // may begin a restart as soon as this method returns, while Podman's + // stop/die event can still be queued. Recording the fence here keeps + // that delayed event from regressing the new run from Starting to + // Error. Keep the start-side recording as a fallback for restarts + // after a driver or gateway process restart. + self.lifecycle_event_fences + .record_previous_exit(sandbox_id, finished_at.as_deref()); + Ok(()) } /// Start a previously stopped sandbox container. diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index b69da8d2b5..cb32584186 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -11,6 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] +hickory-proto = { workspace = true } openshell-core = { path = "../openshell-core", default-features = false } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/openshell-policy/src/ambiguity.rs b/crates/openshell-policy/src/ambiguity.rs index bf97c7e736..5c2b9d9177 100644 --- a/crates/openshell-policy/src/ambiguity.rs +++ b/crates/openshell-policy/src/ambiguity.rs @@ -163,6 +163,12 @@ fn overlapping_ports(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec Vec { let mut conflicts = Vec::new(); + push_conflict( + &mut conflicts, + "transparent_tcp_eligible", + &is_explicit_tcp(&left.protocol), + &is_explicit_tcp(&right.protocol), + ); push_conflict( &mut conflicts, "tls", @@ -184,13 +190,17 @@ fn connection_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec< conflicts } +fn is_explicit_tcp(protocol: &str) -> bool { + protocol.eq_ignore_ascii_case("tcp") +} + /// Keep request-pipeline ambiguity checks aligned with Rego's /// `endpoint_has_extended_config` predicate. Plain L4 endpoints authorize a /// destination but do not participate in endpoint-config selection, so they /// cannot compete with the single L7/connection-config endpoint selected for /// that request. fn endpoint_contributes_request_pipeline_metadata(endpoint: &NetworkEndpoint) -> bool { - !endpoint.protocol.is_empty() + (!endpoint.protocol.is_empty() && !endpoint.protocol.eq_ignore_ascii_case("tcp")) || !endpoint.allowed_ips.is_empty() || !endpoint.tls.is_empty() || endpoint.credential_binding.is_some() @@ -201,8 +211,8 @@ fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) - push_conflict( &mut conflicts, "protocol", - &left.protocol.to_ascii_lowercase(), - &right.protocol.to_ascii_lowercase(), + &normalized_request_protocol(&left.protocol), + &normalized_request_protocol(&right.protocol), ); push_conflict( &mut conflicts, @@ -300,6 +310,14 @@ fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) - conflicts } +fn normalized_request_protocol(protocol: &str) -> String { + if protocol.eq_ignore_ascii_case("tcp") { + String::new() + } else { + protocol.to_ascii_lowercase() + } +} + fn websocket_graphql_policy(endpoint: &NetworkEndpoint) -> bool { let allow_rule_has_graphql_fields = endpoint.rules.iter().any(|rule| { rule.allow.as_ref().is_some_and(|allow| { @@ -1027,4 +1045,22 @@ mod tests { 1 ); } + + #[test] + fn explicit_tcp_and_omitted_protocol_are_ambiguous_for_native_tcp_eligibility() { + let mut explicit_tcp = endpoint("api.example.com", 443); + explicit_tcp.protocol = "tcp".to_string(); + explicit_tcp.tls = "skip".to_string(); + let mut omitted = endpoint("api.example.com", 443); + omitted.tls = "skip".to_string(); + + let ambiguities = find_endpoint_ambiguities(&policy_with(explicit_tcp, omitted)); + assert_eq!(ambiguities.len(), 1); + assert!( + ambiguities[0] + .conflicts + .iter() + .any(|conflict| conflict.contains("transparent_tcp_eligible")) + ); + } } diff --git a/crates/openshell-policy/src/l7_validate.rs b/crates/openshell-policy/src/l7_validate.rs index 18580fd2c1..d60491c4bb 100644 --- a/crates/openshell-policy/src/l7_validate.rs +++ b/crates/openshell-policy/src/l7_validate.rs @@ -41,6 +41,34 @@ impl L7Protocol { } } +/// Returns whether the authored protocol explicitly selects L4 TCP handling. +/// +/// `tcp` is intentionally not an [`L7Protocol`]. It is the explicit spelling +/// of the existing L4 behavior and does not enable request inspection. +pub fn is_explicit_tcp_protocol(protocol: &str) -> bool { + protocol.eq_ignore_ascii_case("tcp") +} + +/// Reject additional L7-only fields represented outside +/// [`L7EndpointFields`] by the runtime and provider-profile schemas. +/// +/// Callers pass only authored fields with a non-default value. Keeping the +/// diagnostic construction here ensures both activation paths use the same +/// explicit-TCP contract. +pub fn validate_explicit_tcp_additional_fields( + protocol: &str, + present_fields: &[&str], +) -> Vec { + if !is_explicit_tcp_protocol(protocol) || present_fields.is_empty() { + return Vec::new(); + } + + vec![format!( + "protocol tcp does not support L7-only fields: {}; remove those fields", + present_fields.join(", ") + )] +} + /// Fields extracted from an endpoint definition needed for L7 semantic /// validation. Both profile lint and the runtime validator construct this /// from their own data representation. @@ -78,17 +106,27 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec let mut errors = Vec::new(); let protocol = ep.protocol; let l7_protocol = L7Protocol::parse(protocol); + let explicit_tcp = is_explicit_tcp_protocol(protocol); let jsonrpc_family = l7_protocol.is_some_and(L7Protocol::is_jsonrpc_family); let is_mcp = matches!(l7_protocol, Some(L7Protocol::Mcp)); let is_jsonrpc = matches!(l7_protocol, Some(L7Protocol::JsonRpc)); // 1. Unknown protocol - if !protocol.is_empty() && l7_protocol.is_none() { + if !protocol.is_empty() && l7_protocol.is_none() && !explicit_tcp { errors.push(format!( - "unknown protocol '{protocol}' (expected rest, websocket, graphql, sql, json-rpc, or mcp)" + "unknown protocol '{protocol}' (expected tcp, rest, websocket, graphql, sql, json-rpc, or mcp)" )); } + // Explicit TCP is an L4 marker, not an inspection protocol. Reject L7 + // policy fields instead of silently ignoring them. + if explicit_tcp && (!ep.access.is_empty() || ep.has_rules || ep.has_deny_rules) { + errors.push( + "protocol tcp does not support access, rules, or deny_rules; remove those L7 fields" + .to_string(), + ); + } + // 2. rules + access mutually exclusive if ep.has_rules && !ep.access.is_empty() { errors.push("rules and access are mutually exclusive".to_string()); @@ -119,7 +157,7 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec // 5. Non-MCP, non-JSON-RPC protocol requires rules or access (JSON-RPC's // dedicated message is emitted by rule 4). - if !protocol.is_empty() && !is_mcp && !is_jsonrpc && !ep.has_rules && ep.access.is_empty() { + if l7_protocol.is_some() && !is_mcp && !is_jsonrpc && !ep.has_rules && ep.access.is_empty() { errors.push("protocol requires rules or access to define allowed traffic".to_string()); } @@ -141,7 +179,7 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec } // 8. deny_rules require protocol - if ep.has_deny_rules && protocol.is_empty() { + if ep.has_deny_rules && l7_protocol.is_none() { errors.push("deny_rules require protocol (L7 inspection must be enabled)".to_string()); } @@ -379,6 +417,51 @@ mod tests { assert!(errors.is_empty(), "expected no errors, got: {errors:?}"); } + #[test] + fn explicit_tcp_is_valid_without_l7_fields() { + let ep = L7EndpointFields { + protocol: "tcp", + access: "", + has_rules: false, + has_deny_rules: false, + rules_would_deny_all: false, + allow_all_known_mcp_methods: false, + }; + let errors = validate_l7_endpoint_semantics(&ep); + assert!(errors.is_empty(), "expected no errors, got: {errors:?}"); + assert!(is_explicit_tcp_protocol("TCP")); + assert_eq!(L7Protocol::parse("tcp"), None); + } + + #[test] + fn explicit_tcp_rejects_l7_fields() { + let ep = L7EndpointFields { + protocol: "tcp", + access: "full", + has_rules: false, + has_deny_rules: false, + rules_would_deny_all: false, + allow_all_known_mcp_methods: false, + }; + let errors = validate_l7_endpoint_semantics(&ep); + assert_eq!( + errors, + vec![ + "protocol tcp does not support access, rules, or deny_rules; remove those L7 fields" + ] + ); + } + + #[test] + fn explicit_tcp_rejects_additional_l7_fields() { + let errors = + validate_explicit_tcp_additional_fields("tcp", &["enforcement", "credential_signing"]); + + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("enforcement, credential_signing")); + assert!(validate_explicit_tcp_additional_fields("rest", &["enforcement"]).is_empty()); + } + #[test] fn l7_protocol_parse_known_variants() { assert_eq!(L7Protocol::parse("rest"), Some(L7Protocol::Rest)); diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index fdfb337232..c8c5dd99c3 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -16,12 +16,14 @@ mod middleware; use std::collections::{BTreeMap, HashMap}; use std::fmt; +use std::net::IpAddr; use std::path::Path; mod ambiguity; pub use ambiguity::{EndpointAmbiguity, find_endpoint_ambiguities}; +use hickory_proto::rr::Name; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, @@ -34,7 +36,10 @@ pub use compose::{ PROVIDER_RULE_NAME_PREFIX, ProviderPolicyLayer, compose_effective_policy, is_provider_rule_name, provider_rule_name, strip_provider_rule_names, }; -pub use l7_validate::{L7EndpointFields, L7Protocol, validate_l7_endpoint_semantics}; +pub use l7_validate::{ + L7EndpointFields, L7Protocol, validate_explicit_tcp_additional_fields, + validate_l7_endpoint_semantics, +}; pub use merge::{ PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, generated_rule_name, merge_policy, policy_covers_rule, @@ -1169,6 +1174,26 @@ pub enum PolicyViolation { TooManyPaths { count: usize }, /// A network endpoint uses a TLD wildcard (e.g. `*.com`). TldWildcard { policy_name: String, host: String }, + /// A network endpoint has no hostname. + MissingEndpointHost { policy_name: String }, + /// An explicit TCP endpoint has no DNS hostname. + MissingTcpEndpointHost { policy_name: String }, + /// An explicit TCP endpoint uses an IP literal instead of a DNS hostname. + TcpEndpointIpLiteral { policy_name: String, host: String }, + /// An explicit TCP endpoint has a hostname that policy DNS cannot resolve. + InvalidTcpEndpointHost { + policy_name: String, + host: String, + reason: String, + }, + /// A network endpoint has no effective destination port. + MissingEndpointPort { policy_name: String, host: String }, + /// A network endpoint contains a port outside the TCP/UDP range. + InvalidEndpointPort { + policy_name: String, + host: String, + port: u32, + }, /// A network endpoint uses a wildcard shape that does not match runtime semantics. InvalidHostWildcard { policy_name: String, host: String }, /// `credential_signing` is set but `signing_service` is missing. @@ -1238,6 +1263,50 @@ impl fmt::Display for PolicyViolation { use subdomain wildcards like '*.example.com' instead" ) } + Self::MissingEndpointHost { policy_name } => { + write!( + f, + "network policy '{policy_name}': endpoint host must not be empty unless allowed_ips constrains a non-TCP proxy endpoint" + ) + } + Self::MissingTcpEndpointHost { policy_name } => { + write!( + f, + "network policy '{policy_name}': protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy" + ) + } + Self::TcpEndpointIpLiteral { policy_name, host } => { + write!( + f, + "network policy '{policy_name}': protocol tcp endpoint '{host}' must use a DNS hostname, not an IP literal; direct IP connections bypass policy DNS and are blocked" + ) + } + Self::InvalidTcpEndpointHost { + policy_name, + host, + reason, + } => { + write!( + f, + "network policy '{policy_name}': protocol tcp endpoint has invalid DNS host selector '{host}': {reason}" + ) + } + Self::MissingEndpointPort { policy_name, host } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' must declare at least one port" + ) + } + Self::InvalidEndpointPort { + policy_name, + host, + port, + } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' has invalid port {port}; expected 1..=65535" + ) + } Self::InvalidHostWildcard { policy_name, host } => { write!( f, @@ -1407,6 +1476,49 @@ pub fn validate_sandbox_policy( rule.name.clone() }; for ep in &rule.endpoints { + let explicit_tcp = l7_validate::is_explicit_tcp_protocol(&ep.protocol); + if ep.host.trim().is_empty() && explicit_tcp { + violations.push(PolicyViolation::MissingTcpEndpointHost { + policy_name: name.clone(), + }); + } else if ep.host.trim().is_empty() && ep.allowed_ips.is_empty() { + violations.push(PolicyViolation::MissingEndpointHost { + policy_name: name.clone(), + }); + } else if explicit_tcp { + if ep.host.parse::().is_ok() { + violations.push(PolicyViolation::TcpEndpointIpLiteral { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } else if let Err(reason) = validate_tcp_dns_host_selector(&ep.host) { + violations.push(PolicyViolation::InvalidTcpEndpointHost { + policy_name: name.clone(), + host: ep.host.clone(), + reason, + }); + } + } + let effective_ports: Vec = if ep.ports.is_empty() { + (ep.port != 0).then_some(ep.port).into_iter().collect() + } else { + ep.ports.clone() + }; + if effective_ports.is_empty() { + violations.push(PolicyViolation::MissingEndpointPort { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } + for port in effective_ports { + if !(1..=u16::MAX.into()).contains(&port) { + violations.push(PolicyViolation::InvalidEndpointPort { + policy_name: name.clone(), + host: ep.host.clone(), + port, + }); + } + } if ep.host.contains('*') && (ep.host.starts_with("*.") || ep.host.starts_with("**.")) { let label_count = ep.host.split('.').count(); if label_count <= 2 { @@ -1477,6 +1589,39 @@ fn host_wildcard_shape_invalid(host: &str) -> bool { .any(|label| label.contains("**") || (label.contains('*') && label != "*")) } +/// Validate that an explicit-TCP host selector can produce names accepted by +/// policy DNS. Wildcards are replaced with a representative DNS label before +/// parsing because the authored selector itself is not a concrete DNS name. +fn validate_tcp_dns_host_selector(host: &str) -> std::result::Result<(), String> { + if host.trim() != host { + return Err("leading or trailing whitespace is not allowed".to_string()); + } + if host.ends_with('.') { + return Err("omit the trailing DNS root dot".to_string()); + } + + openshell_core::host_pattern::HostSelector::new(&[host.to_string()], &[])?; + + let representative = host + .split('.') + .map(|label| { + if label == "**" { + "x".to_string() + } else { + label.replace('*', "x") + } + }) + .collect::>() + .join("."); + let absolute = format!("{representative}."); + let parsed = Name::from_ascii(&absolute) + .map_err(|error| format!("selector cannot represent a valid DNS name: {error}"))?; + if parsed.is_root() { + return Err("DNS root is not a destination hostname".to_string()); + } + Ok(()) +} + /// Truncate a string for safe inclusion in error messages. fn truncate_for_display(s: &str) -> String { if s.len() <= 80 { @@ -2397,6 +2542,209 @@ network_policies: assert!(validate_sandbox_policy(&policy).is_ok()); } + #[test] + fn validate_rejects_yaml_tcp_endpoint_without_host_or_port() { + let policy = parse_sandbox_policy( + r#" +version: 1 +network_policies: + invalid: + endpoints: + - host: "" + protocol: tcp +"#, + ) + .expect("policy syntax should parse before semantic validation"); + + let violations = validate_sandbox_policy(&policy).expect_err("endpoint is incomplete"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MissingTcpEndpointHost { policy_name } if policy_name == "invalid" + ))); + assert!(violations.iter().any(|violation| { + violation.to_string().contains( + "protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy", + ) + })); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MissingEndpointPort { policy_name, .. } if policy_name == "invalid" + ))); + } + + #[test] + fn validate_accepts_hostless_allowed_ips_for_non_tcp_proxy_endpoint() { + let policy = parse_sandbox_policy( + r" +version: 1 +network_policies: + legacy-proxy: + endpoints: + - port: 9443 + allowed_ips: + - 10.0.5.0/24 +", + ) + .expect("policy syntax should parse before semantic validation"); + + validate_sandbox_policy(&policy) + .expect("hostless allowed_ips remains valid for non-TCP proxy endpoints"); + } + + #[test] + fn validate_rejects_hostless_allowed_ips_for_explicit_tcp() { + let policy = parse_sandbox_policy( + r" +version: 1 +network_policies: + native-tcp: + endpoints: + - port: 6379 + protocol: tcp + allowed_ips: + - 10.0.5.0/24 +", + ) + .expect("policy syntax should parse before semantic validation"); + + let violations = + validate_sandbox_policy(&policy).expect_err("transparent TCP requires a DNS hostname"); + let violation = violations + .iter() + .find(|violation| matches!(violation, PolicyViolation::MissingTcpEndpointHost { .. })) + .expect("missing TCP hostname violation"); + assert_eq!( + violation.to_string(), + "network policy 'native-tcp': protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy" + ); + } + + #[test] + fn validate_rejects_ip_literal_hosts_for_explicit_tcp() { + for host in ["192.0.2.10", "2001:db8::10"] { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "native-tcp".into(), + NetworkPolicyRule { + name: "native-tcp".into(), + endpoints: vec![NetworkEndpoint { + host: host.into(), + port: 6379, + protocol: "tcp".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy) + .expect_err("transparent TCP must reject direct IP destinations"); + let violation = violations + .iter() + .find(|violation| matches!(violation, PolicyViolation::TcpEndpointIpLiteral { .. })) + .expect("TCP IP-literal violation"); + assert!( + violation + .to_string() + .contains("direct IP connections bypass policy DNS and are blocked"), + "unexpected diagnostic: {violation}" + ); + } + } + + #[test] + fn validate_rejects_malformed_dns_selectors_for_explicit_tcp() { + for (host, expected_reason) in [ + (" db.example.com", "leading or trailing whitespace"), + ("db.example.com.", "omit the trailing DNS root dot"), + ("db..example.com", "empty DNS labels"), + ("bad name.example.com", "whitespace"), + ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + "cannot represent a valid DNS name", + ), + ] { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "native-tcp".into(), + NetworkPolicyRule { + name: "native-tcp".into(), + endpoints: vec![NetworkEndpoint { + host: host.into(), + port: 6379, + protocol: "tcp".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy) + .expect_err("malformed transparent TCP hostname must be rejected"); + let violation = violations + .iter() + .find(|violation| { + matches!(violation, PolicyViolation::InvalidTcpEndpointHost { .. }) + }) + .expect("invalid TCP hostname violation"); + assert!( + violation.to_string().contains(expected_reason), + "expected {expected_reason:?} in diagnostic for {host:?}, got {violation}" + ); + } + } + + #[test] + fn validate_rejects_raw_endpoint_zero_and_out_of_range_ports() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "invalid".into(), + NetworkPolicyRule { + name: "invalid".into(), + endpoints: vec![NetworkEndpoint { + host: "database.example.com".into(), + ports: vec![0, u32::from(u16::MAX) + 1], + protocol: "tcp".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy).expect_err("ports are invalid"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::InvalidEndpointPort { port: 0, .. } + ))); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::InvalidEndpointPort { port: 65_536, .. } + ))); + } + + #[test] + fn validate_rejects_raw_endpoint_without_effective_port() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "invalid".into(), + NetworkPolicyRule { + name: "invalid".into(), + endpoints: vec![NetworkEndpoint { + host: "database.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy).expect_err("port is missing"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MissingEndpointPort { policy_name, host } + if policy_name == "invalid" && host == "database.example.com" + ))); + } + #[test] fn validate_accepts_empty_process() { let policy = SandboxPolicy { diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index c96e39674a..243d34bc1c 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -13,7 +13,9 @@ use openshell_core::proto::{ ProviderProfileCredential, ProviderProfileDiscovery, }; use openshell_core::secrets::uses_reserved_revision_namespace; -use openshell_policy::{L7EndpointFields, validate_l7_endpoint_semantics}; +use openshell_policy::{ + L7EndpointFields, validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, +}; use serde::ser::SerializeStruct; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::collections::{HashMap, HashSet}; @@ -1923,6 +1925,17 @@ pub fn validate_profile_set( msg, )); } + for msg in validate_explicit_tcp_additional_fields( + &endpoint.protocol, + &additional_l7_profile_fields(endpoint), + ) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + format!("endpoints[{index}]"), + msg, + )); + } if endpoint.protocol == "mcp" { let strict_tool_names = endpoint @@ -2263,6 +2276,49 @@ fn endpoint_is_valid(endpoint: &EndpointProfile) -> bool { (1..=65_535).contains(&endpoint.port) } +fn additional_l7_profile_fields(endpoint: &EndpointProfile) -> Vec<&'static str> { + let mut fields = Vec::new(); + for (name, present) in [ + ("enforcement", !endpoint.enforcement.is_empty()), + ("path", !endpoint.path.is_empty()), + ("allow_encoded_slash", endpoint.allow_encoded_slash), + ( + "websocket_credential_rewrite", + endpoint.websocket_credential_rewrite, + ), + ( + "request_body_credential_rewrite", + endpoint.request_body_credential_rewrite, + ), + ("persisted_queries", !endpoint.persisted_queries.is_empty()), + ( + "graphql_persisted_queries", + !endpoint.graphql_persisted_queries.is_empty(), + ), + ( + "graphql_max_body_bytes", + endpoint.graphql_max_body_bytes > 0, + ), + ( + "json_rpc_max_body_bytes", + endpoint.json_rpc_max_body_bytes > 0, + ), + ("mcp", endpoint.mcp.is_some()), + ( + "credential_signing", + !endpoint.credential_signing.is_empty(), + ), + ("signing_service", !endpoint.signing_service.is_empty()), + ("signing_region", !endpoint.signing_region.is_empty()), + ] { + if present { + fields.push(name); + } + } + + fields +} + #[derive(Debug, Clone)] struct TokenGrantOverrideBinding { override_index: usize, @@ -4351,6 +4407,112 @@ binaries: assert!(errors.is_empty(), "unexpected errors: {errors:?}"); } + #[test] + fn validate_accepts_explicit_tcp_without_l7_fields() { + let profile = parse_profile_yaml( + r" +id: valid-tcp +display_name: Valid TCP +credentials: + - name: api_key + env_vars: [API_KEY] + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + tls: skip + allow_uninspected_credentials: true + allowed_ips: [10.0.0.0/8] +binaries: + - /usr/bin/psql +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("profile.yaml".to_string(), profile)]); + let errors: Vec<_> = diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == "error") + .collect(); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + } + + #[test] + fn validate_rejects_additional_l7_field_families_with_explicit_tcp() { + let profile = parse_profile_yaml( + r" +id: invalid-tcp-l7 +display_name: Invalid TCP L7 +credentials: + - name: api_key + env_vars: [API_KEY] + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + enforcement: enforce + path: /query + allow_encoded_slash: true + websocket_credential_rewrite: true + request_body_credential_rewrite: true + persisted_queries: allow_registered + graphql_persisted_queries: + hash: + operation_type: query + graphql_max_body_bytes: 1024 + json_rpc_max_body_bytes: 1024 + mcp: + strict_tool_names: false + credential_signing: sigv4 + signing_service: rds + signing_region: us-west-2 +binaries: + - /usr/bin/psql +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("profile.yaml".to_string(), profile)]); + let tcp_error = diagnostics + .iter() + .find(|diagnostic| { + diagnostic + .message + .contains("protocol tcp does not support L7-only fields") + }) + .expect("explicit TCP should reject additional L7 fields"); + + for field in [ + "enforcement", + "path", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "json_rpc_max_body_bytes", + "mcp", + "credential_signing", + "signing_service", + "signing_region", + ] { + assert!( + tcp_error.message.contains(field), + "missing {field}: {}", + tcp_error.message + ); + } + } + #[test] fn validate_rejects_unknown_protocol() { let profile = parse_profile_yaml( diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index c1dbada149..56a380b19c 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -5495,12 +5495,22 @@ filesystem_policy: "fail_closed" ); assert_eq!(config["unmapped"]["previous_policy_active"], false); + assert_eq!( + config["unmapped"]["validation_error"], + "conflicting tls metadata" + ); assert!( config["message"] .as_str() .unwrap() .contains("previous policy IS NOT active") ); + assert!( + config["message"] + .as_str() + .unwrap() + .contains("error:conflicting tls metadata") + ); let finding = finding.to_json().unwrap(); assert_eq!(finding["class_uid"], 2004); diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 4b2977b936..2c8204974a 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -214,6 +214,39 @@ network_action := "allow" if { network_policy_for_request } +# --- Authoritative egress authorization snapshot --- +# +# Rust evaluates this rule once per admitted connection. Keeping the action, +# matched policy, endpoint metadata, and exact-host signal in one result makes +# them an atomic view of one policy generation. + +default _egress_matched_policy := "" + +_egress_matched_policy := matched_network_policy if { + matched_network_policy +} + +default _egress_deny_reason := "" + +_egress_deny_reason := deny_reason if { + network_action == "deny" +} + +default _egress_exact_declared_endpoint_host := false + +_egress_exact_declared_endpoint_host := true if { + exact_declared_endpoint_host +} + +egress_authorization := { + "action": network_action, + "deny_reason": _egress_deny_reason, + "matched_policy": _egress_matched_policy, + "endpoint_configs": _matching_endpoint_configs, + "matched_endpoints": _matching_endpoint_records, + "exact_declared_endpoint_host": _egress_exact_declared_endpoint_host, +} + # =========================================================================== # L7 request evaluation (queried per-request within a tunnel) # =========================================================================== @@ -864,6 +897,30 @@ _matching_endpoint_configs := [cfg | endpoint_has_extended_config(cfg) ] +# Full matched endpoint records are kept separate from the legacy +# endpoint-config list, which intentionally contains only connection/L7 +# metadata. The policy name and array index identify the endpoint within this +# policy generation while the complete endpoint preserves explicit protocol +# markers needed by later policy-DNS correlation. + +_policy_endpoint_records(policy_name, policy) := [record | + some endpoint_index + ep := policy.endpoints[endpoint_index] + endpoint_matches_request(ep, input.network) + record := { + "policy_name": policy_name, + "endpoint_index": endpoint_index, + "endpoint": ep, + } +] + +_matching_endpoint_records := [record | + some pname + _matching_policy_names[pname] + records := _policy_endpoint_records(pname, data.network_policies[pname]) + record := records[_] +] + matched_endpoint_config := _matching_endpoint_configs[0] if { count(_matching_endpoint_configs) > 0 } @@ -943,10 +1000,13 @@ endpoint_path_matches_request(ep, request) if { path_matches(request.path, path) } -# An endpoint has extended config if it specifies L7 protocol, allowed_ips, -# or an explicit tls mode (e.g. tls: skip). +# An endpoint has extended config if it specifies an L7 protocol, allowed_ips, +# or an explicit tls mode (e.g. tls: skip). Explicit protocol "tcp" is the +# authored spelling of plain L4 behavior and does not select an L7 config. endpoint_has_extended_config(ep) if { - ep.protocol + protocol := object.get(ep, "protocol", "") + protocol != "" + lower(protocol) != "tcp" } endpoint_has_extended_config(ep) if { diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 9279d3f089..70a980ba2d 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -22,7 +22,9 @@ pub(crate) mod token_grant_injection; pub(crate) mod websocket; pub use openshell_policy::L7Protocol; -use openshell_policy::{L7EndpointFields, validate_l7_endpoint_semantics}; +use openshell_policy::{ + L7EndpointFields, validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, +}; pub(crate) fn build_credential_endpoint_mismatch_finding( policy_name: &str, @@ -1000,6 +1002,69 @@ fn json_endpoint_has_graphql_policy(ep: &serde_json::Value) -> bool { /// /// Returns a list of errors and warnings. Errors should prevent sandbox startup; /// warnings are logged but don't block. +fn additional_l7_fields(ep: &serde_json::Value) -> Vec<&'static str> { + let mut fields = Vec::new(); + let non_empty_string = |name| { + ep.get(name) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.is_empty()) + }; + let enabled = |name| { + ep.get(name) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }; + + for (name, present) in [ + ("enforcement", non_empty_string("enforcement")), + ("path", non_empty_string("path")), + ("allow_encoded_slash", enabled("allow_encoded_slash")), + ( + "websocket_credential_rewrite", + enabled("websocket_credential_rewrite"), + ), + ( + "request_body_credential_rewrite", + enabled("request_body_credential_rewrite"), + ), + ("persisted_queries", non_empty_string("persisted_queries")), + ( + "graphql_persisted_queries", + ep.get("graphql_persisted_queries").is_some(), + ), + ( + "graphql_max_body_bytes", + ep.get("graphql_max_body_bytes").is_some(), + ), + ( + "json_rpc_max_body_bytes", + ep.get("json_rpc_max_body_bytes").is_some(), + ), + ( + "mcp.strict_tool_names", + ep.get("mcp_strict_tool_names").is_some(), + ), + ( + "mcp.allow_all_known_mcp_methods", + ep.get("mcp_allow_all_known_mcp_methods").is_some(), + ), + ("credential_signing", non_empty_string("credential_signing")), + ("signing_service", non_empty_string("signing_service")), + ("signing_region", non_empty_string("signing_region")), + ( + "credential_binding", + ep.get("credential_binding") + .is_some_and(|value| !value.is_null()), + ), + ] { + if present { + fields.push(name); + } + } + + fields +} + pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec) { let mut errors = Vec::new(); let mut warnings = Vec::new(); @@ -1132,6 +1197,10 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< for msg in validate_l7_endpoint_semantics(&l7_fields) { errors.push(format!("{loc}: {msg}")); } + for msg in validate_explicit_tcp_additional_fields(protocol, &additional_l7_fields(ep)) + { + errors.push(format!("{loc}: {msg}")); + } if let Some(mode) = ep.get("persisted_queries").and_then(|v| v.as_str()) && !mode.is_empty() @@ -1915,6 +1984,63 @@ mod tests { ); } + #[test] + fn validate_explicit_tcp_rejects_additional_l7_field_families() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "database.example.com", + "port": 5432, + "protocol": "tcp", + "enforcement": "enforce", + "path": "/query", + "allow_encoded_slash": true, + "websocket_credential_rewrite": true, + "request_body_credential_rewrite": true, + "persisted_queries": "allow_registered", + "graphql_persisted_queries": {}, + "graphql_max_body_bytes": 1024, + "json_rpc_max_body_bytes": 1024, + "mcp_strict_tool_names": false, + "mcp_allow_all_known_mcp_methods": false, + "credential_signing": "sigv4", + "signing_service": "rds", + "signing_region": "us-west-2", + "credential_binding": {"provider": "database"} + }], + "binaries": [] + } + } + }); + + let (errors, _) = validate_l7_policies(&data); + let tcp_error = errors + .iter() + .find(|error| error.contains("protocol tcp does not support L7-only fields")) + .expect("explicit TCP should reject additional L7 fields"); + + for field in [ + "enforcement", + "path", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "json_rpc_max_body_bytes", + "mcp.strict_tool_names", + "mcp.allow_all_known_mcp_methods", + "credential_signing", + "signing_service", + "signing_region", + "credential_binding", + ] { + assert!(tcp_error.contains(field), "missing {field}: {tcp_error}"); + } + } + #[test] fn validate_request_body_credential_rewrite_warns_unless_rest() { let data = serde_json::json!({ diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 33c14e9bd8..3cdd53a0e7 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -50,6 +50,24 @@ pub enum NetworkAction { Deny { reason: String }, } +/// Endpoint identity and metadata captured with one policy generation. +#[derive(Debug, Clone)] +pub struct MatchedEndpoint { + pub policy_name: String, + pub endpoint_index: usize, + pub endpoint: regorus::Value, +} + +/// Atomic policy result used to authorize and materialize one egress request. +#[derive(Debug, Clone)] +pub struct EgressAuthorization { + pub action: NetworkAction, + pub endpoint_configs: Vec, + pub matched_endpoints: Vec, + pub exact_declared_endpoint_host: bool, + pub generation: u64, +} + /// Input for a network access policy evaluation. pub struct NetworkInput { pub host: String, @@ -269,15 +287,6 @@ impl OpaEngine { generation } - #[cfg(test)] - pub(crate) fn poison_lock_for_test(&self) { - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _guard = self.engine.lock().expect("test engine lock"); - panic!("poison OPA engine lock for compatibility fallback test"); - })); - assert!(self.engine.is_poisoned()); - } - /// Load policy from a `.rego` rules file and data from a YAML file. /// /// Preprocesses the YAML data to expand access presets and validate L7 config. @@ -517,6 +526,13 @@ impl OpaEngine { &self, input: &NetworkInput, ) -> Result<(NetworkAction, u64)> { + let authorization = self.authorize_egress(input)?; + Ok((authorization.action, authorization.generation)) + } + + /// Authorize egress and return all connection metadata from one Rego result + /// evaluated against one policy generation. + pub fn authorize_egress(&self, input: &NetworkInput) -> Result { #[cfg(test)] record_test_opa_query(); @@ -534,34 +550,52 @@ impl OpaEngine { .map_err(|_| miette::miette!("OPA fail-closed state lock poisoned"))? .clone(); if let Some(reason) = fail_closed_reason { - return Ok((NetworkAction::Deny { reason }, generation)); + return Ok(EgressAuthorization { + action: NetworkAction::Deny { reason }, + endpoint_configs: Vec::new(), + matched_endpoints: Vec::new(), + exact_declared_endpoint_host: false, + generation, + }); } set_regorus_input(&mut engine, input_json)?; - let action_val = engine - .eval_rule("data.openshell.sandbox.network_action".into()) + let result = engine + .eval_rule("data.openshell.sandbox.egress_authorization".into()) .map_err(|e| miette::miette!("{e}"))?; - let action_str = value_to_string(&action_val); + let action_str = get_str(&result, "action").unwrap_or_default(); + let matched_policy = get_str(&result, "matched_policy").filter(|name| !name.is_empty()); + let endpoint_configs = match get_field(&result, "endpoint_configs") { + Some(regorus::Value::Array(values)) => values.to_vec(), + _ => Vec::new(), + }; + let matched_endpoints = match get_field(&result, "matched_endpoints") { + Some(regorus::Value::Array(values)) => { + values.iter().filter_map(parse_matched_endpoint).collect() + } + _ => Vec::new(), + }; + let exact_declared_endpoint_host = + get_bool(&result, "exact_declared_endpoint_host").unwrap_or(false); - let matched = engine - .eval_rule("data.openshell.sandbox.matched_network_policy".into()) - .map_err(|e| miette::miette!("{e}"))?; - let matched_policy = if matched == regorus::Value::Undefined { - None + let action = if action_str == "allow" { + NetworkAction::Allow { matched_policy } } else { - Some(value_to_string(&matched)) + NetworkAction::Deny { + reason: get_str(&result, "deny_reason") + .filter(|reason| !reason.is_empty()) + .unwrap_or_else(|| "network connections not allowed by policy".to_string()), + } }; - if action_str == "allow" { - Ok((NetworkAction::Allow { matched_policy }, generation)) - } else { - let reason_val = engine - .eval_rule("data.openshell.sandbox.deny_reason".into()) - .map_err(|e| miette::miette!("{e}"))?; - let reason = value_to_string(&reason_val); - Ok((NetworkAction::Deny { reason }, generation)) - } + Ok(EgressAuthorization { + action, + endpoint_configs, + matched_endpoints, + exact_declared_endpoint_host, + generation, + }) } /// Reload policy and data from strings (data is YAML). @@ -1155,6 +1189,21 @@ fn get_field<'a>(val: &'a regorus::Value, key: &str) -> Option<&'a regorus::Valu } } +fn parse_matched_endpoint(value: ®orus::Value) -> Option { + let policy_name = get_str(value, "policy_name")?; + let endpoint_index = match get_field(value, "endpoint_index")? { + regorus::Value::Number(number) => usize::try_from(number.as_i64()?).ok()?, + _ => return None, + }; + let endpoint = get_field(value, "endpoint")?.clone(); + + Some(MatchedEndpoint { + policy_name, + endpoint_index, + endpoint, + }) +} + fn regorus_value_to_struct(value: ®orus::Value) -> prost_types::Struct { let regorus::Value::Object(map) = value else { return prost_types::Struct::default(); @@ -1827,6 +1876,11 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if !e.signing_region.is_empty() { ep["signing_region"] = e.signing_region.clone().into(); } + if let Some(binding) = &e.credential_binding { + ep["credential_binding"] = serde_json::json!({ + "provider": binding.provider.clone(), + }); + } if !e.persisted_queries.is_empty() { ep["persisted_queries"] = e.persisted_queries.clone().into(); } @@ -2692,6 +2746,7 @@ network_policies: name: l4_only endpoints: - { host: l4only.example.com, port: 443 } + - { host: explicit-tcp.example.com, port: 443, protocol: tcp } binaries: - { path: /usr/bin/curl } filesystem_policy: @@ -4475,6 +4530,26 @@ network_policies: assert_eq!(l7.enforcement, crate::l7::EnforcementMode::Enforce); } + #[test] + fn explicit_tcp_authorizes_as_l4_without_endpoint_config() { + let engine = l7_engine(); + let input = NetworkInput { + host: "explicit-tcp.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + assert!(matches!( + engine.evaluate_network_action(&input).unwrap(), + NetworkAction::Allow { .. } + )); + assert!(engine.query_endpoint_config(&input).unwrap().is_none()); + assert!(engine.query_exact_declared_endpoint_host(&input).unwrap()); + } + #[test] fn l7_endpoint_config_preserves_mcp_strict_tool_names_opt_out() { let data = r#" @@ -5360,6 +5435,18 @@ process: } ); + let authorization = engine.authorize_egress(&input).unwrap(); + let mut endpoint_identities = authorization + .matched_endpoints + .iter() + .map(|matched| (matched.policy_name.as_str(), matched.endpoint_index)) + .collect::>(); + endpoint_identities.sort_unstable(); + assert_eq!( + endpoint_identities, + [("allow_192_168_1_100_8567", 0), ("test_server", 0),] + ); + let (configs, generation) = engine .query_endpoint_configs_with_generation(&input) .unwrap(); @@ -5802,6 +5889,121 @@ process: assert_eq!(decision.matched_policy.as_deref(), Some("internal_api")); } + #[test] + fn egress_authorization_returns_one_generation_consistent_snapshot() { + let engine = allowed_ips_engine(); + let input = NetworkInput { + host: "my-service.corp.net".into(), + port: 8080, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let authorization = engine.authorize_egress(&input).unwrap(); + + assert_eq!(authorization.generation, engine.current_generation()); + assert_eq!( + authorization.action, + NetworkAction::Allow { + matched_policy: Some("internal_api".to_string()) + } + ); + assert!(authorization.exact_declared_endpoint_host); + assert_eq!(authorization.endpoint_configs.len(), 1); + assert_eq!( + get_str_array(&authorization.endpoint_configs[0], "allowed_ips"), + vec!["10.0.5.0/24"] + ); + assert_eq!(authorization.matched_endpoints.len(), 1); + assert_eq!( + authorization.matched_endpoints[0].policy_name, + "internal_api" + ); + assert_eq!(authorization.matched_endpoints[0].endpoint_index, 0); + assert_eq!( + get_str(&authorization.matched_endpoints[0].endpoint, "host").as_deref(), + Some("my-service.corp.net") + ); + } + + #[test] + fn egress_authorization_preserves_explicit_tcp_endpoint_identity() { + let engine = OpaEngine::from_strings( + TEST_POLICY, + r#" +network_policies: + native_tcp: + name: native_tcp + endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + binaries: + - path: /usr/bin/client +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#, + ) + .expect("explicit TCP policy should load"); + let input = NetworkInput { + host: "database.example.com".into(), + port: 5432, + binary_path: PathBuf::from("/usr/bin/client"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let authorization = engine.authorize_egress(&input).unwrap(); + + assert!(authorization.endpoint_configs.is_empty()); + assert_eq!(authorization.matched_endpoints.len(), 1); + let matched = &authorization.matched_endpoints[0]; + assert_eq!(matched.policy_name, "native_tcp"); + assert_eq!(matched.endpoint_index, 0); + assert_eq!( + get_str(&matched.endpoint, "protocol").as_deref(), + Some("tcp") + ); + } + + #[test] + fn proto_activation_rejects_explicit_tcp_credential_binding() { + let proto = openshell_policy::parse_sandbox_policy( + r#" +version: 1 +network_policies: + native_tcp: + name: native_tcp + endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + credential_binding: + provider: database + binaries: + - path: /usr/bin/client +"#, + ) + .expect("policy should parse before semantic validation"); + + let error = OpaEngine::from_proto(&proto) + .err() + .expect("explicit TCP must reject credential binding during activation"); + + assert!(error.to_string().contains("credential_binding")); + assert!(error.to_string().contains("protocol tcp")); + } + #[test] fn allowed_ips_mode2_returns_allowed_ips() { let engine = allowed_ips_engine(); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index daaccd0d6e..1bd6a6e001 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1385,7 +1385,7 @@ async fn handle_tcp_connection( } let connect_generation_guard = - match relay::pin_policy_generation(&opa_engine, decision.l4_policy_generation) { + match relay::pin_policy_generation(&opa_engine, decision.policy_generation) { Ok(guard) => guard, Err(error) => { reject_stale_connect_policy( @@ -1406,13 +1406,13 @@ async fn handle_tcp_connection( // allowed_ips validation below — so an internal-address CONNECT still gets // the SSRF 403 and telemetry in degraded state — but before the upstream // connect and before `200 Connection Established`. - hydrate_tls_mode(&opa_engine, &mut decision); + hydrate_tls_mode(&mut decision); let effective_tls_skip = decision.endpoint.tls_mode == crate::l7::TlsMode::Skip; let credential_guard = query_endpoint_credential_guard(&opa_engine, &decision, &host_lc, port)?; let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); - match hydrate_destination_plan(&opa_engine, &mut decision, *trusted_host_gateway) { + match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { deny_connect_destination( @@ -1558,9 +1558,8 @@ async fn handle_tcp_connection( } // CONNECT must use one policy generation from authorization through route - // hydration and relay startup. A later L7 lookup must never make a stale - // L4 allow appear current. - hydrate_l7_route(&opa_engine, &mut decision); + // materialization and relay startup. + hydrate_l7_route(&mut decision); let l7_route = decision.endpoint.l7_route.as_ref(); if let Err(error) = relay::validate_route_generation(l7_route, connect_generation_guard.captured_generation()) @@ -2100,7 +2099,7 @@ fn authorize_egress_intent( EgressDecision { intent: intent.clone(), action: NetworkAction::Deny { reason }, - l4_policy_generation: engine.current_generation(), + policy_generation: engine.current_generation(), identity, endpoint: EndpointDecision::default(), binary, @@ -2166,13 +2165,13 @@ fn authorize_egress_intent( cmdline_paths: cmdline_paths.clone(), }; - let result = match engine.evaluate_network_action_with_generation(&input) { - Ok((action, generation)) => EgressDecision { + let result = match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { intent: intent.clone(), - action, - l4_policy_generation: generation, + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(bin_path), binary_pid: Some(binary_pid), ancestors, @@ -2221,15 +2220,15 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres cmdline_paths: vec![], }; - match engine.evaluate_network_action_with_generation(&input) { - Ok((action, generation)) => EgressDecision { + match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { intent, - action, - l4_policy_generation: generation, + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: None, binary_pid: None, ancestors: vec![], @@ -2240,7 +2239,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres action: NetworkAction::Deny { reason: format!("policy evaluation error: {e}"), }, - l4_policy_generation: engine.current_generation(), + policy_generation: engine.current_generation(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), @@ -2271,7 +2270,7 @@ fn authorize_egress_intent( action: NetworkAction::Deny { reason: "identity binding unavailable on this platform".into(), }, - l4_policy_generation: engine.current_generation(), + policy_generation: engine.current_generation(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::UnsupportedPlatform, ), @@ -2923,27 +2922,25 @@ async fn reject_stale_connect_policy( /// /// Returns `Some(L7EndpointConfig)` if the matched endpoint has L7 config (protocol field), /// `None` for L4-only endpoints. -fn hydrate_l7_route(engine: &OpaEngine, decision: &mut EgressDecision) { +fn hydrate_l7_route(decision: &mut EgressDecision) { let host = decision.intent.destination.host.clone(); let port = decision.intent.destination.port; - decision.endpoint.l7_route = query_l7_route_snapshot(engine, decision, &host, port); + decision.endpoint.l7_route = query_l7_route_snapshot(decision, &host, port); } -fn hydrate_tls_mode(engine: &OpaEngine, decision: &mut EgressDecision) { +fn hydrate_tls_mode(decision: &mut EgressDecision) { let host = decision.intent.destination.host.clone(); let port = decision.intent.destination.port; - decision.endpoint.tls_mode = query_tls_mode(engine, decision, &host, port); + decision.endpoint.tls_mode = query_tls_mode(decision, &host, port); } fn hydrate_destination_plan( - engine: &OpaEngine, decision: &mut EgressDecision, trusted_host_gateway: Option, ) -> std::result::Result<(), DestinationDenial> { let host = decision.intent.destination.host.clone(); - let port = decision.intent.destination.port; - let raw_allowed_ips = query_allowed_ips(engine, decision, &host, port); - let exact_declared_host = query_exact_declared_endpoint_host(engine, decision, &host, port); + let raw_allowed_ips = query_allowed_ips(decision); + let exact_declared_host = decision.endpoint.exact_declared_host; let plan = build_validation_plan( &host, &host.to_ascii_lowercase(), @@ -2956,7 +2953,6 @@ fn hydrate_destination_plan( } fn query_l7_route_snapshot( - engine: &OpaEngine, decision: &EgressDecision, host: &str, port: u16, @@ -2970,46 +2966,27 @@ fn query_l7_route_snapshot( return None; } - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), - }; - - match engine.query_endpoint_configs_with_generation(&input) { - Ok((vals, generation)) => { - let configs: Vec<_> = vals - .into_iter() - .filter_map(|val| crate::l7::parse_l7_config(&val)) - .map(|config| L7ConfigSnapshot { config }) - .collect(); - debug!( - host, - port, - generation, - config_count = configs.len(), - "Forward proxy L7 route lookup complete" - ); - Some(L7RouteSnapshot { - configs, - l7_policy_generation: generation, - }) - } - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(host, port)) - .message(format!("Failed to query L7 endpoint config: {e}")) - .build(); - ocsf_emit!(event); - None - } + let configs: Vec<_> = decision + .endpoint + .policy_configs + .iter() + .filter_map(crate::l7::parse_l7_config) + .map(|config| L7ConfigSnapshot { config }) + .collect(); + if configs.is_empty() { + return None; } + debug!( + host, + port, + generation = decision.policy_generation, + config_count = configs.len(), + "Egress L7 route materialized from authorization snapshot" + ); + Some(L7RouteSnapshot { + configs, + l7_policy_generation: decision.policy_generation, + }) } fn select_l7_config_for_path<'a>( @@ -3025,12 +3002,7 @@ fn select_l7_config_for_path<'a>( /// Query the TLS mode for an endpoint, independent of L7 config. /// /// This extracts `tls: skip` from the endpoint even when no `protocol` is set. -fn query_tls_mode( - engine: &OpaEngine, - decision: &EgressDecision, - host: &str, - port: u16, -) -> crate::l7::TlsMode { +fn query_tls_mode(decision: &EgressDecision, _host: &str, _port: u16) -> crate::l7::TlsMode { let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), NetworkAction::Deny { .. } => false, @@ -3039,19 +3011,11 @@ fn query_tls_mode( return crate::l7::TlsMode::Auto; } - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), - }; - - match engine.query_endpoint_config(&input) { - Ok(Some(val)) => crate::l7::parse_tls_mode(&val), - _ => crate::l7::TlsMode::Auto, - } + decision + .endpoint + .policy_configs + .first() + .map_or(crate::l7::TlsMode::Auto, crate::l7::parse_tls_mode) } fn query_endpoint_credential_guard( @@ -3674,13 +3638,8 @@ fn parse_allowed_ips(raw: &[String]) -> std::result::Result, S } } -/// Query `allowed_ips` from the matched endpoint config for a CONNECT decision. -fn query_allowed_ips( - engine: &OpaEngine, - decision: &EgressDecision, - host: &str, - port: u16, -) -> Vec { +/// Read `allowed_ips` from the endpoint configs captured during authorization. +fn query_allowed_ips(decision: &EgressDecision) -> Vec { // Only query if action is Allow with a matched policy let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), @@ -3690,71 +3649,29 @@ fn query_allowed_ips( return vec![]; } - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), - }; - - match engine.query_allowed_ips(&input) { - Ok(ips) => ips, - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(host, port)) - .message(format!( - "Failed to query allowed_ips from endpoint config: {e}" - )) - .build(); - ocsf_emit!(event); - vec![] - } - } + decision + .endpoint + .policy_configs + .first() + .map(|config| endpoint_config_string_array(config, "allowed_ips")) + .unwrap_or_default() } -/// Query whether the matched endpoint was declared as this exact hostname. -fn query_exact_declared_endpoint_host( - engine: &OpaEngine, - decision: &EgressDecision, - host: &str, - port: u16, -) -> bool { - let has_policy = match &decision.action { - NetworkAction::Allow { matched_policy } => matched_policy.is_some(), - NetworkAction::Deny { .. } => false, +fn endpoint_config_string_array(config: ®orus::Value, key: &str) -> Vec { + let regorus::Value::Object(fields) = config else { + return Vec::new(); }; - if !has_policy { - return false; - } - - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), + let key = regorus::Value::String(key.into()); + let Some(regorus::Value::Array(values)) = fields.get(&key) else { + return Vec::new(); }; - - match engine.query_exact_declared_endpoint_host(&input) { - Ok(is_exact_declared) => is_exact_declared, - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(host, port)) - .message(format!("Failed to query exact declared endpoint host: {e}")) - .build(); - ocsf_emit!(event); - false - } - } + values + .iter() + .filter_map(|value| match value { + regorus::Value::String(value) => Some(value.to_string()), + _ => None, + }) + .collect() } /// Canonicalize the request-target for inference pattern detection. @@ -4533,7 +4450,7 @@ async fn handle_forward_proxy( binary = %binary_str, binary_pid = %pid_str, matched_policy = %policy_str, - l4_policy_generation = decision.l4_policy_generation, + policy_generation = decision.policy_generation, current_generation = opa_engine.current_generation(), action = ?decision.action, "Forward proxy L4 policy decision" @@ -4541,14 +4458,14 @@ async fn handle_forward_proxy( let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); let forward_generation_guard = match relay::pin_policy_generation( &opa_engine, - decision.l4_policy_generation, + decision.policy_generation, ) { Ok(guard) => guard, Err(e) => { warn!( host = %host_lc, port, - l4_policy_generation = decision.l4_policy_generation, + policy_generation = decision.policy_generation, current_generation = opa_engine.current_generation(), error = %e, "Forward proxy rejected request because policy generation changed after L4 decision" @@ -4589,7 +4506,7 @@ async fn handle_forward_proxy( // connection, so a single evaluation suffices. The shared HTTP relay // strips hop-by-hop `Connection` headers and drops the upstream after // the response instead of asking the upstream to close it. - hydrate_l7_route(&opa_engine, &mut decision); + hydrate_l7_route(&mut decision); let canonicalize_options = crate::l7::path::CanonicalizeOptions { allow_encoded_slash: decision.endpoint.l7_route.as_ref().is_some_and(|route| { route @@ -4658,7 +4575,7 @@ async fn handle_forward_proxy( warn!( host = %host_lc, port, - l4_policy_generation = decision.l4_policy_generation, + policy_generation = decision.policy_generation, l4_guard_generation = forward_generation_guard.captured_generation(), l7_policy_generation = route.l7_policy_generation, current_generation = opa_engine.current_generation(), @@ -5091,7 +5008,7 @@ async fn handle_forward_proxy( // - Otherwise: reject internal IPs, allow public IPs through. // When the policy host is already a literal IP address, treat it as // implicitly allowed — the user explicitly declared the destination. - match hydrate_destination_plan(&opa_engine, &mut decision, *trusted_host_gateway) { + match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { deny_forward_destination( @@ -7358,21 +7275,28 @@ network_policies: ) { let policy = include_str!("../data/sandbox-policy.rego"); let engine = OpaEngine::from_strings(policy, data).unwrap(); + let authorization = engine + .authorize_egress(&crate::opa::NetworkInput { + host: host.to_string(), + port, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: String::new(), + ancestors: vec![], + cmdline_paths: vec![], + }) + .expect("authorize egress"); let decision = EgressDecision { intent: EgressIntent::forward_http(host.to_string(), port), - action: NetworkAction::Allow { - matched_policy: Some(policy_name.to_string()), - }, - l4_policy_generation: engine.current_generation(), + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(PathBuf::from("/usr/bin/node")), binary_pid: None, ancestors: vec![], cmdline_paths: vec![], }; - let route = - query_l7_route_snapshot(&engine, &decision, host, port).expect("L7 route should match"); + let route = query_l7_route_snapshot(&decision, host, port).expect("L7 route should match"); let config = select_l7_config_for_path(&route.configs, path) .expect("path-specific L7 config should match") .config @@ -11553,10 +11477,8 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], }; - let (action, generation) = engine - .evaluate_network_action_with_generation(&input) - .expect("evaluate"); - match &action { + let authorization = engine.authorize_egress(&input).expect("evaluate"); + match &authorization.action { NetworkAction::Allow { matched_policy } => { assert!(matched_policy.is_some(), "allow must carry the policy name"); } @@ -11566,16 +11488,16 @@ network_policies: } let decision = EgressDecision { intent: EgressIntent::connect("203.0.113.10".to_string(), 443), - action, - l4_policy_generation: generation, + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(input.binary_path), binary_pid: Some(1), ancestors: vec![], cmdline_paths: vec![], }; - query_tls_mode(&engine, &decision, "203.0.113.10", 443) + query_tls_mode(&decision, "203.0.113.10", 443) }; assert_eq!( diff --git a/crates/openshell-supervisor-network/src/proxy/destination.rs b/crates/openshell-supervisor-network/src/proxy/destination.rs index ea47cf7d06..4f94a08743 100644 --- a/crates/openshell-supervisor-network/src/proxy/destination.rs +++ b/crates/openshell-supervisor-network/src/proxy/destination.rs @@ -20,7 +20,14 @@ pub(super) enum AddressAuthorization { ExplicitAllowedIps(Vec), ExactDeclaredHost, ImplicitIpLiteral(IpAddr), - TrustedGatewayAlias { expected_ip: IpAddr }, + TrustedGatewayAlias { + expected_ip: IpAddr, + }, + /// Addresses already resolved and authorized by policy DNS. This mode must + /// never resolve `DestinationRequest::host` again before constructing the + /// unopened connector. + #[allow(dead_code, reason = "used when the policy DNS adapter lands")] + PinnedResolved(Vec), } /// Fully materialized input to shared destination validation. @@ -94,6 +101,24 @@ pub(super) fn build_validation_plan( }) } +/// Build the destination mode used by policy DNS after it has validated and +/// pinned a non-empty answer set for an endpoint. +#[allow(dead_code, reason = "used when the policy DNS adapter lands")] +pub(super) fn build_pinned_validation_plan( + addresses: Vec, +) -> Result { + if addresses.is_empty() { + return Err(DestinationDenial::new( + DestinationDenialKind::InvalidAllowedIps, + "policy DNS produced an empty pinned address set".to_string(), + )); + } + + Ok(DestinationValidationPlan { + address_authorization: AddressAuthorization::PinnedResolved(addresses), + }) +} + /// Validated, but not yet opened, upstream destination. /// /// The explicit proxy adapter controls when `connect` is called so CONNECT and @@ -179,6 +204,11 @@ pub(super) async fn validate_destination( DestinationDenial::new(DestinationDenialKind::InternalAddress, reason) })? } + AddressAuthorization::PinnedResolved(addresses) => addresses + .iter() + .copied() + .map(|address| SocketAddr::new(address, port)) + .collect(), }; Ok(UpstreamConnector::new(host, port, addrs)) @@ -266,6 +296,27 @@ mod tests { assert_eq!(denial.kind, DestinationDenialKind::TrustedGateway); } + #[tokio::test] + async fn pinned_addresses_construct_connector_without_resolving_host() { + let pinned_ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)); + let plan = build_pinned_validation_plan(vec![pinned_ip]).unwrap(); + + let connector = validate_destination(request("does-not-resolve.invalid", &plan)) + .await + .expect("pinned mode must not resolve the hostname"); + + assert_eq!(connector.addrs(), &[SocketAddr::new(pinned_ip, 80)]); + } + + #[test] + fn pinned_addresses_must_not_be_empty() { + let denial = build_pinned_validation_plan(Vec::new()) + .expect_err("an empty pinned answer set must be rejected"); + + assert_eq!(denial.kind, DestinationDenialKind::InvalidAllowedIps); + assert!(denial.reason.contains("empty pinned address set")); + } + #[test] fn validation_mode_precedence_is_explicit_and_stable() { let trusted_ip = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index f059175cfa..2d88d74995 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -27,14 +27,21 @@ pub(super) struct L7RouteSnapshot { /// Endpoint metadata materialized for an allowed egress decision. /// -/// The migration hydrates these fields at the same points the legacy handlers -/// queried them so policy-reload and upstream-connect timing remain unchanged. +/// Adapters materialize these fields from the authoritative policy snapshot at +/// their existing timing boundaries so upstream-connect behavior stays stable. #[derive(Debug, Clone)] pub(super) struct EndpointDecision { pub(super) tls_mode: crate::l7::TlsMode, pub(super) l7_route: Option, - /// Destination authorization selected at the legacy hydration point. + /// Destination authorization selected from the captured endpoint metadata. pub(super) destination: Option, + /// Raw endpoint configs returned with the authoritative egress decision. + pub(super) policy_configs: Vec, + /// Full endpoint identities and metadata captured in the same generation. + #[allow(dead_code, reason = "consumed when the policy DNS adapter lands")] + pub(super) matched_endpoints: Vec, + /// Whether policy matched the requested hostname exactly (not by glob). + pub(super) exact_declared_host: bool, } impl Default for EndpointDecision { @@ -43,6 +50,20 @@ impl Default for EndpointDecision { tls_mode: crate::l7::TlsMode::Auto, l7_route: None, destination: None, + policy_configs: Vec::new(), + matched_endpoints: Vec::new(), + exact_declared_host: false, + } + } +} + +impl EndpointDecision { + pub(super) fn from_authorization(authorization: &crate::opa::EgressAuthorization) -> Self { + Self { + policy_configs: authorization.endpoint_configs.clone(), + matched_endpoints: authorization.matched_endpoints.clone(), + exact_declared_host: authorization.exact_declared_endpoint_host, + ..Self::default() } } } @@ -52,6 +73,9 @@ impl Default for EndpointDecision { pub(super) enum EgressTransport { Connect, ForwardHttp, + /// Future transparent TCP adapter fed by the policy DNS registry. + #[allow(dead_code, reason = "constructed when transparent TCP adapter lands")] + TransparentTcp, } /// Destination requested by an explicit proxy adapter. @@ -77,6 +101,14 @@ impl EgressIntent { Self::new(EgressTransport::ForwardHttp, host, port) } + #[cfg(test)] + pub(super) fn transparent_tcp(host: String, port: u16) -> Self { + Self { + transport: EgressTransport::TransparentTcp, + destination: RequestedDestination { host, port }, + } + } + fn new(transport: EgressTransport, host: String, port: u16) -> Self { Self { transport, @@ -105,15 +137,13 @@ pub(super) enum ProcessIdentityEvidence { /// Result of authorizing a normalized egress intent. /// -/// The identity fields intentionally mirror the former CONNECT-specific -/// decision during the compatibility migration. Endpoint configuration is -/// hydrated at the legacy query points without changing lookup precedence or -/// failure defaults. +/// The policy action and endpoint metadata are one atomic snapshot. Adapters +/// may parse that metadata later, but they never query a second generation. pub(super) struct EgressDecision { pub(super) intent: EgressIntent, pub(super) action: NetworkAction, - /// Policy generation used for the L4 network decision. - pub(super) l4_policy_generation: u64, + /// Policy generation used for the complete authorization snapshot. + pub(super) policy_generation: u64, /// Whether process identity evidence was available to policy evaluation. pub(super) identity: ProcessIdentityEvidence, /// Endpoint behavior hydrated for destination validation and relays. @@ -142,5 +172,10 @@ mod tests { assert_eq!(connect.destination.port, 443); assert_eq!(forward.transport, EgressTransport::ForwardHttp); assert_eq!(forward.destination.port, 80); + + let transparent = EgressIntent::transparent_tcp("db.example.com".to_string(), 5432); + assert_eq!(transparent.transport, EgressTransport::TransparentTcp); + assert_eq!(transparent.destination.host, "db.example.com"); + assert_eq!(transparent.destination.port, 5432); } } diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 1ec122d11f..70c3b8c52e 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -134,7 +134,7 @@ pub(super) fn prepare_http_relay<'a>( decision: &EgressDecision, request: &'a L7EvalContext, ) -> Option> { - if let Err(error) = validate_route_generation(route, decision.l4_policy_generation) { + if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( &decision.intent.destination.host, decision.intent.destination.port, @@ -144,7 +144,7 @@ pub(super) fn prepare_http_relay<'a>( } let policy = if let Some(route) = route.filter(|route| !route.configs.is_empty()) { - let evaluator = match pin_l7_evaluator(opa_engine, decision.l4_policy_generation) { + let evaluator = match pin_l7_evaluator(opa_engine, decision.policy_generation) { Ok(evaluator) => evaluator, Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -165,18 +165,17 @@ pub(super) fn prepare_http_relay<'a>( evaluator: Box::new(evaluator), } } else { - let generation_guard = - match pin_policy_generation(opa_engine, decision.l4_policy_generation) { - Ok(guard) => guard, - Err(error) => { - emit_l7_tunnel_close_after_policy_change( - &decision.intent.destination.host, - decision.intent.destination.port, - error, - ); - return None; - } - }; + let generation_guard = match pin_policy_generation(opa_engine, decision.policy_generation) { + Ok(guard) => guard, + Err(error) => { + emit_l7_tunnel_close_after_policy_change( + &decision.intent.destination.host, + decision.intent.destination.port, + error, + ); + return None; + } + }; PreparedHttpPolicy::Passthrough { generation_guard } }; @@ -195,7 +194,7 @@ pub(super) fn prepare_raw_relay( opa_engine: &OpaEngine, decision: &EgressDecision, ) -> Option { - if let Err(error) = validate_route_generation(route, decision.l4_policy_generation) { + if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( &decision.intent.destination.host, decision.intent.destination.port, @@ -204,7 +203,7 @@ pub(super) fn prepare_raw_relay( return None; } - match pin_policy_generation(opa_engine, decision.l4_policy_generation) { + match pin_policy_generation(opa_engine, decision.policy_generation) { Ok(guard) => Some(guard), Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -324,13 +323,13 @@ mod tests { const POLICY_REGO: &str = include_str!("../../data/sandbox-policy.rego"); const EMPTY_POLICY_DATA: &str = "network_policies: {}\n"; - fn decision(l4_policy_generation: u64) -> EgressDecision { + fn decision(policy_generation: u64) -> EgressDecision { EgressDecision { intent: EgressIntent::connect("example.com".to_string(), 80), action: NetworkAction::Allow { matched_policy: Some("test".to_string()), }, - l4_policy_generation, + policy_generation, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::default(), binary: None, @@ -373,7 +372,7 @@ mod tests { assert_eq!( generation_guard.captured_generation(), - decision.l4_policy_generation + decision.policy_generation ); } diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index b7abb31268..186d156086 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -13,7 +13,7 @@ fn allowed_decision(intent: EgressIntent) -> EgressDecision { action: NetworkAction::Allow { matched_policy: Some("proxy_compatibility".to_string()), }, - l4_policy_generation: 0, + policy_generation: 0, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::default(), binary: Some(PathBuf::from("/usr/bin/curl")), @@ -279,65 +279,65 @@ fn representative_adapter_allows_preserve_ocsf_fields() { ); } -fn poisoned_engine() -> OpaEngine { - let engine = OpaEngine::from_strings( - include_str!("../../../data/sandbox-policy.rego"), - r#" -network_policies: - proxy_compatibility: - name: proxy_compatibility - endpoints: - - host: target.example - port: 443 - protocol: rest - enforcement: enforce - tls: skip - allowed_ips: ["10.0.0.0/8"] - rules: - - allow: { method: GET, path: "/**" } - binaries: - - path: /usr/bin/curl -"#, - ) - .unwrap(); - engine.poison_lock_for_test(); - engine -} - #[test] -fn l7_query_failure_preserves_l4_only_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_l7_metadata_preserves_l4_only_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); - assert!(query_l7_route_snapshot(&engine, &decision, "target.example", 443).is_none()); + assert!(query_l7_route_snapshot(&decision, "target.example", 443).is_none()); } #[test] -fn tls_query_failure_preserves_auto_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_tls_metadata_preserves_auto_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); assert_eq!( - query_tls_mode(&engine, &decision, "target.example", 443), + query_tls_mode(&decision, "target.example", 443), crate::l7::TlsMode::Auto ); } #[test] -fn allowed_ips_query_failure_preserves_empty_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_allowed_ips_preserves_empty_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); - assert!(query_allowed_ips(&engine, &decision, "target.example", 443).is_empty()); + assert!(query_allowed_ips(&decision).is_empty()); } #[test] -fn exact_host_query_failure_preserves_false_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_exact_host_preserves_false_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); - assert!(!query_exact_declared_endpoint_host( + assert!(!decision.endpoint.exact_declared_host); +} + +#[test] +fn authoritative_evaluation_error_denies_without_metadata_fallback() { + let engine = OpaEngine::from_strings( + include_str!("../../../data/sandbox-policy.rego"), + r#" +network_policies: + proxy_compatibility: + name: proxy_compatibility + endpoints: + - host: "*.example.com" + port: 443 + binaries: + - path: /** +"#, + ) + .unwrap(); + + // Regorus rejects the NUL byte used internally by its glob matcher. The + // combined authorization query must deny rather than preserve the old + // multi-query behavior that could fall back to an L4-only allow. + let decision = evaluate_endpoint_only_opa( &engine, - &decision, - "target.example", - 443 - )); + EgressIntent::connect("sub\0.example.com".to_string(), 443), + ); + + let NetworkAction::Deny { reason } = decision.action else { + panic!("evaluation errors must deny the request"); + }; + assert!(reason.starts_with("policy evaluation error:")); + assert!(decision.endpoint.policy_configs.is_empty()); + assert!(decision.endpoint.matched_endpoints.is_empty()); + assert!(decision.endpoint.destination.is_none()); } #[test] diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 8dd334f022..a81e597f59 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -160,16 +160,16 @@ Each endpoint defines a reachable destination and optional inspection rules. | Field | Type | Required | Description | |---|---|---|---| -| `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. | +| `host` | string | Conditional | Hostname or IP address. Required for `protocol: tcp`; transparent TCP requires a valid DNS hostname and rejects literal IPs. A non-TCP proxy endpoint may omit `host` only when `allowed_ips` supplies the destination constraint. 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, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Omit for TCP passthrough. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | +| `protocol` | string | No | Set to `tcp` for explicit L4 TCP passthrough without payload inspection. Omitting the field has the same payload-handling behavior, but only explicit `tcp` reserves the transparent-TCP path and therefore requires a valid DNS hostname. 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, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | | `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. Provider-credentialed endpoints reject `tls: skip` unless `allow_uninspected_credentials` is explicitly set. 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`. Not valid on `protocol: mcp` or `protocol: json-rpc`; MCP uses explicit rules unless `mcp.allow_all_known_mcp_methods: true` enables the endpoint method profile, and JSON-RPC always uses explicit rules. | | `rules` | list of allow 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. | +| `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. A hostless allowlist is valid only for the legacy proxy path and cannot be combined with `protocol: tcp`. 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. | | `allow_encoded_slash` | bool | No | When `true`, L7 request parsing preserves `%2F` inside path segments instead of rejecting it. Use this for registries and APIs such as npm scoped packages (`/@scope%2Fname`). Defaults to `false`. | | `websocket_credential_rewrite` | bool | No | When `true` on a `protocol: rest` or `protocol: websocket` endpoint, OpenShell rewrites credential placeholders in client-to-server WebSocket text messages after an allowed HTTP `101` upgrade. On provider-credentialed endpoints without `allow_uninspected_credentials`, OpenShell uses the parsed relay and rejects binary frames; text frames containing placeholders fail closed when rewrite is disabled. Defaults to `false`. | | `request_body_credential_rewrite` | bool | No | When `true` on a `protocol: rest` endpoint, OpenShell rewrites credential placeholders in UTF-8 `application/json`, `application/x-www-form-urlencoded`, and `text/*` request bodies before forwarding upstream. The proxy buffers at most 256 KiB and updates `Content-Length` after rewriting. For chunked requests, the limit counts framing, extensions, and trailers. When rewrite is disabled and the sandbox has provider credentials, ordinary bodies continue to stream, but a reserved credential placeholder is rejected before its marker reaches upstream, including for providers without endpoint profiles. Defaults to `false`. Mutually exclusive with `credential_signing`. | @@ -191,6 +191,8 @@ Each endpoint defines a reachable destination and optional inspection rules. **Validation constraints:** - `access` and `rules` are mutually exclusive; setting both is rejected. +- `protocol: tcp` requires a valid DNS hostname. Hostless `allowed_ips`, IP-literal hosts, trailing-dot names, and malformed DNS selectors are rejected with a policy-validation error. +- `protocol: tcp` rejects L7-only fields, including `path`, `enforcement`, `access`, `rules`, `deny_rules`, request rewriting and credential signing fields, and GraphQL, JSON-RPC, or MCP options. - When `protocol` is set, at least one of `access` or `rules` is required for `rest`, `websocket`, `graphql`, and `sql`. - `mcp` and `json-rpc` reject `access` presets; use explicit `rules`. - `json-rpc` requires explicit `rules` with `allow.method`. diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 83150c39d1..90796fe626 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -69,7 +69,7 @@ When a hot reload changes rules, the supervisor publishes a new policy generatio | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | -| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | +| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol`, or with `protocol: tcp`, allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | | `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | ## Supervisor Middleware @@ -307,7 +307,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 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`, `mcp`, and `json-rpc`. | +| `protocol` | No | Endpoint mode accepted by `openshell policy update`: `tcp`, `rest`, `websocket`, or `sql`. `tcp` explicitly selects the same L4 passthrough used when this field is omitted. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql`, `mcp`, 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`. | @@ -317,6 +317,7 @@ Examples: |---|---| | `pypi.org:443` | Add a plain L4 endpoint. The proxy allows the TCP stream and does not inspect HTTP requests. | | `telemetry.example.com:443::::allow-uninspected-credentials` | Explicitly allow a provider-credentialed L4 endpoint after accepting that OpenShell cannot inspect or rewrite its traffic. | +| `db.internal.example:5432::tcp` | Add an explicit L4 endpoint. The empty `access` segment is required before `tcp`. | | `api.github.com:443:read-only:rest:enforce` | Add a REST endpoint with the `read-only` preset expanded by the policy engine into GET, HEAD, and OPTIONS access. | | `api.example.com:443:read-write:rest:enforce:request-body-credential-rewrite` | Add a REST endpoint that rewrites credential placeholders in supported text request bodies. | | `realtime.example.com:443:read-write:websocket:enforce` | Add a WebSocket endpoint with the `read-write` preset expanded by the policy engine into the upgrade `GET` and client `WEBSOCKET_TEXT` access. | @@ -340,6 +341,7 @@ Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding). For example: +- `db.internal.example:5432::tcp` is valid. - `api.github.com:443:read-only:rest` is valid. - `realtime.example.com:443:read-write:websocket` is valid. - `api.github.com:443::rest` is invalid. It does not mean "allow all traffic." An L7 endpoint with `protocol` but no `access` or `rules` is rejected when the policy loads. @@ -606,7 +608,7 @@ Allow `pip install` and `uv pip install` to reach PyPI: - { path: /usr/local/bin/uv } ``` -Endpoints without `protocol` use TCP passthrough, where the proxy allows the stream without inspecting payloads. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. Provider-credentialed endpoints cannot use this shape unless `allow_uninspected_credentials: true` records the exception. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`. +Endpoints without `protocol`, or with explicit `protocol: tcp`, use TCP passthrough, where the proxy allows the stream without inspecting payloads. Explicit `protocol: tcp` does not enable direct sandbox DNS or transparent TCP capture at this stage. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. Provider-credentialed endpoints cannot use this shape unless `allow_uninspected_credentials: true` records the exception. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`. diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 02aaefe8a7..97f97aeccd 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -177,6 +177,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -202,6 +223,21 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crypto-common" version = "0.1.7" @@ -212,6 +248,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deranged" version = "0.5.8" @@ -371,6 +413,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core", ] [[package]] @@ -425,6 +468,25 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand", + "ring", + "thiserror", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "http" version = "1.4.2" @@ -665,6 +727,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -868,6 +979,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "openshell-core" @@ -931,6 +1046,7 @@ dependencies = [ name = "openshell-policy" version = "0.0.0" dependencies = [ + "hickory-proto", "miette", "openshell-core", "prost-types", @@ -1050,6 +1166,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1249,6 +1371,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rcgen" version = "0.13.2" @@ -1320,6 +1459,15 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1392,6 +1540,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1430,6 +1587,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1505,7 +1668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1525,6 +1688,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -1713,6 +1892,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -1979,6 +2173,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2039,6 +2243,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 95df265ff2..51139ba461 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -109,7 +109,8 @@ message NetworkEndpoint { // Single port (backwards compat). Use `ports` for multiple ports. // Mutually exclusive with `ports` — if both are set, `ports` takes precedence. uint32 port = 2; - // Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only). + // Endpoint protocol. "tcp" and "" select L4-only handling; "rest", + // "websocket", "graphql", "sql", "json-rpc", and "mcp" select L7 inspection. string protocol = 3; // TLS handling: "terminate" or "passthrough" (default). string tls = 4; diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 25ad8295ce..8da143ebaa 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -652,7 +652,8 @@ type NetworkEndpoint struct { // Single port (backwards compat). Use `ports` for multiple ports. // Mutually exclusive with `ports` — if both are set, `ports` takes precedence. Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` - // Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only). + // Endpoint protocol. "tcp" and "" select L4-only handling; "rest", + // "websocket", "graphql", "sql", "json-rpc", and "mcp" select L7 inspection. Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` // TLS handling: "terminate" or "passthrough" (default). Tls string `protobuf:"bytes,4,opt,name=tls,proto3" json:"tls,omitempty"`