Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9e94f1c
refactor(network): introduce shared egress pipeline
johntmyers Jul 14, 2026
dedc530
test(network): cover shared proxy egress paths
johntmyers Jul 14, 2026
772d91d
refactor(network): make destination authorization explicit
johntmyers Jul 15, 2026
9dbc4fe
refactor(network): pin proxy relay policy context
johntmyers Jul 15, 2026
3dddb1c
test(network): lock relay generation contracts
johntmyers Jul 15, 2026
1e58be0
test(network): establish phase zero compatibility baseline
johntmyers Jul 16, 2026
b59544d
feat(policy): detect ambiguous network endpoints
johntmyers Jul 20, 2026
c55d980
feat(sandbox): fail closed on invalid policy updates
johntmyers Jul 20, 2026
3b33812
refactor(network): invalidate relays on policy changes
johntmyers Jul 20, 2026
2e330b5
docs(policy): document validation failure posture
johntmyers Jul 20, 2026
5b653ec
test(network): cover validation and middleware egress
johntmyers Jul 20, 2026
5ab776f
fix(config): move policy failure mode to gateway toml
johntmyers Jul 20, 2026
0c67990
test(network): name proxy contracts by behavior
johntmyers Jul 21, 2026
79d2123
fix(network): align overlap validation with endpoint selection
johntmyers Jul 23, 2026
1d568e3
test(network): expect hard loopback denial
johntmyers Jul 23, 2026
92efd90
test(network): match declared endpoint denial
johntmyers Jul 23, 2026
344a2eb
fix(policy): preserve path-specific endpoint overrides
johntmyers Jul 23, 2026
0f9a6bf
test(network): respect hard-blocked host gateways
johntmyers Jul 23, 2026
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
15 changes: 15 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ paths, such as proxy support files or GPU device paths when a GPU is present.
All ordinary agent egress is routed through the sandbox proxy. The proxy
identifies the calling binary, checks trust-on-first-use binary identity, rejects
unsafe internal destinations, and evaluates the active policy.

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
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
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.

For inspected HTTP traffic, the proxy can enforce REST method/path rules,
WebSocket upgrade and text-message rules, GraphQL operation rules, and
MCP method, tool, and supported params rules or generic JSON-RPC method rules
Expand Down
32 changes: 25 additions & 7 deletions architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ metadata before forwarding. The proxy also supports credential injection on
terminated HTTP streams when policy allows the endpoint.

Raw streams and long-lived response bodies are connection scoped. Policy
reloads affect the next connection or the next parsed HTTP request; they do not
rewrite bytes already being relayed. HTTP upgrades switch to raw relay by
default. A `protocol: rest` endpoint can opt in to
generation changes close relays pinned to the previous generation instead of
allowing them to continue under stale authorization. HTTP upgrades switch to
raw relay by default. A `protocol: rest` endpoint can opt in to
`websocket_credential_rewrite` for client-to-server WebSocket text messages
after an allowed `101` upgrade; server-to-client traffic and all other upgraded
protocols remain raw passthrough.
Expand All @@ -98,10 +98,28 @@ supervisor polls for config revisions and attempts to load new dynamic policy
into the in-process OPA engine; CLI reads of the latest sandbox policy use the
same effective configuration path.

If a new policy fails validation or loading, the supervisor reports the failure
and keeps the last-known-good policy. Static controls, such as filesystem
allowlists and process identity, require a new sandbox because they are applied
before the child process starts.
The supervisor validates complete effective policy generations before
activation. Overlapping endpoint selectors may contribute request allow and
deny rules only when their connection and request-processing metadata agree;
conflicting TLS, destination, credential, parser, or enforcement metadata
rejects the complete generation. Plain L4 endpoints do not contribute
request-processing metadata, so they may overlap an L7 endpoint when their
connection metadata agrees. When request paths overlap, a path endpoint with a
higher specificity rank deterministically overrides broader request-processing
metadata. Equally specific overlapping endpoints must agree.

The `[openshell.gateway] policy_validation_failure_mode` configuration controls
rejected generations. It defaults to `fail_closed`, which publishes a quarantine
generation, denies new egress, invalidates existing relays, and leaves the
previous policy inactive. Operators may explicitly select
`retain_last_valid`, which keeps the previous generation active. With no
previous valid generation, the effective mode remains `fail_closed` regardless
of the configured mode. The gateway distributes this startup configuration to
sandbox supervisors with each effective policy snapshot. OCSF configuration and finding events state the
candidate version, validation rationale, configured and effective modes, active
generation, and whether the previous policy is active. Static controls,
such as filesystem allowlists and process identity, require a new sandbox
because they are applied before the child process starts.

Gateway-global policy can override sandbox-scoped policy. Use it sparingly
because it changes the effective access model for every sandbox on the gateway.
Expand Down
64 changes: 60 additions & 4 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,43 @@ pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker";
/// Default domain used for browser-facing sandbox service URLs.
pub const DEFAULT_SERVICE_ROUTING_DOMAIN: &str = "openshell.localhost";

/// Gateway posture when a sandbox rejects a candidate policy generation.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyValidationFailureMode {
/// Deactivate the previous policy and deny new egress until a valid
/// generation is loaded.
#[default]
FailClosed,
/// Keep the last valid generation active when a newer candidate fails
/// validation. Startup still fails closed when no valid generation exists.
RetainLastValid,
}

impl PolicyValidationFailureMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::FailClosed => "fail_closed",
Self::RetainLastValid => "retain_last_valid",
}
}
}

impl FromStr for PolicyValidationFailureMode {
type Err = String;

fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"fail_closed" => Ok(Self::FailClosed),
"retain_last_valid" => Ok(Self::RetainLastValid),
_ => Err(format!(
"invalid policy validation failure mode '{value}'; expected fail_closed or retain_last_valid"
)),
}
}
}

/// Default OCI repository for the supervisor image (no tag).
pub const DEFAULT_SUPERVISOR_IMAGE_REPO: &str = "ghcr.io/nvidia/openshell/supervisor";

Expand Down Expand Up @@ -396,6 +433,9 @@ pub struct Config {
/// Log level (trace, debug, info, warn, error).
pub log_level: String,

/// Security posture for rejected sandbox policy generations.
pub policy_validation_failure_mode: PolicyValidationFailureMode,

/// TLS configuration. When `None`, the server listens on plaintext HTTP.
pub tls: Option<TlsConfig>,

Expand Down Expand Up @@ -737,6 +777,7 @@ impl Config {
health_bind_address: None,
metrics_bind_address: None,
log_level: default_log_level(),
policy_validation_failure_mode: PolicyValidationFailureMode::default(),
tls,
oidc: None,
auth: GatewayAuthConfig::default(),
Expand Down Expand Up @@ -981,10 +1022,10 @@ mod tests {
use super::{
ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy,
GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig,
GatewayProviderProfileSourceConfig, detect_docker_socket_from_candidates, detect_driver,
detect_podman_socket_from_candidates, docker_host_unix_socket_path, docker_socket_responds,
is_unix_socket, normalize_compute_driver_name, podman_socket_candidates_from_env,
podman_socket_responds,
GatewayProviderProfileSourceConfig, PolicyValidationFailureMode,
detect_docker_socket_from_candidates, detect_driver, detect_podman_socket_from_candidates,
docker_host_unix_socket_path, docker_socket_responds, is_unix_socket,
normalize_compute_driver_name, podman_socket_candidates_from_env, podman_socket_responds,
};
#[cfg(unix)]
use std::io::{Read as _, Write as _};
Expand Down Expand Up @@ -1020,6 +1061,21 @@ mod tests {
assert!(err.contains("unsupported compute driver 'firecracker'"));
}

#[test]
fn policy_validation_failure_mode_is_secure_by_default() {
assert_eq!(
Config::new(None).policy_validation_failure_mode,
PolicyValidationFailureMode::FailClosed
);
assert_eq!(
"retain_last_valid"
.parse::<PolicyValidationFailureMode>()
.unwrap(),
PolicyValidationFailureMode::RetainLastValid
);
assert!("keep_old".parse::<PolicyValidationFailureMode>().is_err());
}

#[test]
fn compute_driver_name_normalization_accepts_builtin_and_custom_names() {
assert_eq!(normalize_compute_driver_name(" VM ").unwrap(), "vm");
Expand Down
37 changes: 37 additions & 0 deletions crates/openshell-core/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,8 @@ pub struct SettingsPollResult {
pub supervisor_middleware_services: Vec<crate::proto::SupervisorMiddlewareService>,
/// Workspace the sandbox belongs to.
pub workspace: String,
/// Gateway-configured posture for rejected policy generations.
pub policy_validation_failure_mode: crate::PolicyValidationFailureMode,
}

fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult {
Expand All @@ -795,6 +797,41 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin
provider_env_revision: inner.provider_env_revision,
supervisor_middleware_services: inner.supervisor_middleware_services,
workspace: inner.workspace,
policy_validation_failure_mode: inner
.policy_validation_failure_mode
.parse()
.unwrap_or_default(),
}
}

#[cfg(test)]
mod settings_poll_tests {
use super::settings_poll_result;
use crate::PolicyValidationFailureMode;
use crate::proto::GetSandboxConfigResponse;

#[test]
fn validation_failure_mode_round_trips_from_gateway_config() {
let result = settings_poll_result(GetSandboxConfigResponse {
policy_validation_failure_mode: "retain_last_valid".to_string(),
..Default::default()
});
assert_eq!(
result.policy_validation_failure_mode,
PolicyValidationFailureMode::RetainLastValid
);
}

#[test]
fn unknown_validation_failure_mode_fails_closed() {
let result = settings_poll_result(GetSandboxConfigResponse {
policy_validation_failure_mode: "future_mode".to_string(),
..Default::default()
});
assert_eq!(
result.policy_validation_failure_mode,
PolicyValidationFailureMode::FailClosed
);
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ pub use config::{
ComputeDriverKind, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride,
GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy,
GatewayInterceptorPhaseConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig,
MtlsAuthConfig, OidcConfig, TlsConfig,
MtlsAuthConfig, OidcConfig, PolicyValidationFailureMode, TlsConfig,
};
pub use error::{ComputeDriverError, Error, Result};
pub use metadata::{
Expand Down
Loading
Loading