Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6ef9490
feat(middleware): add in-process supervisor middleware
pimlock Jun 26, 2026
e4e6f8f
fix(supervisor-middleware): harden middleware relay handling
pimlock Jun 26, 2026
2765055
fix(supervisor-middleware): default stored policy rule fields
pimlock Jun 26, 2026
e487f2c
fix(supervisor-middleware): resolve rebase policy conflicts
pimlock Jun 26, 2026
9f892ef
feat(supervisor-middleware): implement phase one runtime
pimlock Jun 29, 2026
da486b2
fix(supervisor-middleware): harden selection and buffering
pimlock Jun 29, 2026
bb08a33
feat(supervisor-middleware): support external services
pimlock Jun 29, 2026
762887a
refactor(supervisor-middleware): clarify service contract
pimlock Jun 29, 2026
31662d8
docs(supervisor-middleware): refine preview warning
pimlock Jun 30, 2026
2304e2f
docs(extensibility): add supervisor middleware guide
pimlock Jun 30, 2026
97bef95
fix(server): remove stale middleware import
pimlock Jun 30, 2026
ab2daba
fix(network): remove needless test struct updates
pimlock Jun 30, 2026
a820dd2
fix(middleware): avoid enabling core telemetry
pimlock Jun 30, 2026
49424a0
refactor(supervisor-middleware): simplify service endpoints
pimlock Jun 30, 2026
3e69c5f
fix(supervisor-middleware): keep sandbox startup resilient to middlew…
pimlock Jul 1, 2026
9bdc7f7
fix(supervisor-middleware): ignore unresolved bindings in chain body …
pimlock Jul 1, 2026
9d4b631
fix(supervisor-middleware): harden policy enforcement
pimlock Jul 2, 2026
2b7cf4e
feat(ocsf): enrich middleware shorthand logs
pimlock Jul 2, 2026
0ef948b
fix(policy): initialize network middleware test fixtures
pimlock Jul 2, 2026
400ca0f
fix(policy): preserve immutable validation precedence
pimlock Jul 2, 2026
1f6aec1
feat(ocsf): log middleware denial reasons
pimlock Jul 3, 2026
6fdc5e3
refactor(ocsf): decouple shorthand from middleware
pimlock Jul 3, 2026
d27085c
refactor(supervisor-middleware): clarify runtime integration
pimlock Jul 3, 2026
d2a9791
refactor(middleware): move shared contracts to core
pimlock Jul 3, 2026
64439ea
refactor(policy): extract middleware serialization
pimlock Jul 3, 2026
3b58759
refactor(middleware): move host matching to core
pimlock Jul 3, 2026
3b6fc10
feat(middleware): align operations and ordering
pimlock Jul 3, 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
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ serde_yml = "0.0.12"
toml = "0.8"
apollo-parser = "0.8.5"
tower-mcp-types = "0.12.0"
regex = "1"

# HTTP client
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] }
Expand Down
8 changes: 8 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ config path. A gateway-global policy can override sandbox-scoped policy. The
sandbox supervisor polls for config revisions and hot-reloads dynamic policy
when the policy engine accepts the update.

External supervisor middleware registration is operator-owned gateway
configuration. At startup the gateway connects to each service, validates its
described bindings and operator body limit, and rejects duplicate binding IDs.
Before persisting a policy, the gateway asks each selected implementation to
validate its config. The effective sandbox config contains only the registered
services required by that policy; supervisors invoke those services directly on
the request path.

Provider credential expiry is enforced during gateway-to-sandbox credential
resolution and again by the sandbox placeholder resolver. This keeps expired
credentials from resolving even when a running sandbox still has retained
Expand Down
17 changes: 17 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ matchers; generic JSON-RPC rules match only the method.
JSON-RPC responses and server-to-client MCP messages on response or SSE streams
are relayed but are not currently parsed for policy enforcement.

For admitted HTTP requests, the proxy can run an ordered supervisor middleware
chain before credential injection. Host selectors choose the chain independently
of the network rule that admitted the request. Policy entries use integer order
values with stable name tie-breaking, and the gRPC contract represents operations
and phases as enums. Built-ins run in-process;
operator-registered services are called directly from the supervisor
over the common middleware gRPC contract. The gateway validates external
service capabilities and policy-owned config before delivery. Supervisors keep
the last-known-good service registry when a live config reload fails. Built-in
middleware identifiers, host-selector matching, and pure config validation live
in `openshell-core` so policy admission does not depend on the supervisor
runtime implementation. The policy and runtime also share the core
JSON/protobuf adapter for middleware configuration, keeping serialization
consistent across that boundary.

`https://inference.local` is special. It bypasses OPA network policy and is
handled by the inference interception path:

Expand Down Expand Up @@ -176,6 +191,8 @@ quickly.
- If gateway config polling fails, the sandbox keeps its last-known-good policy.
- If a live policy update is invalid, the supervisor rejects it and keeps the
current policy.
- If an operator-run middleware call fails, the selected config's `on_error`
behavior decides whether to deny the request or continue without that stage.
- Existing raw byte streams are connection scoped. Dynamic policy changes apply
to new connections or the next parsed HTTP request where the proxy can safely
re-evaluate.
Expand Down
36 changes: 3 additions & 33 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1648,39 +1648,9 @@ fn parse_driver_config_json(value: &str) -> Result<prost_types::Struct> {
));
};

Ok(prost_types::Struct {
fields: fields
.into_iter()
.map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value)))
.collect::<Result<_>>()?,
})
}

fn json_to_protobuf_value(value: serde_json::Value) -> Result<prost_types::Value> {
use prost_types::{ListValue, Struct, Value, value::Kind};

let kind = match value {
serde_json::Value::Null => Kind::NullValue(0),
serde_json::Value::Bool(value) => Kind::BoolValue(value),
serde_json::Value::Number(value) => Kind::NumberValue(value.as_f64().ok_or_else(|| {
miette!("--driver-config-json contains a number that cannot be represented")
})?),
serde_json::Value::String(value) => Kind::StringValue(value),
serde_json::Value::Array(values) => Kind::ListValue(ListValue {
values: values
.into_iter()
.map(json_to_protobuf_value)
.collect::<Result<_>>()?,
}),
serde_json::Value::Object(fields) => Kind::StructValue(Struct {
fields: fields
.into_iter()
.map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value)))
.collect::<Result<_>>()?,
}),
};

Ok(Value { kind: Some(kind) })
openshell_core::proto_struct::json_object_to_struct(fields)
.into_diagnostic()
.wrap_err("--driver-config-json contains a value that cannot be represented")
}

fn validate_cpu_quantity(value: &str) -> Result<String> {
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ license.workspace = true
repository.workspace = true

[dependencies]
glob = { workspace = true }
prost = { workspace = true }
prost-types = { workspace = true }
tonic = { workspace = true, features = ["channel", "tls-native-roots"] }
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,17 @@ router agree on provider defaults. Profiles define:

Do not duplicate provider-specific inference behavior in callers. Add shared
behavior here, then consume it from the gateway, sandbox, and router.

## Middleware Contracts

Built-in supervisor middleware identifiers, host-selector matching, and pure
configuration validation live in `openshell_core::middleware`. Policy admission
and the supervisor runtime consume the same contract without introducing a
dependency from the policy crate to the supervisor implementation.

## Protobuf Struct Conversion

Use `openshell_core::proto_struct` when crossing between `serde_json` values and
`prost_types::{Struct, Value}`. Both conversion directions live in this module;
JSON-to-protobuf conversion is fallible so callers cannot silently replace an
unrepresentable number.
41 changes: 30 additions & 11 deletions crates/openshell-core/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};

use crate::proto::{
DenialSummary, GetDraftPolicyRequest, GetInferenceBundleRequest, GetInferenceBundleResponse,
GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, IssueSandboxTokenRequest,
NetworkActivitySummary, PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest,
ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest,
SubmitPolicyAnalysisResponse, UpdateConfigRequest, inference_client::InferenceClient,
open_shell_client::OpenShellClient,
GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest,
IssueSandboxTokenRequest, NetworkActivitySummary, PolicyChunk, PolicySource, PolicyStatus,
RefreshSandboxTokenRequest, ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy,
SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UpdateConfigRequest,
inference_client::InferenceClient, open_shell_client::OpenShellClient,
};
use crate::sandbox_env;
use miette::{IntoDiagnostic, Result, WrapErr};
Expand Down Expand Up @@ -573,19 +573,36 @@ pub async fn fetch_policy(endpoint: &str, sandbox_id: &str) -> Result<Option<Pro
fetch_policy_with_client(&mut client, sandbox_id).await
}

/// Fetch sandbox policy using an existing client connection.
async fn fetch_policy_with_client(
/// Fetch the complete effective sandbox configuration, including external
/// middleware registrations required by the policy.
pub async fn fetch_sandbox_config(
endpoint: &str,
sandbox_id: &str,
) -> Result<GetSandboxConfigResponse> {
debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Connecting to OpenShell server");
let mut client = connect(endpoint).await?;
fetch_sandbox_config_with_client(&mut client, sandbox_id).await
}

async fn fetch_sandbox_config_with_client(
client: &mut OpenShellClient<AuthedChannel>,
sandbox_id: &str,
) -> Result<Option<ProtoSandboxPolicy>> {
let response = client
) -> Result<GetSandboxConfigResponse> {
client
.get_sandbox_config(GetSandboxConfigRequest {
sandbox_id: sandbox_id.to_string(),
})
.await
.into_diagnostic()?;
.map(tonic::Response::into_inner)
.into_diagnostic()
}

let inner = response.into_inner();
/// Fetch sandbox policy using an existing client connection.
async fn fetch_policy_with_client(
client: &mut OpenShellClient<AuthedChannel>,
sandbox_id: &str,
) -> Result<Option<ProtoSandboxPolicy>> {
let inner = fetch_sandbox_config_with_client(client, sandbox_id).await?;

// version 0 with no policy means the sandbox was created without one.
if inner.version == 0 && inner.policy.is_none() {
Expand Down Expand Up @@ -711,6 +728,7 @@ pub struct SettingsPollResult {
/// When `policy_source` is `Global`, the version of the global policy revision.
pub global_policy_version: u32,
pub provider_env_revision: u64,
pub supervisor_middleware_services: Vec<crate::proto::SupervisorMiddlewareService>,
}

pub struct ProviderEnvironmentResult {
Expand Down Expand Up @@ -755,6 +773,7 @@ impl CachedOpenShellClient {
settings: inner.settings,
global_policy_version: inner.global_policy_version,
provider_env_revision: inner.provider_env_revision,
supervisor_middleware_services: inner.supervisor_middleware_services,
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub mod grpc_client;
pub mod image;
pub mod inference;
pub mod metadata;
pub mod middleware;
pub mod net;
pub mod paths;
pub mod policy;
Expand Down
108 changes: 108 additions & 0 deletions crates/openshell-core/src/middleware.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Shared supervisor middleware identifiers and policy validation contracts.

use miette::{Result, miette};

/// Binding identifier for the built-in secret redaction middleware.
pub const BUILTIN_SECRETS: &str = "openshell/secrets";

/// Match a middleware host selector pattern using the runtime's glob semantics.
///
/// Matching is case-insensitive. Invalid or empty patterns return an error
/// instead of silently becoming a non-match.
pub fn host_matches(pattern: &str, host: &str) -> std::result::Result<bool, String> {
if pattern.is_empty() {
return Err("host pattern must not be empty".to_string());
}
if pattern.chars().any(char::is_whitespace) {
return Err("host pattern must not contain whitespace".to_string());
}

let pattern = glob::Pattern::new(&pattern.to_ascii_lowercase())
.map_err(|error| format!("invalid host pattern: {error}"))?;
Ok(pattern.matches(&host.to_ascii_lowercase()))
}

/// Validate policy-owned configuration for a built-in middleware.
pub fn validate_builtin_config(implementation: &str, config: &prost_types::Struct) -> Result<()> {
match implementation {
BUILTIN_SECRETS => validate_secrets_config(config),
other => Err(miette!(
"middleware implementation '{other}' is not available in phase 1"
)),
}
}

fn validate_secrets_config(config: &prost_types::Struct) -> Result<()> {
let mode = config
.fields
.get("secrets")
.and_then(|value| match value.kind.as_ref() {
Some(prost_types::value::Kind::StringValue(value)) => Some(value.as_str()),
_ => None,
})
.unwrap_or("redact");
if mode != "redact" {
return Err(miette!(
"{BUILTIN_SECRETS} only supports config.secrets: redact in phase 1"
));
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn host_matching_is_case_insensitive() {
assert!(host_matches("*.Example.COM", "API.example.com").unwrap());
assert!(!host_matches("*.example.com", "example.com").unwrap());
assert!(host_matches("*", "deep.api.example.com").unwrap());
}

#[test]
fn host_matching_rejects_invalid_patterns() {
assert!(host_matches("", "api.example.com").is_err());
assert!(host_matches("api .example.com", "api.example.com").is_err());
assert!(host_matches("api[.example.com", "api.example.com").is_err());
}

#[test]
fn secrets_config_defaults_to_redact() {
validate_builtin_config(BUILTIN_SECRETS, &prost_types::Struct::default()).unwrap();
}

#[test]
fn secrets_config_rejects_unsupported_mode() {
let config = prost_types::Struct {
fields: std::iter::once((
"secrets".to_string(),
prost_types::Value {
kind: Some(prost_types::value::Kind::StringValue("allow".into())),
},
))
.collect(),
};

let error = validate_builtin_config(BUILTIN_SECRETS, &config).unwrap_err();
assert!(
error
.to_string()
.contains("only supports config.secrets: redact")
);
}

#[test]
fn rejects_unknown_builtin() {
let error = validate_builtin_config("openshell/unknown", &prost_types::Struct::default())
.unwrap_err();
assert!(
error
.to_string()
.contains("implementation 'openshell/unknown' is not available")
);
}
}
14 changes: 14 additions & 0 deletions crates/openshell-core/src/proto/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,22 @@ pub mod inference {
}
}

#[allow(
clippy::all,
clippy::pedantic,
clippy::nursery,
unused_qualifications,
rust_2018_idioms
)]
pub mod middleware {
pub mod v1 {
include!(concat!(env!("OUT_DIR"), "/openshell.middleware.v1.rs"));
}
}

pub use datamodel::v1::*;
pub use inference::v1::*;
pub use middleware::v1::*;
pub use openshell::*;
pub use sandbox::v1::*;
pub use test::ObjectForTest;
Loading
Loading