Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions crates/openshell-policy/src/l7_validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 104 additions & 5 deletions crates/openshell-server/src/grpc/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
(
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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()
}],
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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};
Expand Down
46 changes: 46 additions & 0 deletions crates/openshell-supervisor-network/src/policy_local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/policy-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions docs/sandboxes/policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/sandboxes/policy-advisor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading