diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 9203ba3178..62f5837e70 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -97,6 +97,13 @@ raw relay by default. A `protocol: rest` endpoint can opt in to after an allowed `101` upgrade; server-to-client traffic and all other upgraded protocols remain raw passthrough. +A `protocol: tcp` hostname is a connection-routing constraint, not an +application-authority boundary. Transparent capture validates the approved DNS +name, pinned destination address, port, and calling binary before opening the +stream, but it does not inspect TLS SNI, HTTP `Host`, or another protocol-level +destination. Compatible shared infrastructure can therefore let a client +select another tenant, virtual host, or service behind the approved front door. + ## Credentialed Endpoints OpenShell keeps provider credentials on paths it can inspect or rewrite by @@ -216,6 +223,12 @@ through the proposal loop instead of treating the denial as terminal. 1. **Submit.** Both proposers POST through the same `SubmitPolicyAnalysis` path. Each chunk is persisted with its `analysis_mode` for audit provenance. + Agent-authored chunks cannot request `protocol: tcp` or `tls: skip`; the + sandbox-local API rejects those transport choices for immediate feedback, + and the gateway repeats the check before persistence. + Omitted-protocol endpoints remain available through the explicit proxy with + default TLS termination and HTTP authority checks. Administrators can still + author native TCP and raw TLS policy directly. 2. **Build and validate the candidate.** The gateway first canonicalizes a mechanistic proposal against the live effective policy. If an endpoint is already governed by an inspected or provider-owned contract, the candidate diff --git a/crates/openshell-policy/src/l7_validate.rs b/crates/openshell-policy/src/l7_validate.rs index d60491c4bb..fe07df123f 100644 --- a/crates/openshell-policy/src/l7_validate.rs +++ b/crates/openshell-policy/src/l7_validate.rs @@ -49,6 +49,26 @@ pub fn is_explicit_tcp_protocol(protocol: &str) -> bool { protocol.eq_ignore_ascii_case("tcp") } +/// Reject transport choices that an in-sandbox agent must not grant itself. +/// +/// An omitted protocol remains allowed: it uses the established explicit +/// proxy, which canonicalizes forward HTTP authorities and terminates TLS by +/// default. Native transparent TCP and `tls: skip` bypass those application +/// authority checks, so only an administrator may author them directly. +pub fn agent_authored_transport_rejection(protocol: &str, tls: &str) -> Option<&'static str> { + if is_explicit_tcp_protocol(protocol) { + return Some( + "agent-authored proposals cannot request protocol tcp; ask an administrator to add native TCP access explicitly", + ); + } + if tls.eq_ignore_ascii_case("skip") { + return Some( + "agent-authored proposals cannot request tls: skip; ask an administrator to add raw TLS access explicitly", + ); + } + None +} + /// Reject additional L7-only fields represented outside /// [`L7EndpointFields`] by the runtime and provider-profile schemas. /// @@ -69,6 +89,24 @@ pub fn validate_explicit_tcp_additional_fields( )] } +#[cfg(test)] +mod agent_transport_tests { + use super::agent_authored_transport_rejection; + + #[test] + fn omitted_protocol_with_default_tls_remains_available_to_agents() { + assert_eq!(agent_authored_transport_rejection("", ""), None); + } + + #[test] + fn agent_cannot_request_native_tcp_or_skip_tls_inspection() { + assert!(agent_authored_transport_rejection("tcp", "").is_some()); + assert!(agent_authored_transport_rejection("TCP", "terminate").is_some()); + assert!(agent_authored_transport_rejection("", "skip").is_some()); + assert!(agent_authored_transport_rejection("rest", "SKIP").is_some()); + } +} + /// 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. diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 5be8173e3e..3dcbdf3d32 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -37,8 +37,8 @@ pub use compose::{ is_provider_rule_name, provider_rule_name, strip_provider_rule_names, }; pub use l7_validate::{ - L7EndpointFields, L7Protocol, validate_explicit_tcp_additional_fields, - validate_l7_endpoint_semantics, + L7EndpointFields, L7Protocol, agent_authored_transport_rejection, + validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, }; pub use merge::{ PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 0e4c74af95..f1f57c2f5b 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -4306,6 +4306,18 @@ pub(super) async fn handle_submit_policy_analysis( } let rule_ref = chunk.proposed_rule.as_ref().expect("checked above"); + if req.analysis_mode == "agent_authored" + && let Some(reason) = rule_ref.endpoints.iter().find_map(|endpoint| { + openshell_policy::agent_authored_transport_rejection( + &endpoint.protocol, + &endpoint.tls, + ) + }) + { + rejected += 1; + rejection_reasons.push(format!("chunk '{}': {reason}", chunk.rule_name)); + continue; + } let incoming_observation_key = rule_ref.endpoints.first().and_then(|endpoint| { rule_ref.binaries.first().map(|binary| { ( @@ -11298,7 +11310,7 @@ mod tests { } #[tokio::test] - async fn approve_all_skips_later_tls_conflict_and_applies_compatible_prefix() { + async fn approve_all_skips_later_endpoint_conflict_and_applies_compatible_prefix() { let state = test_server_state().await; let sandbox_id = "sb-approve-all-conflict"; let sandbox_name = "approve-all-conflict"; @@ -11339,13 +11351,15 @@ mod tests { ..Default::default() }, PolicyChunk { - rule_name: "passthrough".to_string(), + rule_name: "conflicting".to_string(), proposed_rule: Some(NetworkPolicyRule { - name: "passthrough".to_string(), + name: "conflicting".to_string(), endpoints: vec![NetworkEndpoint { host: "shared.example.com".to_string(), port: 443, - tls: "skip".to_string(), + protocol: "graphql".to_string(), + enforcement: "enforce".to_string(), + access: "read-only".to_string(), advisor_proposed: true, ..Default::default() }], @@ -11423,7 +11437,7 @@ mod tests { .unwrap(); let policy = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap(); assert!(policy.network_policies.contains_key("inspected")); - assert!(!policy.network_policies.contains_key("passthrough")); + assert!(!policy.network_policies.contains_key("conflicting")); } #[tokio::test] @@ -13886,6 +13900,91 @@ mod tests { ); } + #[tokio::test] + async fn agent_authored_submit_rejects_native_tcp_and_tls_skip_but_allows_explicit_proxy() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + + let state = test_server_state().await; + let sandbox_name = "reject-agent-raw-transports"; + state + .store + .put_message(&test_sandbox( + "sb-reject-agent-raw-transports", + sandbox_name, + ProtoSandboxPolicy::default(), + vec![], + )) + .await + .unwrap(); + + let endpoint = |protocol: &str, tls: &str| NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + protocol: protocol.to_string(), + tls: tls.to_string(), + ..Default::default() + }; + let chunk = |name: &str, endpoint: NetworkEndpoint| PolicyChunk { + rule_name: name.to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: name.to_string(), + endpoints: vec![endpoint], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }), + ..Default::default() + }; + + let response = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![ + chunk("native_tcp", endpoint("tcp", "")), + chunk("raw_tls", endpoint("", "skip")), + chunk("explicit_proxy", endpoint("", "")), + ], + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.accepted_chunks, 1); + assert_eq!(response.rejected_chunks, 2); + assert_eq!(response.rejection_reasons.len(), 2); + assert!( + response + .rejection_reasons + .iter() + .any(|reason| reason.contains("protocol tcp")) + ); + assert!( + response + .rejection_reasons + .iter() + .any(|reason| reason.contains("tls: skip")) + ); + + let draft = handle_get_draft_policy( + &state, + with_user(Request::new(GetDraftPolicyRequest { + name: sandbox_name.to_string(), + status_filter: String::new(), + workspace: "default".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(draft.chunks.len(), 1); + assert_eq!(draft.chunks[0].rule_name, "explicit_proxy"); + } + #[tokio::test] async fn approve_draft_chunk_rejects_stored_reserved_provider_rule_name() { use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index ef899b666e..6a42eafb8a 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -1112,6 +1112,11 @@ fn network_endpoint_from_json( if endpoint.host.trim().is_empty() { return Err("endpoint.host is required".to_string()); } + if let Some(reason) = + openshell_policy::agent_authored_transport_rejection(&endpoint.protocol, &endpoint.tls) + { + return Err(reason.to_string()); + } let mut ports = endpoint.ports; if ports.is_empty() && endpoint.port > 0 { @@ -1484,6 +1489,47 @@ mod tests { assert!(!error.contains("secret")); } + #[test] + fn proposal_chunks_from_body_rejects_native_tcp_and_tls_skip() { + for endpoint in [ + r#"{"host":"db.example.com","port":5432,"protocol":"tcp"}"#, + r#"{"host":"api.example.com","port":443,"tls":"skip"}"#, + ] { + let body = format!( + r#"{{ + "operations": [{{ + "addRule": {{ + "ruleName": "raw_transport", + "rule": {{"endpoints": [{endpoint}]}} + }} + }}] + }}"# + ); + + let error = proposal_chunks_from_body(body.as_bytes()).unwrap_err(); + assert!(error.contains("administrator"), "unexpected error: {error}"); + } + } + + #[test] + fn proposal_chunks_from_body_accepts_omitted_protocol_with_default_tls() { + let body = br#"{ + "operations": [{ + "addRule": { + "ruleName": "explicit_proxy", + "rule": { + "endpoints": [{"host":"api.example.com","port":443}] + } + } + }] + }"#; + + let chunks = proposal_chunks_from_body(body).unwrap(); + let endpoint = &chunks[0].proposed_rule.as_ref().unwrap().endpoints[0]; + assert!(endpoint.protocol.is_empty()); + assert!(endpoint.tls.is_empty()); + } + #[test] fn parse_last_query_clamps_to_max() { assert_eq!(parse_last_query("last=5"), Some(5)); diff --git a/crates/openshell-supervisor-process/src/skills/policy_advisor.md b/crates/openshell-supervisor-process/src/skills/policy_advisor.md index 4b7074f6dd..8d792aaedb 100644 --- a/crates/openshell-supervisor-process/src/skills/policy_advisor.md +++ b/crates/openshell-supervisor-process/src/skills/policy_advisor.md @@ -50,10 +50,12 @@ operations. Each `addRule` carries a complete narrow `NetworkPolicyRule`. credential is in scope auto-approve** (see Auto-approval below). Any credentialed reach or capability change goes to human review — that is the design. L7 is still the agent-speed path because the prover can - precisely describe the change (which method was added on which path); - L4 to a credentialed host loses that precision. Use L4 only when the - binary's wire protocol is opaque to L7 inspection (`ssh`, `nc`, - `git-remote-http`) or the host has no documented REST surface. + precisely describe the change (which method was added on which path). + When a client can use the explicit proxy but its wire protocol is opaque + to L7 inspection (`ssh`, `nc`, `git-remote-http`), omit `protocol` and + retain default TLS handling. Never propose `protocol: tcp` or `tls: skip`: + those choices bypass application-authority inspection and require an + administrator to add the rule explicitly. 5. Draft the narrowest rule: exact host, exact port, exact binary when known, exact method, and the smallest safe path. 6. Submit the proposal, save `accepted_chunk_ids` from the response, and @@ -207,6 +209,8 @@ The new submission wins by structural overlap. `fe80::/10`) or known metadata hostnames such as `metadata.google.internal`. Cloud-metadata endpoints there can hand out the host's credentials. +- Do not propose `protocol: tcp` or `tls: skip`. If the task requires native + TCP or a raw TLS tunnel, ask an administrator to add that access explicitly. - Do not include query strings, tokens, credentials, or secret values in paths. - Explain uncertainty in `intent_summary` instead of widening the rule. diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index a0c5d29737..0ef627cf2b 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -193,8 +193,10 @@ Each endpoint defines a reachable destination and optional inspection rules. - `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` requires at least one port and rejects L7-only fields, including `path`, `enforcement`, `access`, `rules`, `deny_rules`, request rewriting and credential signing fields, and GraphQL, JSON-RPC, or MCP options. +- A `protocol: tcp` hostname constrains connection routing, not application authority. OpenShell does not inspect TLS SNI, HTTP `Host`, or another protocol-level destination in the stream. Compatible shared infrastructure can therefore expose other tenants, virtual hosts, or services behind an allowed hostname. - A sandbox runtime must support policy DNS and transparent TCP capture before it can activate a policy containing `protocol: tcp`. Docker and Podman provide this runtime support. - Adding the first `protocol: tcp` endpoint to a running sandbox that started without one is rejected atomically because its DNS and capture substrate is startup infrastructure. Recreate the sandbox with a TCP endpoint. A sandbox that started with the substrate can remove and re-add TCP endpoints dynamically. +- Policy-advisor agent proposals cannot request `protocol: tcp` or `tls: skip`. Add native TCP or raw TLS access through an administrator-authored policy. Agent proposals may omit `protocol` to use the explicit proxy with its default TLS termination and HTTP authority checks. - 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 c5a34db14f..1deb76548e 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -131,6 +131,8 @@ network_policies: OpenShell answers DNS only for hostnames eligible under an active `protocol: tcp` endpoint. It validates upstream answers against destination and SSRF controls, returns a supervisor-owned synthetic address, and records the validated real addresses. When the application connects to the synthetic address, OpenShell recovers the hostname and port, evaluates the calling process against the current policy generation, and dials only an address pinned by that DNS result. +Treat that hostname as a connection-routing constraint, not an application-authority boundary. OpenShell does not inspect TLS SNI, HTTP `Host`, or another protocol-level destination inside a `protocol: tcp` stream. If the approved hostname reaches compatible shared infrastructure, a client may be able to select another tenant, virtual host, or service behind the same front door. Use native TCP only when the trust boundary includes every destination that the shared infrastructure can expose; use an inspected protocol when application authority must remain constrained. + Applications must honor the returned DNS TTL and resolve the hostname again before reconnecting after that TTL expires. A client that caches the synthetic address indefinitely can receive a connection failure after the mapping expires. Docker and Podman currently advertise only IPv4 egress for this feature, so OpenShell returns an empty successful answer for AAAA queries and lets dual-stack clients use the working A record. DNS resolution does not authorize a connection by itself. Unknown names, wrong ports, stale mappings, disallowed destination addresses, and binaries outside the matching policy fail closed. Applications cannot inherit access by connecting directly to a real IP returned by an upstream resolver. diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index eeb13058e1..80884620d5 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -129,7 +129,7 @@ OpenShell has two proposal paths: | Path | Source | Typical rule shape | |---|---|---| | Mechanistic mapper | Aggregated denial summaries from the sandbox. | Groups by host, port, and binary. If L7 request samples are available, it can draft REST method and path rules. Otherwise it drafts an L4 endpoint. | -| Agent-authored proposal | The in-sandbox agent, using `policy.local`. | Usually a REST `addRule` with exact host, port, binary, method, and path from the structured denial. It can also propose L4 rules for opaque protocols. | +| Agent-authored proposal | The in-sandbox agent, using `policy.local`. | Usually a REST `addRule` with exact host, port, binary, method, and path from the structured denial. It can also omit `protocol` for endpoint-only access through the explicit proxy. | ### How proposal provenance works @@ -189,7 +189,7 @@ For REST APIs, prefer L7 rules over broad L4 access. A good proposal allows one } ``` -The current `policy.local` JSON shape covers L4 endpoints and REST method or path rules. Use [Customize Sandbox Policies](/sandboxes/policies) or [Policy Schema Reference](/reference/policy-schema) for policy fields that are not part of the agent-authored proposal surface, such as WebSocket credential rewrite, GraphQL operation matching, endpoint path scoping, and provider-owned policy bundles. +The current `policy.local` JSON shape covers explicit-proxy endpoints and REST method or path rules. Agent-authored proposals cannot set `protocol: tcp` or `tls: skip`, because those modes bypass application-authority inspection. When a task requires native TCP or a raw TLS tunnel, a developer must add the rule through the normal policy-authoring workflow. Omitting `protocol` remains supported and retains the explicit proxy's default TLS termination and HTTP authority checks. Use [Customize Sandbox Policies](/sandboxes/policies) or [Policy Schema Reference](/reference/policy-schema) for policy fields that are not part of the agent-authored proposal surface, such as WebSocket credential rewrite, GraphQL operation matching, endpoint path scoping, and provider-owned policy bundles. Policy advisor proposals do not add `allowed_ips` automatically. If an advisor-proposed hostname resolves to an internal or private address, OpenShell's SSRF protections still block the connection until a developer explicitly adds the required `allowed_ips` entry. diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 0b2ce7f7bb..73c0863df7 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -404,7 +404,7 @@ Evaluate the generated policy for overly broad access and **include warnings in | Condition | Warning to show | |-----------|----------------| -| **L4-only** (no `protocol`, or `protocol: tcp`) | "This policy allows all application methods and paths without inspection. An omitted protocol uses explicit-proxy behavior; `protocol: tcp` enables policy DNS and transparent TCP only on a runtime that advertises the complete substrate (currently Docker and Podman). Consider `protocol: rest` with a preset if you want HTTP method-level control." | +| **L4-only** (no `protocol`, or `protocol: tcp`) | "This policy allows all application methods and paths without inspection. An omitted protocol uses explicit-proxy behavior. `protocol: tcp` enables policy DNS and transparent TCP only on a runtime that advertises the complete substrate (currently Docker and Podman); its hostname constrains connection routing, not application authority, so compatible shared infrastructure may expose other tenants or services. Consider `protocol: rest` with a preset if you want HTTP method-level or authority 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." |