From 2ecfd9cf0a05bb38ab6a2464365672abebd12aac Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Fri, 7 Aug 2026 16:11:43 -0700 Subject: [PATCH 1/2] feat(supervisor): prototype Pi conversation middleware Signed-off-by: Matthew Grossman --- Cargo.lock | 24 + .../Cargo.toml | 33 + .../README.md | 75 ++ .../src/lib.rs | 703 ++++++++++++++++++ .../src/main.rs | 33 + crates/openshell-sandbox/Cargo.toml | 6 + crates/openshell-sandbox/src/agent_bridge.rs | 250 +++++++ crates/openshell-sandbox/src/lib.rs | 73 ++ .../src/lib.rs | 13 +- .../src/regex.rs | 1 + .../src/lib.rs | 246 +++++- .../src/remote.rs | 12 +- .../src/l7/relay.rs | 26 + .../openshell-supervisor-network/src/opa.rs | 2 +- docs/extensibility/supervisor-middleware.mdx | 6 +- docs/reference/gateway-config.mdx | 4 +- .../pi-conversation-middleware/Dockerfile | 26 + examples/pi-conversation-middleware/README.md | 39 + .../pi-conversation-middleware/models.json | 26 + .../pi-extension.ts | 192 +++++ .../pi-conversation-middleware/policy.yaml | 31 + proto/supervisor_middleware.proto | 71 +- 22 files changed, 1869 insertions(+), 23 deletions(-) create mode 100644 crates/openshell-pi-conversation-middleware/Cargo.toml create mode 100644 crates/openshell-pi-conversation-middleware/README.md create mode 100644 crates/openshell-pi-conversation-middleware/src/lib.rs create mode 100644 crates/openshell-pi-conversation-middleware/src/main.rs create mode 100644 crates/openshell-sandbox/src/agent_bridge.rs create mode 100644 examples/pi-conversation-middleware/Dockerfile create mode 100644 examples/pi-conversation-middleware/README.md create mode 100644 examples/pi-conversation-middleware/models.json create mode 100644 examples/pi-conversation-middleware/pi-extension.ts create mode 100644 examples/pi-conversation-middleware/policy.yaml diff --git a/Cargo.lock b/Cargo.lock index acf5fff2c7..e87c24eb58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3958,6 +3958,26 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "openshell-pi-conversation-middleware" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "clap", + "miette", + "openshell-core", + "prost-types", + "ring", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tokio-stream", + "tonic", + "tracing", + "tracing-subscriber", +] + [[package]] name = "openshell-policy" version = "0.0.0" @@ -4019,12 +4039,14 @@ dependencies = [ name = "openshell-sandbox" version = "0.0.0" dependencies = [ + "axum", "clap", "futures", "miette", "nix 0.29.0", "openshell-core", "openshell-ocsf", + "openshell-pi-conversation-middleware", "openshell-policy", "openshell-supervisor-middleware", "openshell-supervisor-middleware-builtins", @@ -4032,6 +4054,7 @@ dependencies = [ "openshell-supervisor-process", "prost", "prost-types", + "reqwest 0.12.28", "rustls 0.23.38", "serde", "serde_json", @@ -4043,6 +4066,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/crates/openshell-pi-conversation-middleware/Cargo.toml b/crates/openshell-pi-conversation-middleware/Cargo.toml new file mode 100644 index 0000000000..8d887c0154 --- /dev/null +++ b/crates/openshell-pi-conversation-middleware/Cargo.toml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-pi-conversation-middleware" +description = "Standalone reference gRPC middleware for the Pi conversation prototype" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } + +base64 = { workspace = true } +clap = { workspace = true } +miette = { workspace = true } +prost-types = { workspace = true } +ring = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true, features = ["server"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +tokio-stream = { workspace = true, features = ["net"] } + +[lints] +workspace = true diff --git a/crates/openshell-pi-conversation-middleware/README.md b/crates/openshell-pi-conversation-middleware/README.md new file mode 100644 index 0000000000..601d9fbe73 --- /dev/null +++ b/crates/openshell-pi-conversation-middleware/README.md @@ -0,0 +1,75 @@ +# Pi conversation middleware prototype + +This crate is a standalone operator gRPC middleware server for the narrow Pi +prototype in `examples/pi-conversation-middleware`. + +It implements both `EvaluateAgentConversation` and `EvaluateHttpRequest`. The +agent operation replaces every exact, case-sensitive `sandbox` substring with +`REDACTED` and signs the resulting model/message array. The HTTP operation +denies requests with missing, invalid, expired, or mismatched attestations and +removes the internal attestation header from allowed requests. + +Run the service on an address reachable from the gateway and sandbox +supervisors: + +```shell +cargo run -p openshell-pi-conversation-middleware -- --listen 0.0.0.0:50061 +``` + +Register it in the gateway TOML before starting the gateway: + +```toml +[[openshell.supervisor.middleware]] +name = "pi-conversation-prototype" +grpc_endpoint = "http://host.openshell.internal:50061" +max_body_bytes = 262144 +timeout = "5s" +``` + +The same registered service must be selected as fail-closed network middleware +for the protected provider host. The supervisor prototype bridge is enabled by +setting these variables in the sandbox supervisor environment: + +```shell +OPENSHELL_PI_CONVERSATION_MIDDLEWARE=pi-conversation-prototype +OPENSHELL_PI_CONVERSATION_PROVIDER_HOST=api.openai.com +``` + +The bridge reuses the selected network middleware's validated `config` for hook +evaluation, so signing and egress verification use the same policy revision. + +When enabled, the supervisor injects the stable +`OPENSHELL_PI_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation` +address into the workload environment. Load +`examples/pi-conversation-middleware/pi-extension.ts` as a Pi extension. + +## Prototype limitations + +- Only OpenAI Chat Completions requests whose messages contain exactly string + `role` and `content` fields are supported. Images, tool calls, tool results, + multipart content, and other provider message shapes fail closed. Other + top-level request options are forwarded but are not attested. +- The Pi adapter requires a non-reasoning `openai-completions` model so Pi emits + the effective system prompt with the `system` role used by this prototype. + It checks the serialized provider messages against the signed context and + fails before dispatch if Pi changes their roles or string content. +- The extension persists sanitized user text through `input` and sanitized + plain-text assistant output through `message_end`. Pi rebuilds and sanitizes + the system prompt on each turn. The prototype has not been validated against + every Pi compaction, retry, steering, or session-fork path. +- `context` replacement is ephemeral in Pi. The persistent hooks are therefore + part of the prototype, while egress verification remains the final security + boundary. +- The attestation uses a reserved HTTP header. The supervisor strips it before + forwarding, but the transport has not been integrated into a native Pi or + provider API. +- Only the combined supervisor topology is implemented. Kubernetes sidecar + topology fails startup when this bridge is enabled. +- The deterministic Ed25519 seed is public and forgeable. It exists only to + make tests reproducible. Production must use operator-controlled secret + storage, separated signing/verifying material, key rotation, and key IDs. +- Attestations are stateless and short-lived. There is no transcript hash chain, + server-side conversation state, or replay cache. +- The bridge captures one policy configuration and middleware registry at + sandbox startup. Hot policy or registration changes can make later requests + fail closed until the sandbox restarts. diff --git a/crates/openshell-pi-conversation-middleware/src/lib.rs b/crates/openshell-pi-conversation-middleware/src/lib.rs new file mode 100644 index 0000000000..be52b1cada --- /dev/null +++ b/crates/openshell-pi-conversation-middleware/src/lib.rs @@ -0,0 +1,703 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Standalone reference middleware for the Pi conversation prototype. + +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; +use openshell_core::proto::{ + AgentConversationEvaluation, AgentConversationResult, ConversationMessageV1, + ConversationRequestV1, Decision, Finding, HeaderMutation, HttpRequestEvaluation, + HttpRequestResult, MiddlewareBinding, MiddlewareManifest, RemoveHeader, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, + ValidateConfigResponse, header_mutation, +}; +use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tonic::{Request, Response, Status}; + +pub const SERVICE_NAME: &str = "operator/pi-conversation-prototype"; +pub const ATTESTATION_HEADER: &str = "x-openshell-agent-attestation"; + +const CLAIMS_VERSION: &str = "pi-conversation-attestation/v1"; +const CANONICALIZATION_VERSION: &str = "openai-chat-completions-conversation/v1"; +const ATTESTATION_FORMAT: &str = "v1"; +const KEY_ID: &str = "prototype-ed25519-2026-01"; +const DEFAULT_TTL_SECONDS: u64 = 60; +const MAX_BODY_BYTES: u64 = 256 * 1024; +const MAX_ATTESTATION_BYTES: usize = 8 * 1024; + +// Public deterministic prototype key. Production must use operator-controlled +// secret storage, separate signing/verifying material, and key-id rotation. +const PROTOTYPE_SIGNING_SEED: [u8; 32] = [ + 0x70, 0x69, 0x2d, 0x63, 0x6f, 0x6e, 0x76, 0x65, 0x72, 0x73, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x74, 0x79, 0x70, 0x65, 0x2d, 0x6b, 0x65, 0x79, 0x2d, 0x76, 0x31, +]; + +#[derive(Clone)] +pub struct PrototypeService { + now: Arc u64 + Send + Sync>, +} + +impl fmt::Debug for PrototypeService { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("PrototypeService").finish() + } +} + +impl Default for PrototypeService { + fn default() -> Self { + Self::new() + } +} + +impl PrototypeService { + pub fn new() -> Self { + Self { + now: Arc::new(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) + }), + } + } + + #[cfg(test)] + fn with_clock(now: impl Fn() -> u64 + Send + Sync + 'static) -> Self { + Self { now: Arc::new(now) } + } + + fn now(&self) -> u64 { + (self.now)() + } +} + +#[derive(Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct PrototypeConfig { + policy_revision: String, +} + +impl Default for PrototypeConfig { + fn default() -> Self { + Self { + policy_revision: "prototype-v1".into(), + } + } +} + +impl PrototypeConfig { + fn from_struct(config: Option<&prost_types::Struct>) -> Result { + let value = config.map_or_else( + || serde_json::json!({}), + openshell_core::proto_struct::struct_to_json_value, + ); + let parsed: Self = serde_json::from_value(value).map_err(|error| error.to_string())?; + if parsed.policy_revision.is_empty() { + return Err("policy_revision cannot be empty".into()); + } + Ok(parsed) + } +} + +#[derive(Debug, Serialize)] +struct CanonicalConversation<'a> { + canonicalization_version: &'static str, + model: &'a str, + messages: Vec>, +} + +#[derive(Debug, Serialize)] +struct CanonicalMessage<'a> { + role: &'a str, + content: &'a str, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AttestationClaims { + attestation_version: String, + canonicalization_version: String, + middleware_binding: String, + key_id: String, + sandbox_id: String, + #[serde(skip_serializing_if = "String::is_empty")] + session_id: String, + #[serde(skip_serializing_if = "String::is_empty")] + turn_id: String, + scheme: String, + host: String, + port: u32, + path: String, + model: String, + policy_revision: String, + conversation_hash: String, + issued_at: u64, + expires_at: u64, +} + +#[derive(Debug, Deserialize)] +struct ChatCompletionsBody { + model: String, + messages: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ChatMessageBody { + role: String, + content: String, +} + +fn validate_conversation(conversation: &ConversationRequestV1) -> Result<(), &'static str> { + if conversation.model.is_empty() || conversation.messages.is_empty() { + return Err("model and messages must be non-empty"); + } + if conversation.messages.iter().any(|message| { + !matches!( + message.role.as_str(), + "system" | "developer" | "user" | "assistant" + ) + }) { + return Err("unsupported message role"); + } + Ok(()) +} + +fn conversation_hash(conversation: &ConversationRequestV1) -> Result { + validate_conversation(conversation).map_err(str::to_owned)?; + let canonical = CanonicalConversation { + canonicalization_version: CANONICALIZATION_VERSION, + model: &conversation.model, + messages: conversation + .messages + .iter() + .map(|message| CanonicalMessage { + role: &message.role, + content: &message.content, + }) + .collect(), + }; + let encoded = serde_json::to_vec(&canonical).map_err(|error| error.to_string())?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(encoded))) +} + +fn sign_claims(claims: &AttestationClaims) -> Result, String> { + let payload = serde_json::to_vec(claims).map_err(|error| error.to_string())?; + let key_pair = Ed25519KeyPair::from_seed_unchecked(&PROTOTYPE_SIGNING_SEED) + .map_err(|_| "invalid prototype signing key".to_string())?; + let signature = key_pair.sign(&payload); + let base64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + Ok(format!( + "{ATTESTATION_FORMAT}.{}.{}", + base64.encode(payload), + base64.encode(signature.as_ref()) + ) + .into_bytes()) +} + +fn verify_attestation(value: &[u8]) -> Result { + if value.len() > MAX_ATTESTATION_BYTES { + return Err("attestation exceeds capacity".into()); + } + let value = std::str::from_utf8(value).map_err(|_| "attestation is not UTF-8")?; + let mut parts = value.split('.'); + let (Some(format), Some(payload), Some(signature), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err("malformed attestation".into()); + }; + if format != ATTESTATION_FORMAT { + return Err("unsupported attestation format".into()); + } + let base64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let payload = base64 + .decode(payload) + .map_err(|_| "invalid payload encoding")?; + let signature = base64 + .decode(signature) + .map_err(|_| "invalid signature encoding")?; + let key_pair = Ed25519KeyPair::from_seed_unchecked(&PROTOTYPE_SIGNING_SEED) + .map_err(|_| "invalid prototype signing key")?; + UnparsedPublicKey::new(&ED25519, key_pair.public_key().as_ref()) + .verify(&payload, &signature) + .map_err(|_| "signature verification failed")?; + serde_json::from_slice(&payload).map_err(|error| error.to_string()) +} + +fn deny_http(reason_code: &str) -> HttpRequestResult { + HttpRequestResult { + decision: Decision::Deny as i32, + reason_code: reason_code.into(), + findings: vec![Finding { + r#type: "pi_conversation.attestation_denied".into(), + label: "Pi conversation attestation denied".into(), + count: 1, + confidence: "high".into(), + severity: "high".into(), + }], + ..Default::default() + } +} + +fn deny_agent(reason_code: &str) -> AgentConversationResult { + AgentConversationResult { + decision: Decision::Deny as i32, + reason_code: reason_code.into(), + ..Default::default() + } +} + +#[tonic::async_trait] +impl SupervisorMiddleware for PrototypeService { + async fn describe( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(MiddlewareManifest { + name: SERVICE_NAME.into(), + service_version: env!("CARGO_PKG_VERSION").into(), + bindings: ["input", "before_agent_start", "message_end", "context"] + .into_iter() + .map(|hook| MiddlewareBinding { + operation: SupervisorMiddlewareOperation::AgentConversation as i32, + phase: SupervisorMiddlewarePhase::AgentContext as i32, + max_body_bytes: MAX_BODY_BYTES, + harness: "pi".into(), + hook: hook.into(), + schema_version: "v1".into(), + ..Default::default() + }) + .chain(std::iter::once(MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_body_bytes: MAX_BODY_BYTES, + ..Default::default() + })) + .collect(), + })) + } + + async fn validate_config( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + Ok(Response::new( + match PrototypeConfig::from_struct(request.config.as_ref()) { + Ok(_) => ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + Err(reason) => ValidateConfigResponse { + valid: false, + reason, + }, + }, + )) + } + + async fn evaluate_agent_conversation( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let config = PrototypeConfig::from_struct(request.config.as_ref()) + .map_err(Status::invalid_argument)?; + let Some(context) = request.context else { + return Ok(Response::new(deny_agent("missing_request_context"))); + }; + let Some(target) = request.target else { + return Ok(Response::new(deny_agent("missing_conversation_target"))); + }; + let Some(mut conversation) = request.conversation else { + return Ok(Response::new(deny_agent("missing_conversation"))); + }; + if context.sandbox_id.is_empty() + || request.phase != SupervisorMiddlewarePhase::AgentContext as i32 + || target.harness != "pi" + || !matches!( + target.hook.as_str(), + "input" | "before_agent_start" | "message_end" | "context" + ) + || target.schema_version != "v1" + || target.scheme != "https" + || target.host.is_empty() + || target.port != 443 + || target.path != "/v1/chat/completions" + || validate_conversation(&conversation).is_err() + { + return Ok(Response::new(deny_agent("unsupported_conversation_shape"))); + } + + let mut replacements = 0u32; + for message in &mut conversation.messages { + let count = message.content.matches("sandbox").count(); + replacements = replacements.saturating_add(u32::try_from(count).unwrap_or(u32::MAX)); + message.content = message.content.replace("sandbox", "REDACTED"); + } + let issued_at = self.now(); + let claims = AttestationClaims { + attestation_version: CLAIMS_VERSION.into(), + canonicalization_version: CANONICALIZATION_VERSION.into(), + middleware_binding: request.middleware_name, + key_id: KEY_ID.into(), + sandbox_id: context.sandbox_id, + session_id: request.session_id, + turn_id: request.turn_id, + scheme: target.scheme, + host: target.host, + port: target.port, + path: target.path, + model: conversation.model.clone(), + policy_revision: config.policy_revision, + conversation_hash: conversation_hash(&conversation).map_err(Status::internal)?, + issued_at, + expires_at: issued_at.saturating_add(DEFAULT_TTL_SECONDS), + }; + let attestation = sign_claims(&claims).map_err(Status::internal)?; + let findings = (replacements > 0) + .then(|| Finding { + r#type: "pi_conversation.sandbox_replaced".into(), + label: "Prototype word replacement".into(), + count: replacements, + confidence: "high".into(), + severity: "low".into(), + }) + .into_iter() + .collect(); + Ok(Response::new(AgentConversationResult { + decision: Decision::Allow as i32, + conversation: Some(conversation), + has_conversation: true, + attestation, + findings, + metadata: HashMap::from([("replacement_count".into(), replacements.to_string())]), + ..Default::default() + })) + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let config = PrototypeConfig::from_struct(request.config.as_ref()) + .map_err(Status::invalid_argument)?; + let Some(context) = request.context.as_ref() else { + return Ok(Response::new(deny_http("missing_request_context"))); + }; + let Some(target) = request.target.as_ref() else { + return Ok(Response::new(deny_http("missing_request_target"))); + }; + if request.phase != SupervisorMiddlewarePhase::PreCredentials as i32 + || target.scheme != "https" + || target.host.is_empty() + || target.port != 443 + || target.method != "POST" + || target.path != "/v1/chat/completions" + || !target.query.is_empty() + { + return Ok(Response::new(deny_http("unsupported_request_target"))); + } + if request.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-encoding") + && !header.value.eq_ignore_ascii_case("identity") + }) { + return Ok(Response::new(deny_http("unsupported_content_encoding"))); + } + let attestations: Vec<&[u8]> = request + .headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case(ATTESTATION_HEADER)) + .map(|header| header.value.as_bytes()) + .collect(); + let [attestation] = attestations.as_slice() else { + return Ok(Response::new(deny_http(if attestations.is_empty() { + "missing_attestation" + } else { + "duplicate_attestation" + }))); + }; + let body: ChatCompletionsBody = match serde_json::from_slice(&request.body) { + Ok(body) => body, + Err(_) => return Ok(Response::new(deny_http("unsupported_request_shape"))), + }; + let conversation = ConversationRequestV1 { + model: body.model, + messages: body + .messages + .into_iter() + .map(|message| ConversationMessageV1 { + role: message.role, + content: message.content, + }) + .collect(), + }; + if validate_conversation(&conversation).is_err() { + return Ok(Response::new(deny_http("unsupported_request_shape"))); + } + let Ok(claims) = verify_attestation(attestation) else { + return Ok(Response::new(deny_http("invalid_attestation"))); + }; + let now = self.now(); + if claims.issued_at > now || claims.expires_at <= now { + return Ok(Response::new(deny_http("expired_attestation"))); + } + let hash = conversation_hash(&conversation).map_err(Status::internal)?; + if claims.attestation_version != CLAIMS_VERSION + || claims.canonicalization_version != CANONICALIZATION_VERSION + || claims.middleware_binding != request.middleware_name + || claims.key_id != KEY_ID + || claims.sandbox_id != context.sandbox_id + || claims.scheme != target.scheme + || claims.host != target.host + || claims.port != target.port + || claims.path != target.path + || claims.model != conversation.model + || claims.policy_revision != config.policy_revision + || claims.conversation_hash != hash + { + return Ok(Response::new(deny_http("attestation_mismatch"))); + } + Ok(Response::new(HttpRequestResult { + decision: Decision::Allow as i32, + body: request.body, + header_mutations: vec![HeaderMutation { + operation: Some(header_mutation::Operation::Remove(RemoveHeader { + name: ATTESTATION_HEADER.into(), + })), + }], + metadata: HashMap::from([("attestation_version".into(), ATTESTATION_FORMAT.into())]), + ..Default::default() + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; + use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddlewareServer; + use openshell_core::proto::{ + AgentConversationTarget, HttpHeader, HttpRequestTarget, RequestContext, + }; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::transport::Server; + + const NOW: u64 = 1_800_000_000; + const MIDDLEWARE_NAME: &str = "pi-prototype"; + + fn context() -> RequestContext { + RequestContext { + request_id: "request-1".into(), + sandbox_id: "sandbox-123".into(), + originating_process: None, + } + } + + fn agent_evaluation() -> AgentConversationEvaluation { + AgentConversationEvaluation { + phase: SupervisorMiddlewarePhase::AgentContext as i32, + context: Some(context()), + config: Some(prost_types::Struct::default()), + target: Some(AgentConversationTarget { + harness: "pi".into(), + harness_version: "prototype".into(), + hook: "context".into(), + schema_version: "v1".into(), + scheme: "https".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/chat/completions".into(), + }), + conversation: Some(ConversationRequestV1 { + model: "prototype-model".into(), + messages: vec![ + ConversationMessageV1 { + role: "system".into(), + content: "You are a sandbox assistant.".into(), + }, + ConversationMessageV1 { + role: "user".into(), + content: "Create a sandbox inside another sandbox.".into(), + }, + ], + }), + middleware_name: MIDDLEWARE_NAME.into(), + session_id: "session-1".into(), + turn_id: "turn-1".into(), + } + } + + fn http_evaluation(body: Vec, attestation: Option<&[u8]>) -> HttpRequestEvaluation { + let mut headers = vec![HttpHeader { + name: "content-type".into(), + value: "application/json".into(), + }]; + if let Some(attestation) = attestation { + headers.push(HttpHeader { + name: ATTESTATION_HEADER.into(), + value: String::from_utf8(attestation.to_vec()).unwrap(), + }); + } + HttpRequestEvaluation { + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + context: Some(context()), + config: Some(prost_types::Struct::default()), + target: Some(HttpRequestTarget { + scheme: "https".into(), + host: "api.openai.com".into(), + port: 443, + method: "POST".into(), + path: "/v1/chat/completions".into(), + query: String::new(), + }), + headers, + body, + middleware_name: MIDDLEWARE_NAME.into(), + } + } + + async fn grpc_client( + now: u64, + ) -> ( + SupervisorMiddlewareClient, + tokio::task::JoinHandle<()>, + ) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + Server::builder() + .add_service(SupervisorMiddlewareServer::new( + PrototypeService::with_clock(move || now), + )) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let client = SupervisorMiddlewareClient::connect(format!("http://{address}")) + .await + .unwrap(); + (client, task) + } + + #[tokio::test] + async fn grpc_server_mutates_signs_and_verifies_fail_closed() { + let (mut client, server) = grpc_client(NOW).await; + let inspected = client + .evaluate_agent_conversation(agent_evaluation()) + .await + .unwrap() + .into_inner(); + assert_eq!(inspected.decision, Decision::Allow as i32); + let replacement = inspected.conversation.as_ref().unwrap(); + assert_eq!( + replacement.messages[0].content, + "You are a REDACTED assistant." + ); + assert_eq!( + replacement.messages[1].content, + "Create a REDACTED inside another REDACTED." + ); + + let matching_body = serde_json::to_vec(&serde_json::json!({ + "model": replacement.model, + "messages": replacement.messages.iter().map(|message| serde_json::json!({ + "role": message.role, + "content": message.content, + })).collect::>(), + "stream": true, + })) + .unwrap(); + let allowed = client + .evaluate_http_request(http_evaluation( + matching_body.clone(), + Some(&inspected.attestation), + )) + .await + .unwrap() + .into_inner(); + assert_eq!(allowed.decision, Decision::Allow as i32); + assert!(matches!( + allowed.header_mutations[0].operation, + Some(header_mutation::Operation::Remove(ref remove)) + if remove.name == ATTESTATION_HEADER + )); + + let original_body = serde_json::to_vec(&serde_json::json!({ + "model": "prototype-model", + "messages": [ + {"role": "system", "content": "You are a sandbox assistant."}, + {"role": "user", "content": "Create a sandbox inside another sandbox."} + ], + "stream": true, + })) + .unwrap(); + let original = client + .evaluate_http_request(http_evaluation(original_body, Some(&inspected.attestation))) + .await + .unwrap() + .into_inner(); + assert_eq!(original.decision, Decision::Deny as i32); + assert_eq!(original.reason_code, "attestation_mismatch"); + + let mut tampered: serde_json::Value = serde_json::from_slice(&matching_body).unwrap(); + tampered["messages"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "role": "user", + "content": "extra", + })); + let tampered = client + .evaluate_http_request(http_evaluation( + serde_json::to_vec(&tampered).unwrap(), + Some(&inspected.attestation), + )) + .await + .unwrap() + .into_inner(); + assert_eq!(tampered.decision, Decision::Deny as i32); + assert_eq!(tampered.reason_code, "attestation_mismatch"); + + let invalid = client + .evaluate_http_request(http_evaluation( + matching_body.clone(), + Some(b"v1.invalid.invalid"), + )) + .await + .unwrap() + .into_inner(); + assert_eq!(invalid.decision, Decision::Deny as i32); + assert_eq!(invalid.reason_code, "invalid_attestation"); + + let (mut expired_client, expired_server) = grpc_client(NOW + DEFAULT_TTL_SECONDS).await; + let expired = expired_client + .evaluate_http_request(http_evaluation( + matching_body.clone(), + Some(&inspected.attestation), + )) + .await + .unwrap() + .into_inner(); + assert_eq!(expired.decision, Decision::Deny as i32); + assert_eq!(expired.reason_code, "expired_attestation"); + expired_server.abort(); + + let missing = client + .evaluate_http_request(http_evaluation(matching_body, None)) + .await + .unwrap() + .into_inner(); + assert_eq!(missing.decision, Decision::Deny as i32); + assert_eq!(missing.reason_code, "missing_attestation"); + server.abort(); + } +} diff --git a/crates/openshell-pi-conversation-middleware/src/main.rs b/crates/openshell-pi-conversation-middleware/src/main.rs new file mode 100644 index 0000000000..2c6bdc54cc --- /dev/null +++ b/crates/openshell-pi-conversation-middleware/src/main.rs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; + +use clap::Parser; +use miette::{IntoDiagnostic, Result}; +use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddlewareServer; +use openshell_pi_conversation_middleware::PrototypeService; +use tonic::transport::Server; + +#[derive(Debug, Parser)] +#[command(about = "Run the Pi conversation reference gRPC middleware")] +struct Args { + #[arg(long, default_value = "127.0.0.1:50061")] + listen: SocketAddr, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + let args = Args::parse(); + tracing::info!(listen = %args.listen, "Pi conversation middleware listening"); + Server::builder() + .add_service(SupervisorMiddlewareServer::new(PrototypeService::new())) + .serve(args.listen) + .await + .into_diagnostic() +} diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 94cbb4ad51..918832d5ca 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -26,6 +26,9 @@ openshell-supervisor-process = { path = "../openshell-supervisor-process" } # Async runtime tokio = { workspace = true } +# Stable workload-facing hook bridge +axum = { workspace = true } + # gRPC (tonic::Status downcast in error mapping) tonic = { workspace = true, features = ["channel", "tls-native-roots"] } prost-types = { workspace = true } @@ -46,6 +49,7 @@ rustls = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } prost = { workspace = true } +uuid = { workspace = true } # Logging tracing = { workspace = true } @@ -63,10 +67,12 @@ telemetry = ["openshell-core/telemetry"] bundled-ca-roots = ["openshell-supervisor-network/bundled-ca-roots"] [dev-dependencies] +openshell-pi-conversation-middleware = { path = "../openshell-pi-conversation-middleware" } tempfile = "3" temp-env = "0.3" tokio-tungstenite = { workspace = true } futures = { workspace = true } +reqwest = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-sandbox/src/agent_bridge.rs b/crates/openshell-sandbox/src/agent_bridge.rs new file mode 100644 index 0000000000..1d0b76637d --- /dev/null +++ b/crates/openshell-sandbox/src/agent_bridge.rs @@ -0,0 +1,250 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Stable workload-facing bridge for agent conversation middleware. + +use std::sync::Arc; + +use axum::extract::{DefaultBodyLimit, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use openshell_core::proto::{ + AgentConversationEvaluation, AgentConversationTarget, ConversationMessageV1, + ConversationRequestV1, Decision, RequestContext, SupervisorMiddlewarePhase, +}; +use serde::{Deserialize, Serialize}; +use tokio::net::TcpListener; +use tracing::{debug, warn}; + +pub const BRIDGE_ADDR: &str = "127.0.0.1:8193"; +pub const BRIDGE_PATH: &str = "/v1/agent/conversation"; +pub const BRIDGE_URL: &str = "http://127.0.0.1:8193/v1/agent/conversation"; +pub const BRIDGE_URL_ENV: &str = "OPENSHELL_PI_CONVERSATION_URL"; +pub const MIDDLEWARE_ENV: &str = "OPENSHELL_PI_CONVERSATION_MIDDLEWARE"; +pub const PROVIDER_HOST_ENV: &str = "OPENSHELL_PI_CONVERSATION_PROVIDER_HOST"; + +const MAX_BRIDGE_BODY_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone)] +pub struct BridgeConfig { + pub middleware_name: String, + pub sandbox_id: String, + pub provider_host: String, + pub middleware_config: prost_types::Struct, +} + +#[derive(Clone)] +struct BridgeState { + runner: openshell_supervisor_middleware::ChainRunner, + config: Arc, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BridgeRequest { + hook: String, + harness_version: String, + #[serde(default)] + session_id: String, + #[serde(default)] + turn_id: String, + model: String, + messages: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct BridgeMessage { + role: String, + content: String, +} + +#[derive(Debug, Serialize)] +struct BridgeResponse { + model: String, + messages: Vec, + attestation: String, +} + +#[derive(Debug, Serialize)] +struct BridgeErrorResponse { + error: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + reason_code: Option, +} + +pub fn spawn( + listener: TcpListener, + runner: openshell_supervisor_middleware::ChainRunner, + config: BridgeConfig, +) -> tokio::task::JoinHandle<()> { + let state = BridgeState { + runner, + config: Arc::new(config), + }; + tokio::spawn(async move { + let app = Router::new() + .route(BRIDGE_PATH, post(evaluate)) + .layer(DefaultBodyLimit::max(MAX_BRIDGE_BODY_BYTES)) + .with_state(state); + if let Err(error) = axum::serve(listener, app).await { + warn!(%error, "Pi conversation bridge stopped"); + } + }) +} + +async fn evaluate(State(state): State, Json(input): Json) -> Response { + if !matches!( + input.hook.as_str(), + "input" | "before_agent_start" | "message_end" | "context" + ) { + return ( + StatusCode::BAD_REQUEST, + Json(BridgeErrorResponse { + error: "unsupported_hook", + reason_code: None, + }), + ) + .into_response(); + } + let evaluation = AgentConversationEvaluation { + phase: SupervisorMiddlewarePhase::AgentContext as i32, + context: Some(RequestContext { + request_id: uuid::Uuid::new_v4().to_string(), + sandbox_id: state.config.sandbox_id.clone(), + originating_process: None, + }), + config: Some(state.config.middleware_config.clone()), + target: Some(AgentConversationTarget { + harness: "pi".into(), + harness_version: input.harness_version, + hook: input.hook, + schema_version: "v1".into(), + scheme: "https".into(), + host: state.config.provider_host.clone(), + port: 443, + path: "/v1/chat/completions".into(), + }), + conversation: Some(ConversationRequestV1 { + model: input.model, + messages: input + .messages + .into_iter() + .map(|message| ConversationMessageV1 { + role: message.role, + content: message.content, + }) + .collect(), + }), + middleware_name: state.config.middleware_name.clone(), + session_id: input.session_id, + turn_id: input.turn_id, + }; + let result = match state.runner.evaluate_agent_conversation(evaluation).await { + Ok(result) => result, + Err(error) => { + debug!(error = %error, "Pi conversation middleware evaluation failed"); + return ( + StatusCode::BAD_GATEWAY, + Json(BridgeErrorResponse { + error: "middleware_unavailable", + reason_code: None, + }), + ) + .into_response(); + } + }; + if Decision::try_from(result.decision).unwrap_or(Decision::Unspecified) != Decision::Allow { + return ( + StatusCode::FORBIDDEN, + Json(BridgeErrorResponse { + error: "conversation_denied", + reason_code: (!result.reason_code.is_empty()).then_some(result.reason_code), + }), + ) + .into_response(); + } + let Some(conversation) = result.conversation else { + return ( + StatusCode::BAD_GATEWAY, + Json(BridgeErrorResponse { + error: "invalid_middleware_response", + reason_code: None, + }), + ) + .into_response(); + }; + let Ok(attestation) = String::from_utf8(result.attestation) else { + return ( + StatusCode::BAD_GATEWAY, + Json(BridgeErrorResponse { + error: "invalid_middleware_response", + reason_code: None, + }), + ) + .into_response(); + }; + Json(BridgeResponse { + model: conversation.model, + messages: conversation + .messages + .into_iter() + .map(|message| BridgeMessage { + role: message.role, + content: message.content, + }) + .collect(), + attestation, + }) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_pi_conversation_middleware::PrototypeService; + + #[tokio::test] + async fn local_http_bridge_proxies_to_agent_grpc_operation() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = spawn( + listener, + openshell_supervisor_middleware::ChainRunner::new(Arc::new(PrototypeService::new())), + BridgeConfig { + middleware_name: openshell_pi_conversation_middleware::SERVICE_NAME.into(), + sandbox_id: "trusted-sandbox".into(), + provider_host: "api.openai.com".into(), + middleware_config: prost_types::Struct::default(), + }, + ); + let response = reqwest::Client::new() + .post(format!("http://{address}{BRIDGE_PATH}")) + .json(&serde_json::json!({ + "hook": "context", + "harness_version": "test", + "session_id": "session-1", + "turn_id": "turn-1", + "model": "prototype-model", + "messages": [ + {"role": "system", "content": "sandbox assistant"}, + {"role": "user", "content": "sandbox in a sandbox"} + ] + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response: serde_json::Value = response.json().await.unwrap(); + assert_eq!(response["messages"][0]["content"], "REDACTED assistant"); + assert_eq!(response["messages"][1]["content"], "REDACTED in a REDACTED"); + assert!( + response["attestation"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); + task.abort(); + } +} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 956fed927c..894f803eed 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -6,6 +6,7 @@ //! This crate provides process sandboxing and monitoring capabilities. mod activity_aggregator; +mod agent_bridge; mod denial_aggregator; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod google_cloud_metadata; @@ -398,6 +399,78 @@ pub async fn run_sandbox( None }; + // Prototype Pi hook bridge. It binds inside the workload network namespace + // and proxies typed hook requests through the supervisor-owned middleware + // registry. The operator service must also be selected as network + // middleware so it is present in the synchronized registry. + if let Ok(middleware_name) = std::env::var(agent_bridge::MIDDLEWARE_ENV) { + if middleware_name.trim().is_empty() { + return Err(miette::miette!( + "{} cannot be empty", + agent_bridge::MIDDLEWARE_ENV + )); + } + if sidecar_network_enforcement { + return Err(miette::miette!( + "Pi conversation bridge is not yet supported in sidecar topology" + )); + } + let engine = opa_engine.as_ref().ok_or_else(|| { + miette::miette!("Pi conversation bridge requires a middleware registry") + })?; + let proto = retained_proto.as_ref().ok_or_else(|| { + miette::miette!("Pi conversation bridge requires gateway policy data") + })?; + let mut matching_configs = proto + .network_middlewares + .values() + .filter(|config| config.middleware == middleware_name); + let middleware_config = matching_configs + .next() + .ok_or_else(|| { + miette::miette!( + "Pi conversation middleware '{middleware_name}' must be selected by network policy" + ) + })? + .config + .clone() + .unwrap_or_default(); + if matching_configs.next().is_some() { + return Err(miette::miette!( + "Pi conversation prototype requires exactly one network policy config for '{middleware_name}'" + )); + } + #[cfg(target_os = "linux")] + let listener = netns + .as_ref() + .ok_or_else(|| miette::miette!("Pi conversation bridge requires network enforcement"))? + .bind_tcp_in_netns(agent_bridge::BRIDGE_ADDR) + .await?; + #[cfg(not(target_os = "linux"))] + let listener = tokio::net::TcpListener::bind(agent_bridge::BRIDGE_ADDR) + .await + .into_diagnostic()?; + agent_bridge::spawn( + listener, + engine.middleware_runner()?, + agent_bridge::BridgeConfig { + middleware_name, + sandbox_id: sandbox_id.clone().unwrap_or_default(), + provider_host: std::env::var(agent_bridge::PROVIDER_HOST_ENV) + .unwrap_or_else(|_| "api.openai.com".into()), + middleware_config, + }, + ); + provider_env.insert( + agent_bridge::BRIDGE_URL_ENV.into(), + agent_bridge::BRIDGE_URL.into(), + ); + info!( + url = agent_bridge::BRIDGE_URL, + "Pi conversation bridge ready" + ); + } + #[cfg(target_os = "linux")] let sidecar_control_server = if network_enabled && sidecar_network_enforcement { if !matches!(policy.network.mode, NetworkMode::Proxy) { diff --git a/crates/openshell-supervisor-middleware-builtins/src/lib.rs b/crates/openshell-supervisor-middleware-builtins/src/lib.rs index e23a228f05..3394553b6b 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/lib.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/lib.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use miette::{Result, miette}; use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; use openshell_core::proto::{ - HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, - ValidateConfigResponse, + AgentConversationEvaluation, AgentConversationResult, HttpRequestEvaluation, HttpRequestResult, + MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, }; use tonic::{Request, Response, Status}; @@ -86,6 +86,15 @@ impl SupervisorMiddleware for BuiltinMiddlewareService { .map(Response::new) .map_err(|error| Status::invalid_argument(error.to_string())) } + + async fn evaluate_agent_conversation( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "regex middleware does not inspect agent conversations", + )) + } } #[cfg(test)] diff --git a/crates/openshell-supervisor-middleware-builtins/src/regex.rs b/crates/openshell-supervisor-middleware-builtins/src/regex.rs index 34e727430a..037fad8b47 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/regex.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/regex.rs @@ -52,6 +52,7 @@ pub fn describe() -> MiddlewareBinding { phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_body_bytes: MAX_BODY_BYTES, timeout: String::new(), + ..Default::default() } } diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index fe0f15f0a6..1c225021a0 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -18,7 +18,8 @@ use prost::Message; use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; use openshell_core::proto::{ - Decision, Finding, HeaderMutation, HttpHeader, HttpRequestEvaluation, HttpRequestTarget, + AgentConversationEvaluation, AgentConversationResult, AgentConversationTarget, Decision, + Finding, HeaderMutation, HttpHeader, HttpRequestEvaluation, HttpRequestTarget, MiddlewareBinding, MiddlewareManifest, NetworkMiddlewareConfig, RequestContext, SandboxPolicy, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, SupervisorMiddlewareService, ValidateConfigRequest, @@ -55,6 +56,8 @@ pub const MAX_MIDDLEWARE_FINDING_BYTES: usize = 4 * 1024; pub const MAX_MIDDLEWARE_METADATA_ENTRIES: usize = 64; /// Largest combined metadata key/value payload accepted from one middleware stage. pub const MAX_MIDDLEWARE_METADATA_BYTES: usize = 32 * 1024; +/// Largest opaque agent attestation accepted from one middleware result. +pub const MAX_AGENT_ATTESTATION_BYTES: usize = 8 * 1024; const MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES: usize = 64 * 1024; const MAX_MIDDLEWARE_PROTOBUF_OVERHEAD_BYTES: usize = 64 * 1024; @@ -83,6 +86,9 @@ pub const MIDDLEWARE_GRPC_MESSAGE_BYTES: usize = const HTTP_REQUEST_OPERATION: SupervisorMiddlewareOperation = SupervisorMiddlewareOperation::HttpRequest; const PRE_CREDENTIALS_PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials; +const AGENT_CONVERSATION_OPERATION: SupervisorMiddlewareOperation = + SupervisorMiddlewareOperation::AgentConversation; +const AGENT_CONTEXT_PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::AgentContext; const MAX_STABLE_IDENTIFIER_BYTES: usize = 128; const EXTERNAL_FINDING_LABEL: &str = "External middleware finding"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -501,18 +507,43 @@ fn validate_manifest_bindings( let mut described_pairs = HashSet::with_capacity(manifest.bindings.len()); for binding in &manifest.bindings { - if binding.operation != HTTP_REQUEST_OPERATION as i32 - || binding.phase != PRE_CREDENTIALS_PHASE as i32 + let is_http = binding.operation == HTTP_REQUEST_OPERATION as i32 + && binding.phase == PRE_CREDENTIALS_PHASE as i32; + let is_agent = binding.operation == AGENT_CONVERSATION_OPERATION as i32 + && binding.phase == AGENT_CONTEXT_PHASE as i32; + if !is_http && !is_agent { + return Err(miette!("{source} describes an unsupported operation/phase")); + } + if is_http + && (!binding.harness.is_empty() + || !binding.hook.is_empty() + || !binding.schema_version.is_empty()) { return Err(miette!( - "{source} must support HTTP_REQUEST/PRE_CREDENTIALS" + "{source} HTTP_REQUEST binding cannot declare agent hook fields" )); } - if !described_pairs.insert((binding.operation, binding.phase)) { + if is_agent + && (binding.harness != "pi" + || !matches!( + binding.hook.as_str(), + "input" | "before_agent_start" | "message_end" | "context" + ) + || binding.schema_version != "v1") + { return Err(miette!( - "{source} describes more than one binding for HTTP_REQUEST/PRE_CREDENTIALS" + "{source} describes an unsupported Pi agent hook binding" )); } + if !described_pairs.insert(( + binding.operation, + binding.phase, + binding.harness.as_str(), + binding.hook.as_str(), + binding.schema_version.as_str(), + )) { + return Err(miette!("{source} describes a duplicate middleware binding")); + } let advertised = validate_body_limit(source, binding)?; if !binding.timeout.trim().is_empty() { parse_middleware_timeout(&binding.timeout) @@ -912,6 +943,149 @@ impl ChainRunner { }) } + fn agent_context_binding<'a>( + manifest: &'a MiddlewareManifest, + target: &AgentConversationTarget, + ) -> Option<&'a MiddlewareBinding> { + manifest.bindings.iter().find(|binding| { + binding.operation == AGENT_CONVERSATION_OPERATION as i32 + && binding.phase == AGENT_CONTEXT_PHASE as i32 + && binding.harness == target.harness + && binding.hook == target.hook + && binding.schema_version == target.schema_version + }) + } + + /// Evaluate one complete Pi conversation through the named registered + /// middleware. The caller supplies supervisor-stamped identity and target + /// fields; failures are returned so the local bridge can fail closed. + pub async fn evaluate_agent_conversation( + &self, + mut evaluation: AgentConversationEvaluation, + ) -> Result { + let middleware_name = evaluation.middleware_name.clone(); + if middleware_name.is_empty() { + return Err(miette!( + "agent conversation middleware name cannot be empty" + )); + } + let target = evaluation + .target + .as_ref() + .ok_or_else(|| miette!("agent conversation target is required"))?; + let manifests = self.manifests().await?; + let Some((state, binding)) = manifests.iter().find_map(|(state, manifest)| { + (Self::attachment_name(state, manifest) == middleware_name) + .then(|| Self::agent_context_binding(manifest, target)) + .flatten() + .map(|binding| (state, binding)) + }) else { + return Err(miette!( + "middleware '{middleware_name}' has no matching agent hook binding" + )); + }; + if evaluation.phase != AGENT_CONTEXT_PHASE as i32 { + return Err(miette!("agent conversation phase must be AGENT_CONTEXT")); + } + if target.harness != binding.harness + || target.hook != binding.hook + || target.schema_version != binding.schema_version + { + return Err(miette!("agent conversation target does not match binding")); + } + if evaluation + .context + .as_ref() + .is_none_or(|context| context.sandbox_id.is_empty()) + { + return Err(miette!("trusted sandbox context is required")); + } + let conversation = evaluation + .conversation + .as_ref() + .ok_or_else(|| miette!("agent conversation payload is required"))?; + let max_body_bytes = validate_body_limit("agent conversation binding", binding)?; + if conversation.encoded_len() > max_body_bytes { + return Err(miette!( + "agent conversation payload exceeds binding capacity" + )); + } + if evaluation + .config + .as_ref() + .is_some_and(|config| config.encoded_len() > MAX_MIDDLEWARE_CONFIG_BYTES) + { + return Err(miette!( + "agent conversation config exceeds platform capacity" + )); + } + evaluation.middleware_name = middleware_name.clone(); + let mut result = call_with_timeout( + state.timeout_for_binding(binding)?, + "EvaluateAgentConversation", + state + .service + .evaluate_agent_conversation(Request::new(evaluation)), + ) + .await + .map(tonic::Response::into_inner) + .map_err(|error| { + miette!( + "middleware EvaluateAgentConversation failed: {}", + safe_reason(&error.to_string()) + ) + })?; + let decision = Decision::try_from(result.decision).unwrap_or(Decision::Unspecified); + if decision == Decision::Unspecified { + return Err(miette!( + "agent conversation response has unspecified decision" + )); + } + if result.reason_code.len() > MAX_MIDDLEWARE_REASON_CODE_BYTES + || (!result.reason_code.is_empty() && !is_stable_reason_code(&result.reason_code)) + { + return Err(miette!( + "agent conversation response reason code is invalid" + )); + } + if result.attestation.len() > MAX_AGENT_ATTESTATION_BYTES { + return Err(miette!( + "agent conversation attestation exceeds platform capacity" + )); + } + if decision == Decision::Allow { + if !result.has_conversation || result.conversation.is_none() { + return Err(miette!( + "allowed agent response must replace the conversation" + )); + } + if result.attestation.is_empty() { + return Err(miette!( + "allowed agent response must include an attestation" + )); + } + if result + .conversation + .as_ref() + .is_some_and(|conversation| conversation.encoded_len() > max_body_bytes) + { + return Err(miette!( + "agent replacement conversation exceeds binding capacity" + )); + } + } + if state.diagnostic_policy == MiddlewareDiagnosticPolicy::Normalize { + result.reason.clear(); + result.metadata.clear(); + for finding in &mut result.findings { + finding.r#type = format!("{middleware_name}.finding"); + finding.label = EXTERNAL_FINDING_LABEL.into(); + finding.confidence.clear(); + } + } + Ok(result) + } + pub async fn describe_chain(&self, entries: &[ChainEntry]) -> Result> { ensure_chain_capacity(entries.len())?; let manifests = self.manifests().await?; @@ -1688,6 +1862,7 @@ mod tests { phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_body_bytes: self.max_body_bytes, timeout: String::new(), + ..Default::default() }], })) } @@ -1716,6 +1891,15 @@ mod tests { > { Ok(tonic::Response::new(self.result.clone())) } + + async fn evaluate_agent_conversation( + &self, + _request: Request, + ) -> std::result::Result, tonic::Status> { + Err(tonic::Status::unimplemented( + "test service has no agent conversation binding", + )) + } } struct SlowService { @@ -1737,6 +1921,7 @@ mod tests { phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_body_bytes: 4096, timeout: self.binding_timeout.clone(), + ..Default::default() }], })) } @@ -1767,6 +1952,15 @@ mod tests { tokio::time::sleep(self.delay).await; Ok(tonic::Response::new(allow_result())) } + + async fn evaluate_agent_conversation( + &self, + _request: Request, + ) -> std::result::Result, tonic::Status> { + Err(tonic::Status::unimplemented( + "test service has no agent conversation binding", + )) + } } /// A middleware attached twice for exercising per-stage validation. The @@ -1790,6 +1984,7 @@ mod tests { phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_body_bytes: 256 * 1024, timeout: String::new(), + ..Default::default() }], })) } @@ -1834,6 +2029,15 @@ mod tests { } Ok(tonic::Response::new(result)) } + + async fn evaluate_agent_conversation( + &self, + _request: Request, + ) -> std::result::Result, tonic::Status> { + Err(tonic::Status::unimplemented( + "test service has no agent conversation binding", + )) + } } #[tokio::test] @@ -2054,6 +2258,7 @@ mod tests { phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_body_bytes: 4096, timeout: String::new(), + ..Default::default() }], })) } @@ -2090,6 +2295,15 @@ mod tests { .push(request.into_inner()); Ok(tonic::Response::new(allow_result())) } + + async fn evaluate_agent_conversation( + &self, + _request: Request, + ) -> std::result::Result, tonic::Status> { + Err(tonic::Status::unimplemented( + "test service has no agent conversation binding", + )) + } } /// Three-stage service used to verify that each stage observes the header @@ -2113,6 +2327,7 @@ mod tests { phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_body_bytes: 4096, timeout: String::new(), + ..Default::default() }], })) } @@ -2162,6 +2377,15 @@ mod tests { } Ok(tonic::Response::new(result)) } + + async fn evaluate_agent_conversation( + &self, + _request: Request, + ) -> std::result::Result, tonic::Status> { + Err(tonic::Status::unimplemented( + "test service has no agent conversation binding", + )) + } } #[tokio::test] @@ -2528,6 +2752,7 @@ mod tests { phase: PRE_CREDENTIALS_PHASE as i32, max_body_bytes: 4096, timeout: String::new(), + ..Default::default() }], }; let error = validate_external_manifest(®istration, &manifest, 4097) @@ -2554,6 +2779,7 @@ mod tests { phase: PRE_CREDENTIALS_PHASE as i32, max_body_bytes: u64::MAX, timeout: String::new(), + ..Default::default() }], }; let error = validate_external_manifest(®istration, &manifest, 4096) @@ -2569,6 +2795,7 @@ mod tests { phase: PRE_CREDENTIALS_PHASE as i32, max_body_bytes: 4096, timeout: String::new(), + ..Default::default() }; let manifest = MiddlewareManifest { name: "example/service".into(), @@ -2578,11 +2805,7 @@ mod tests { let error = validate_external_manifest(®istration, &manifest, 4096) .expect_err("one service cannot advertise two bindings for the same pair"); - assert!( - error - .to_string() - .contains("more than one binding for HTTP_REQUEST/PRE_CREDENTIALS") - ); + assert!(error.to_string().contains("duplicate middleware binding")); } #[test] @@ -2650,6 +2873,7 @@ mod tests { phase: PRE_CREDENTIALS_PHASE as i32, max_body_bytes: 4096, timeout: timeout.into(), + ..Default::default() }], }; let error = validate_external_manifest(®istration, &manifest, 4096) diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index 30ea5a74bb..4ee99a3fdd 100644 --- a/crates/openshell-supervisor-middleware/src/remote.rs +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -7,8 +7,8 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; use openshell_core::proto::{ - HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, - ValidateConfigResponse, + AgentConversationEvaluation, AgentConversationResult, HttpRequestEvaluation, HttpRequestResult, + MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, }; use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; use tonic::{Request, Response, Status}; @@ -91,4 +91,12 @@ impl SupervisorMiddleware for RemoteMiddlewareService { let mut client = self.client.clone(); client.evaluate_http_request(request).await } + + async fn evaluate_agent_conversation( + &self, + request: Request, + ) -> std::result::Result, Status> { + let mut client = self.client.clone(); + client.evaluate_agent_conversation(request).await + } } diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index fa2eab4ad7..241a6d6f10 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -3224,6 +3224,7 @@ network_policies: as i32, max_body_bytes: 8192, timeout: String::new(), + ..Default::default() }], }, )) @@ -3260,6 +3261,18 @@ network_policies: }, )) } + + async fn evaluate_agent_conversation( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Err(tonic::Status::unimplemented( + "test service has no agent conversation binding", + )) + } } fn jsonrpc_transforming_relay_parts( @@ -3712,6 +3725,7 @@ network_policies: phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_body_bytes: self.max_body_bytes, timeout: String::new(), + ..Default::default() }], })) } @@ -3749,6 +3763,18 @@ network_policies: } Ok(tonic::Response::new(result)) } + + async fn evaluate_agent_conversation( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Err(tonic::Status::unimplemented( + "test service has no agent conversation binding", + )) + } } #[tokio::test] diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index d6af02a9f0..6ef9fcfda4 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -720,7 +720,7 @@ impl OpaEngine { Ok(()) } - pub(crate) fn middleware_runner(&self) -> Result { + pub fn middleware_runner(&self) -> Result { self.middleware_runner .read() .map(|runner| runner.clone()) diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index f3bdcac9bb..f67cd69256 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -37,7 +37,7 @@ Middleware receives the request before credential injection. Operator-run servic `openshell/regex` is an example built-in middleware. It replaces only simple, self-contained token patterns in UTF-8 request bodies; the initial pattern recognizes `sk-` tokens. It does not infer values from keyword assignments such as JSON `password` fields. This best-effort text transformation is not parser-aware and does not guarantee that it will detect or fully remove sensitive values. Its `config` accepts one field, `mode: redact`, which is also the default when the field is omitted. Unknown config fields and non-string values are rejected at policy validation. Custom expressions are not configurable yet. -Operator-run services expose bindings for supported operation and phase pairs. A binding is identified by its operation and phase; V1 supports only `HttpRequest/pre_credentials`. Policies attach the complete middleware by its operator-owned gateway registration name. +Operator-run services expose bindings for supported operations and phases. HTTP bindings are identified by operation and phase. The experimental agent-conversation binding also identifies a harness, hook, and schema version. Policies attach the complete middleware by its operator-owned gateway registration name. ## Register a Middleware Service @@ -58,9 +58,9 @@ timeout = "500ms" | `max_body_bytes` | Operator limit applied to every binding exposed by the service, up to the 4 MiB platform maximum. | | `timeout` | Optional service-wide RPC timeout using an integer with an `ms` or `s` suffix. Defaults to `500ms`; valid values range from `10ms` through `30s`. | -Each binding returned by `Describe` may advertise a shorter `timeout` using the same syntax and bounds. The operator-configured service timeout is a ceiling: OpenShell uses the smaller of the binding and service values. An omitted binding timeout inherits the service setting, and an omitted service setting uses the 500 ms platform default. OpenShell rejects an invalid timeout before accepting the manifest. The service timeout applies to `Describe`, while the effective binding timeout applies to `ValidateConfig` and `EvaluateHttpRequest`. +Each binding returned by `Describe` may advertise a shorter `timeout` using the same syntax and bounds. The operator-configured service timeout is a ceiling: OpenShell uses the smaller of the binding and service values. An omitted binding timeout inherits the service setting, and an omitted service setting uses the 500 ms platform default. OpenShell rejects an invalid timeout before accepting the manifest. The service timeout applies to `Describe`, while the effective binding timeout applies to `ValidateConfig`, `EvaluateHttpRequest`, and experimental `EvaluateAgentConversation` calls. -The gateway connects to every registered service and verifies its capabilities before accepting traffic. Gateway startup fails when a service is unavailable, reports an invalid capability, or exposes more than one binding for the same operation and phase. The manifest `name` is diagnostic metadata and does not need to match the operator registration name. Operator-run registration names cannot claim the reserved `openshell/` namespace. +The gateway connects to every registered service and verifies its capabilities before accepting traffic. Gateway startup fails when a service is unavailable, reports an invalid capability, or exposes a duplicate binding. The manifest `name` is diagnostic metadata and does not need to match the operator registration name. Operator-run registration names cannot claim the reserved `openshell/` namespace. Registration is static. Restart the gateway after adding, removing, or changing a service. See [Gateway Configuration](/reference/gateway-config#supervisor-middleware-services) for the complete gateway TOML context. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 2cd10b8a0b..d1769f4d38 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -260,13 +260,13 @@ max_body_bytes = 262144 timeout = "500ms" ``` -Each service implements the supervisor middleware gRPC contract and exposes bindings through `Describe`. Policies reference the operator-owned registration `name`, attaching the complete middleware and all of its bindings. Bindings are identified by operation and phase. A manifest may expose at most one binding for each operation and phase pair; V1 supports only `HttpRequest/pre_credentials`, so a service currently exposes one binding. Registration names must be unique, and operator-run registrations cannot claim the reserved `openshell/` namespace. The service-reported manifest name is diagnostic metadata and does not need to match the registration name. +Each service implements the supervisor middleware gRPC contract and exposes bindings through `Describe`. Policies reference the operator-owned registration `name`, attaching the complete middleware and all of its bindings. HTTP bindings are identified by operation and phase. Experimental agent-conversation bindings additionally identify the harness, hook, and schema version. Duplicate bindings are rejected. Registration names must be unique, and operator-run registrations cannot claim the reserved `openshell/` namespace. The service-reported manifest name is diagnostic metadata and does not need to match the registration name. The gateway connects to every registered service and validates `Describe` before it starts. The service must therefore be running before the gateway. Policy creation and full policy updates call `ValidateConfig`; an unavailable service or invalid middleware configuration rejects the policy before persistence. `max_body_bytes` is the operator limit for every binding exposed by the service. It must be greater than zero, no larger than each binding's advertised limit, and no larger than the 4 MiB platform maximum. OpenShell rejects an oversized value instead of silently clamping it. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size body and its protobuf envelope fit on the transport. -`timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The effective timeout covers `ValidateConfig` and `EvaluateHttpRequest`; `Describe` uses the service timeout because binding metadata is not available yet. +`timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The effective timeout covers `ValidateConfig`, `EvaluateHttpRequest`, and experimental `EvaluateAgentConversation` calls; `Describe` uses the service timeout because binding metadata is not available yet. The service `grpc_endpoint` currently supports plaintext `http://` and TLS `https://` using the platform trust store. Custom trust roots, client authentication, health checks, and runtime registration are not currently supported. The endpoint must be reachable from both the gateway and sandbox supervisors; use `host.openshell.internal` or another shared address that can be resolved in both places. diff --git a/examples/pi-conversation-middleware/Dockerfile b/examples/pi-conversation-middleware/Dockerfile new file mode 100644 index 0000000000..27323c1bf5 --- /dev/null +++ b/examples/pi-conversation-middleware/Dockerfile @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM node:22-bookworm-slim + +USER root + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates iproute2 nftables \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g --ignore-scripts \ + @earendil-works/pi-coding-agent@0.84.1 + +RUN install -d -o node -g node \ + /workspace \ + /workspace/.pi/agent \ + /opt/pi-conversation + +COPY --chown=node:node models.json /workspace/.pi/agent/models.json +COPY --chown=node:node pi-extension.ts /opt/pi-conversation/pi-extension.ts + +WORKDIR /workspace +USER node + +CMD ["sleep", "infinity"] diff --git a/examples/pi-conversation-middleware/README.md b/examples/pi-conversation-middleware/README.md new file mode 100644 index 0000000000..d163810a6f --- /dev/null +++ b/examples/pi-conversation-middleware/README.md @@ -0,0 +1,39 @@ +# Pi conversation middleware prototype + +This example contains the Pi hook adapter and policy for the standalone +`openshell-pi-conversation-middleware` reference server. See the crate README +for startup and gateway registration details. + +The directory is also a complete local Docker build context. Its `Dockerfile` +installs Pi and copies `pi-extension.ts` plus `models.json` into the image. +OpenShell builds it automatically when creating the test sandbox: + +```shell +openshell sandbox create \ + --from examples/pi-conversation-middleware/Dockerfile \ + --name pi-redaction-demo +``` + +The `models.json` file contains the literal `$OPENAI_API_KEY` environment +reference, not a credential. Attach an OpenShell provider when creating the +sandbox so Pi receives an opaque credential placeholder at runtime. + +Load the extension from inside a Pi-capable sandbox: + +```shell +pi --no-tools --extension /path/to/pi-extension.ts +``` + +The extension calls only the stable supervisor URL supplied in +`OPENSHELL_PI_CONVERSATION_URL`; it does not know the operator gRPC endpoint. +The supervisor proxies each hook call through its registered middleware client, +stamps sandbox and provider identity, and returns the replacement conversation +plus opaque attestation. + +For the smallest proof, select an `openai-completions` model, disable Pi tools, +and use text-only messages. A prompt such as `describe a sandbox` is stored and +sent as `describe a REDACTED`. Any attempt to send the original body, alter the +signed message list, or omit the internal header is denied at egress. + +This example is deliberately not a general Pi integration. Review the +limitations in the crate README before using it. diff --git a/examples/pi-conversation-middleware/models.json b/examples/pi-conversation-middleware/models.json new file mode 100644 index 0000000000..bdc0a54c02 --- /dev/null +++ b/examples/pi-conversation-middleware/models.json @@ -0,0 +1,26 @@ +{ + "providers": { + "openai-chat-prototype": { + "baseUrl": "https://api.openai.com/v1", + "api": "openai-completions", + "apiKey": "$OPENAI_API_KEY", + "authHeader": true, + "models": [ + { + "id": "gpt-4o-mini", + "name": "GPT-4o mini (middleware prototype)", + "reasoning": false, + "input": ["text"], + "contextWindow": 128000, + "maxTokens": 4096, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + } + } + ] + } + } +} diff --git a/examples/pi-conversation-middleware/pi-extension.ts b/examples/pi-conversation-middleware/pi-extension.ts new file mode 100644 index 0000000000..a69e087c3d --- /dev/null +++ b/examples/pi-conversation-middleware/pi-extension.ts @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const ATTESTATION_HEADER = "x-openshell-agent-attestation"; +const HARNESS_VERSION = "prototype-v1"; +const REQUEST_TIMEOUT_MS = 5_000; + +function bridgeUrl() { + const value = process.env.OPENSHELL_PI_CONVERSATION_URL; + if (!value) { + throw new Error("OPENSHELL_PI_CONVERSATION_URL is not set"); + } + return value; +} + +function modelId(ctx) { + if (!ctx.model?.id) { + throw new Error("Pi conversation middleware requires a selected model"); + } + if (ctx.model.api !== "openai-completions" || ctx.model.reasoning) { + throw new Error( + "prototype requires a non-reasoning openai-completions model with a system role", + ); + } + return ctx.model.id; +} + +function textContent(message) { + if (typeof message.content === "string") { + return message.content; + } + if ( + Array.isArray(message.content) && + message.content.length > 0 && + message.content.every((part) => part?.type === "text" && typeof part.text === "string") + ) { + return message.content.map((part) => part.text).join(""); + } + throw new Error(`unsupported ${message.role} message content`); +} + +function conversationMessage(message) { + if (!["system", "developer", "user", "assistant"].includes(message?.role)) { + throw new Error(`unsupported Pi message role: ${message?.role ?? "missing"}`); + } + return { role: message.role, content: textContent(message) }; +} + +function replacePiMessage(original, replacement) { + if (original.role !== replacement.role) { + throw new Error("middleware changed a Pi message role"); + } + if (original.role === "user" || typeof original.content === "string") { + return { ...original, content: replacement.content }; + } + return { + ...original, + content: [{ type: "text", text: replacement.content }], + }; +} + +function providerMessages(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("unsupported OpenAI Chat Completions payload"); + } + if (typeof payload.model !== "string" || !Array.isArray(payload.messages)) { + throw new Error("payload must contain model and messages"); + } + return payload.messages.map((message) => { + if ( + !message || + typeof message !== "object" || + Array.isArray(message) || + typeof message.content !== "string" || + Object.keys(message).some((key) => key !== "role" && key !== "content") + ) { + throw new Error("prototype supports only role/content provider messages"); + } + return conversationMessage(message); + }); +} + +async function inspect(hook, ctx, turnId, model, messages) { + const response = await fetch(bridgeUrl(), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + hook, + harness_version: HARNESS_VERSION, + session_id: ctx.sessionManager.getSessionId(), + turn_id: String(turnId), + model, + messages, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`Pi conversation middleware denied ${hook} (${response.status})`); + } + const result = await response.json(); + if ( + result?.model !== model || + !Array.isArray(result.messages) || + result.messages.length !== messages.length || + typeof result.attestation !== "string" || + result.attestation.length === 0 + ) { + throw new Error(`invalid Pi conversation middleware response for ${hook}`); + } + return result; +} + +export default function piConversationMiddleware(pi) { + let turnId = 0; + let pendingApproval; + + // This request persists the sanitized user text in Pi's session record. + pi.on("input", async (event, ctx) => { + if (event.images?.length) { + throw new Error("Pi conversation prototype does not support image input"); + } + const result = await inspect("input", ctx, turnId, modelId(ctx), [ + { role: "user", content: event.text }, + ]); + return { action: "transform", text: result.messages[0].content }; + }); + + // Pi rebuilds the system prompt each turn, so replace that effective prompt + // before every agent run. + pi.on("before_agent_start", async (event, ctx) => { + const result = await inspect("before_agent_start", ctx, turnId, modelId(ctx), [ + { role: "system", content: event.systemPrompt }, + ]); + return { systemPrompt: result.messages[0].content }; + }); + + // Persist finalized plain-text user and assistant messages in their sanitized + // form. The user pass is idempotent with the earlier input transformation and + // also covers text introduced by prompt expansion. + pi.on("message_end", async (event, ctx) => { + if (event.message.role !== "user" && event.message.role !== "assistant") return; + const message = conversationMessage(event.message); + const result = await inspect("message_end", ctx, turnId, modelId(ctx), [message]); + return { message: replacePiMessage(event.message, result.messages[0]) }; + }); + + pi.on("turn_start", (event) => { + turnId = event.turnIndex; + pendingApproval = undefined; + }); + + // Inspect and apply the complete semantic conversation Pi will use for this + // model call. This transformation is intentionally limited to text-only + // user/assistant messages. + pi.on("context", async (event, ctx) => { + pendingApproval = undefined; + const system = { role: "system", content: ctx.getSystemPrompt() }; + const messages = event.messages.map(conversationMessage); + const result = await inspect("context", ctx, turnId, modelId(ctx), [system, ...messages]); + pendingApproval = { + model: result.model, + messages: result.messages, + attestation: result.attestation, + }; + return { + messages: event.messages.map((message, index) => + replacePiMessage(message, result.messages[index + 1]), + ), + }; + }); + + // Pi assembles headers before it invokes this payload hook. Confirm that its + // provider serialization exactly preserves the conversation signed by the + // preceding context hook; serialization drift fails before dispatch. + pi.on("before_provider_request", async (event, ctx) => { + const messages = providerMessages(event.payload); + if ( + !pendingApproval || + event.payload.model !== pendingApproval.model || + JSON.stringify(messages) !== JSON.stringify(pendingApproval.messages) + ) { + throw new Error("provider message serialization differs from signed Pi context"); + } + }); + + pi.on("before_provider_headers", (event) => { + if (!pendingApproval?.attestation) { + throw new Error("missing Pi conversation attestation"); + } + event.headers[ATTESTATION_HEADER] = pendingApproval.attestation; + }); +} diff --git a/examples/pi-conversation-middleware/policy.yaml b/examples/pi-conversation-middleware/policy.yaml new file mode 100644 index 0000000000..3f1277e896 --- /dev/null +++ b/examples/pi-conversation-middleware/policy.yaml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +network_middlewares: + pi-conversation: + name: Pi conversation attestation + middleware: pi-conversation-prototype + order: 10 + config: + policy_revision: prototype-v1 + on_error: fail_closed + endpoints: + include: + - api.openai.com + +network_policies: + openai-chat-completions: + name: OpenAI Chat Completions + endpoints: + - host: api.openai.com + port: 443 + protocol: rest + rules: + - allow: + method: POST + path: /v1/chat/completions + binaries: + - path: /usr/local/bin/node + - path: /usr/bin/node diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index dbde411c9f..19dfdf4b6f 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -20,6 +20,10 @@ service SupervisorMiddleware { // EvaluateHttpRequest returns an allow, deny, or mutation decision for one // buffered HTTP request. rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); + + // EvaluateAgentConversation inspects and optionally replaces one complete + // model-visible conversation before an agent harness invokes its provider. + rpc EvaluateAgentConversation(AgentConversationEvaluation) returns (AgentConversationResult); } // MiddlewareManifest describes one middleware service and the bindings it @@ -38,9 +42,9 @@ message MiddlewareManifest { // MiddlewareBinding declares one operation and phase supported by a service. message MiddlewareBinding { - // Supported operation. V1 supports HTTP_REQUEST. + // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. V1 supports PRE_CREDENTIALS. + // Supported evaluation phase. SupervisorMiddlewarePhase phase = 2; // Maximum request or replacement body this binding can process. uint64 max_body_bytes = 3; @@ -50,6 +54,15 @@ message MiddlewareBinding { // Values use an integer with an `ms` or `s` suffix and must be between // 10ms and 30s. string timeout = 4; + // Agent harness supported by an AGENT_CONVERSATION binding, initially "pi". + // Empty for HTTP_REQUEST bindings. + string harness = 5; + // Harness hook supported by an AGENT_CONVERSATION binding. Empty for + // HTTP_REQUEST bindings. + string hook = 6; + // Version of the hook payload schema, initially "v1". Empty for HTTP_REQUEST + // bindings. + string schema_version = 7; } // ValidateConfigRequest contains one policy configuration to validate. @@ -104,12 +117,14 @@ message HttpHeader { enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; + SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 2; } // Ordered phase within a supervisor operation. enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; + SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT = 2; } // RequestContext identifies the sandbox request being evaluated. @@ -148,6 +163,58 @@ message Process { repeated string ancestors = 3; } +// ConversationRequestV1 is the only model-visible conversation shape supported +// by the prototype. It represents a narrow OpenAI Chat Completions projection: +// ordered messages with string content and no tools or multipart values. +message ConversationRequestV1 { + string model = 1; + repeated ConversationMessageV1 messages = 2; +} + +message ConversationMessageV1 { + string role = 1; + string content = 2; +} + +// AgentConversationTarget identifies the harness hook and provider destination +// for which the replacement conversation will be attested. +message AgentConversationTarget { + string harness = 1; + string harness_version = 2; + string hook = 3; + string schema_version = 4; + string scheme = 5; + string host = 6; + uint32 port = 7; + string path = 8; +} + +// AgentConversationEvaluation is stamped by the supervisor bridge. Workload +// callers do not supply the trusted RequestContext or middleware selection. +message AgentConversationEvaluation { + SupervisorMiddlewarePhase phase = 1; + RequestContext context = 2; + google.protobuf.Struct config = 3; + AgentConversationTarget target = 4; + ConversationRequestV1 conversation = 5; + string middleware_name = 6; + string session_id = 7; + string turn_id = 8; +} + +// AgentConversationResult returns the complete replacement conversation and an +// opaque attestation that the harness attaches to its provider request. +message AgentConversationResult { + Decision decision = 1; + string reason = 2; + ConversationRequestV1 conversation = 3; + bool has_conversation = 4; + bytes attestation = 5; + repeated Finding findings = 6; + map metadata = 7; + string reason_code = 8; +} + // Decision controls whether OpenShell continues processing the request. enum Decision { // Invalid response value handled according to the policy failure mode. From c20a36a13582dd216c8542e9e41e15dbef64bf6b Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Fri, 7 Aug 2026 17:44:16 -0700 Subject: [PATCH 2/2] fix(supervisor): complete Pi conversation middleware demo Signed-off-by: Matthew Grossman --- architecture/pi-conversation-middleware.md | 574 ++++++++++++++++++ .../README.md | 29 +- .../src/lib.rs | 38 +- crates/openshell-sandbox/src/agent_bridge.rs | 2 - crates/openshell-sandbox/src/lib.rs | 135 ++-- .../src/lib.rs | 28 + examples/pi-conversation-middleware/README.md | 22 +- .../pi-conversation-middleware/TESTING.md | 366 +++++++++++ .../pi-conversation-middleware/models.json | 18 +- .../pi-extension.ts | 7 +- .../pi-conversation-middleware/policy.yaml | 22 +- tasks/scripts/stage-prebuilt-binaries.sh | 10 +- 12 files changed, 1166 insertions(+), 85 deletions(-) create mode 100644 architecture/pi-conversation-middleware.md create mode 100644 examples/pi-conversation-middleware/TESTING.md diff --git a/architecture/pi-conversation-middleware.md b/architecture/pi-conversation-middleware.md new file mode 100644 index 0000000000..de3d48e002 --- /dev/null +++ b/architecture/pi-conversation-middleware.md @@ -0,0 +1,574 @@ +# Pi Conversation Middleware and Signed Egress Attestation + +Status: Draft plan + +## TL;DR + +We want to man-in-the-middle inference requests made by Pi so an operator-owned +middleware can inspect, audit, and mutate the conversation before it reaches a +model provider. + +Mutating only the HTTP request at the network proxy is insufficient. Pi keeps a +local, stateful chat record, so a proxy-only mutation can make the conversation +seen by the model diverge from the conversation Pi records and uses on later +turns. Instead, a Pi extension will send hook data to a narrow supervisor-owned +local bridge. The bridge acts as an application-level proxy to an operator gRPC +middleware server. The server will inspect the complete model-visible +conversation, return a replacement conversation, and sign that replacement. + +Pi must not be able to send an inference request that bypasses this inspection. +The extension will attach the attestation to the outbound request, and mandatory +fail-closed egress middleware will verify that the actual model-visible request +matches the signed sanitized conversation before forwarding it. Missing, +invalid, expired, or mismatched attestations will be denied. + +The MVP evaluates and signs each complete request independently. It does not +maintain a transcript hash chain or server-side conversation state. + +## Goal + +Provide an end-to-end inspection and mutation path for Pi conversations with the +following property: + +> Every inference request allowed to leave the sandbox contains exactly the +> model-visible conversation that trusted middleware inspected and approved +> after applying any required mutations. + +The prototype policy is intentionally trivial: replace every exact, +case-sensitive occurrence of `sandbox` in supported message text with +`REDACTED`. Pi continues the turn using the replacement conversation, and the +model receives that same conversation. This silly mutation proves the transport +and state-alignment mechanism without turning the prototype into a content +classification project. + +## Why both hooks and egress middleware are required + +Pi maintains a local conversation and serializes that state into provider +requests. The two interception points serve different purposes: + +- **Pi hooks preserve harness state.** They let middleware inspect Pi's semantic + message representation before inference and return replacements through Pi's + supported mutation API. This keeps normal Pi execution aligned with the + sanitized conversation. +- **Egress middleware enforces mediation.** It observes the actual HTTP request + Pi is attempting to send. It prevents a disabled, bypassed, or ignored hook + result from reaching the provider. + +Neither point is sufficient alone. Hooks without an egress check are bypassable +by workload code. Egress mutation without hooks can leave Pi's local chat record +inconsistent with the provider-visible conversation. + +## Security property and threat model + +The MVP does not attempt to prove which JavaScript code executed inside Pi. It +enforces a behavior at the OpenShell boundary: an inference request must carry a +valid attestation whose signed conversation matches the actual request. + +If workload code manually invokes the inspection protocol instead of using the +bundled extension, the security property still holds: the middleware sees and +may mutate the exact conversation that the workload can subsequently send. + +The design assumes: + +- The network supervisor controls all provider egress. +- Provider endpoints requiring conversation inspection use inspectable HTTP/TLS + paths and mandatory fail-closed middleware. +- Opaque TCP, `tls: skip`, HTTP/2 prior knowledge, QUIC, and other uninspectable + routes cannot reach protected provider endpoints. +- The operator trusts the selected middleware service with raw conversation + content. +- The supervisor, not the Pi extension, stamps trusted sandbox and policy + context on agent-conversation evaluations. +- OpenShell-managed credentials are injected only after successful verification. + +## Non-goals for the MVP + +- A cross-turn transcript hash chain or authoritative conversation database. +- Single-use attestation enforcement or replay prevention. +- Proving the private contents of Pi's JavaScript memory. +- Provider-response mutation or pre-display output filtering. +- Supporting every Pi lifecycle event. +- Automatic extension injection into arbitrary sandbox images. +- A universal abstraction for every agent harness. +- Every provider protocol or OpenAI-compatible request variant. + +Replay of an identical, unexpired, already-inspected request does not bypass +inspection or mutation. A token identifier and replay cache can be added later +if duplicate inference calls become a security, billing, or audit concern. + +## Proposed architecture + +```mermaid +sequenceDiagram + participant U as User + participant P as Pi + participant B as Supervisor hook bridge + participant M as Operator gRPC middleware server + participant E as Egress proxy + participant L as Model provider + + U->>P: User message + P->>B: Local hook request with complete conversation + B->>B: Stamp trusted sandbox and policy context + B->>M: gRPC EvaluateAgentConversation + M-->>B: Replacement conversation + attestation + B-->>P: Hook replacement + attestation + P->>P: Apply sanitized messages + P->>E: Provider request + internal attestation header + E->>M: EvaluateHttpRequest with body + attestation + M-->>E: Allow only if signature and conversation match + E->>E: Strip internal attestation header + E->>L: Sanitized provider request + L-->>P: Normal provider response +``` + +### Components + +#### Pi extension + +The Pi community image will include a pinned OpenShell extension. The extension +will: + +1. Register the selected Pi hooks. +2. Send bounded hook inputs to a supervisor-owned local bridge. +3. Apply middleware-returned message replacements through Pi's hook API. +4. Retain the returned attestation for the current inference attempt. +5. Attach the attestation to a reserved internal header before provider egress. + +The prototype hook set should be deliberately small: + +- `input` for early user-input inspection and persistent transformation. +- `before_agent_start` for replacing the effective system prompt on each turn. +- `message_end` for persisting finalized plain-text assistant replacements. +- `context` for evaluating and replacing the complete effective message list + immediately before an LLM call. +- `before_provider_request` for confirming that Pi's exact serialized Chat + Completions message array matches the replacement signed by `context`. +- `before_provider_headers` for attaching the current attestation. + +Pi documents `context` replacement as non-destructive and `message_end` +replacement as applying to the finalized message. The prototype therefore uses +the persistent hooks for stored user/assistant text and re-sanitizes the system +prompt each turn. Pi currently assembles provider headers before invoking the +provider-payload hook, so the `context` result supplies the attestation and the +later payload hook can only verify that serialization preserved the signed +roles and text. Compaction, retries, steering, and session forks still require +explicit integration testing before this can be considered production-safe. + +#### Supervisor hook bridge + +The extension calls a stable, supervisor-owned local endpoint. It does not call +the operator server as ordinary sandbox egress. The bridge accepts the Pi hook +request and forwards it through the supervisor's registered gRPC middleware +client. In this sense the supervisor is a proxy, but it is an application-level +hook proxy rather than a transparent TCP or HTTP egress proxy. + +The bridge will: + +- Stamp trusted sandbox and policy context. +- Enforce request, response, and timeout bounds. +- Select only policy-authorized agent middleware. +- Normalize failures and apply fail-open or fail-closed policy. +- Return only hook-specific mutations and the opaque attestation. + +A loopback HTTP/JSON bridge is the likely MVP transport because the supervisor +already has a loopback service pattern and agent processes bypass the egress +proxy for localhost. A Unix socket remains an alternative, but it requires a +shared mount in the Kubernetes sidecar topology. + +The bridge should be thin. For the prototype it needs only one bounded request +shape, one fail-closed middleware selection, and one response containing the +replacement conversation and opaque attestation. It must not implement content +mutation or signing itself. + +#### Agent-hook middleware operation + +Extend the supervisor middleware protocol with a distinct typed operation rather +than overloading `HttpRequestEvaluation`. + +The operation should include: + +- Harness and harness version, initially Pi. +- Hook identity and hook schema version. +- Supervisor-stamped request context. +- Session and turn correlation identifiers when Pi exposes them. +- A bounded, typed conversation payload. +- Validated middleware configuration. + +The result should include: + +- Continue, deny, or replace semantics appropriate for the hook. +- The complete sanitized conversation when replaced. +- An opaque, size-bounded attestation. +- Audit-safe findings and metadata. + +The middleware manifest must advertise the exact harnesses, hook identifiers, +and schema versions it supports. + +#### Reference operator middleware server + +The prototype will include a small standalone gRPC server implementing both +middleware operations: + +- `EvaluateAgentConversation` receives the supervisor-stamped conversation, + replaces every exact, case-sensitive `sandbox` occurrence in supported message + text with `REDACTED`, signs the complete replacement conversation, and returns + both. +- `EvaluateHttpRequest` receives the buffered OpenAI Chat Completions request + through the existing egress middleware path. It verifies the attestation and + exact replacement conversation, then allows or denies. It never repairs or + mutates a mismatched provider body. + +Mutation and signing belong only in this operator server. The Pi extension and +supervisor bridge transport typed data and apply returned mutations; they do not +share the signing key. + +#### Signed attestation + +The attestation is self-contained and does not require a server-side pending +request record. Its signed claims should include at least: + +```text +attestation_version +canonicalization_version +middleware_binding +sandbox_id +session_id, when available +turn_id, when available +conversation_hash +provider or destination scope +policy_revision +issued_at +expires_at +key_id +``` + +The middleware server owns signing and verification. OpenShell transports the +attestation as opaque bytes and does not need access to the signing key. The +prototype uses the same standalone server for both the agent-conversation and +HTTP egress bindings. + +The initial implementation should use an asymmetric signature with explicit key +identifiers and rotation behavior. If the service is always the verifier, the +format can remain service-owned; the OpenShell contract needs only size limits +and opaque bytes. + +#### Conversation canonicalization + +Signature correctness depends on the hook and egress verifier producing the +same semantic representation. Define a versioned `ConversationRequestV1` +projection rather than hashing arbitrary Pi objects or raw JSON bytes. + +The prototype supports one explicit OpenAI Chat Completions-style shape: + +- An ordered `messages` array. +- `system`, `developer`, `user`, and `assistant` roles. +- String `content` only. +- The model identifier and any other explicitly admitted request-scoping fields. + +Multipart content, images, tool definitions, tool calls, tool results, prompt +fields outside the message array, and unknown message variants are unsupported +and fail closed. + +The Pi adapter converts Pi messages into this projection. The egress verifier +parses the provider request into the same projection. Both sides hash the same +versioned canonical encoding. + +The prototype does not claim generic OpenAI compatibility. Responses API +requests, legacy completions, compressed bodies, unknown content variants, and +unsupported endpoints must fail closed when an attestation is required. + +#### Egress verification + +The extension attaches the opaque attestation using a reserved internal header, +for example: + +```text +X-OpenShell-Agent-Attestation: +``` + +The exact header name and encoding are contract decisions. The network +supervisor must treat it as internal metadata: + +- Reject duplicate or oversized values. +- Make it available to the selected verification middleware. +- Strip it unconditionally before upstream forwarding. +- Prevent middleware failure policy from accidentally forwarding it. + +The egress verification binding parses the actual request body, reconstructs +`ConversationRequestV1`, and verifies: + +- The signature is valid for `key_id`. +- The attestation is within its validity window. +- The recomputed conversation hash matches the signed hash. +- Sandbox, destination, binding, and policy claims match the current request. +- The endpoint and body shape are supported and completely inspectable. + +The verification binding returns deny on any mismatch. The network middleware +policy for protected inference endpoints must use `on_error: fail_closed`. + +## Policy model + +The feature requires separate policy selection for agent hooks and network +egress verification. + +An illustrative policy shape is: + +```yaml +agent_middlewares: + pi-conversation-guard: + middleware: operator/pi-conversation-prototype + harness: pi + hooks: + - input + - before_agent_start + - message_end + - context + on_error: fail_closed + config: + replacement: sandbox-to-redacted + +network_middlewares: + verify-pi-conversation: + middleware: operator/pi-conversation-prototype + order: 10 + on_error: fail_closed + endpoints: + include: + - api.openai.com +``` + +The gateway must validate that both referenced bindings exist and distribute the +registration to the supervisor when either policy section requires it. + +## Example behavior + +Given Pi's current conversation: + +```json +[ + {"role": "system", "content": "You are a sandbox assistant."}, + {"role": "user", "content": "Create a sandbox inside another sandbox."} +] +``` + +the agent-hook middleware returns: + +```json +[ + {"role": "system", "content": "You are a REDACTED assistant."}, + {"role": "user", "content": "Create a REDACTED inside another REDACTED."} +] +``` + +and signs the canonical hash of that complete sanitized conversation. Pi applies +the replacement. At egress: + +- A request containing the complete replacement conversation and matching + attestation is allowed. +- A request containing any original `sandbox` occurrence is denied because its + conversation hash does not match. +- A request with no attestation is denied. +- A request with additional or reordered model-visible messages is denied. + +The provider response flows normally, so Pi can continue the conversation. + +## Audit behavior + +The operator middleware may retain an audit record containing: + +- Trusted sandbox identity and available session/turn identifiers. +- Original and sanitized conversation digests. +- Policy decision, safe reason code, and policy revision. +- Attestation identifier, signing key identifier, and timestamps. +- Original or sanitized content only when explicitly enabled in a protected + audit store with appropriate encryption, access control, and retention. + +OpenShell OCSF events must not include raw prompts, matched unsafe text, provider +credentials, or query parameters. They should contain aggregate findings and +safe reason codes only. + +The MVP does not chain audit records. A future version may add a tamper-evident +hash chain without changing the per-request attestation or egress verification +contract. + +## Failure behavior + +- **Inspection middleware unavailable:** fail closed; Pi must not start the + provider request and should surface a bounded diagnostic. +- **No attestation at egress:** deny. +- **Invalid, expired, or mismatched attestation:** deny. +- **Unsupported request schema:** deny when attestation is required. +- **Payload exceeds hook or HTTP middleware limits:** deny. +- **Pi applies an invalid replacement:** deny before egress. +- **Verification service unavailable:** fail closed. +- **Provider retry with an identical unexpired request:** allowed in the MVP. + +## Implementation phases + +### Phase 1: Smallest gRPC proof + +- Define the narrow string-content `ConversationRequestV1` projection and its + canonical encoding. +- Add `EvaluateAgentConversation` and its typed request/result messages to + `proto/supervisor_middleware.proto` without changing existing HTTP behavior. +- Extend middleware manifests just enough to advertise the selected Pi hooks and + schema version. +- Implement a standalone reference gRPC middleware server that exposes both + `EvaluateAgentConversation` and the existing `EvaluateHttpRequest`. +- Keep all `sandbox` to `REDACTED` mutation, deterministic prototype signing, + and verification logic inside that server. +- First prove directly over gRPC that inspection returns the replacement and an + attestation, the matching HTTP request is allowed, and original, tampered, or + unattested requests are denied. + +Do not add general agent policy, sidecar topology, or provider variants until +this direct gRPC proof passes. + +### Phase 2: Supervisor local bridge + +- Add one bounded loopback HTTP/JSON endpoint owned by the supervisor. +- Have it stamp trusted sandbox and policy context and call the registered gRPC + server's `EvaluateAgentConversation` operation. +- Apply the registered timeout and fail-closed behavior. +- Return only the complete replacement conversation and opaque attestation. +- Prove the extension-facing caller does not need the operator endpoint, gRPC + transport credentials, or signing key. + +The first bridge proof may target the combined supervisor topology. Kubernetes +network-sidecar routing must remain an explicit documented limitation until a +safe route from the sandbox-side bridge to the network-side middleware client is +designed and tested. + +### Phase 3: Minimal Pi extension and egress enforcement + +- Build a small Pi extension that registers `input`, `before_agent_start`, + `message_end`, `context`, `before_provider_request`, and + `before_provider_headers`. +- Persist supported user/assistant replacements, apply and sign the complete + semantic context replacement, attach that attestation during header assembly, + then fail if the provider-serialized message array differs from the signed + replacement. +- Confirm experimentally that Pi persists the transformed input and finalized + assistant replacement across normal turns, compaction, retries, steering, and + session lifecycle operations. Stop and revise the adapter if any path restores + unsanitized content without reapplying the persistent hooks. +- Reserve, bound, and unconditionally strip the attestation header at protected + egress. +- Select the same standalone server's `EvaluateHttpRequest` binding as mandatory + `fail_closed` middleware for the fake provider destination. +- Exercise Pi against a local OpenAI Chat Completions-style upstream and verify + the upstream never receives the word `sandbox` from the test conversation. + +### Phase 4: Production integration and documentation + +- Add full agent middleware policy parsing, validation, and distribution. +- Support and test combined and sidecar topologies plus relevant Docker, Podman, + Kubernetes, and VM paths. +- Replace deterministic prototype keys with operator-controlled key management + and rotation. +- Add cross-language canonicalization fixtures for the Pi extension and server. +- Update `architecture/sandbox.md` and `architecture/gateway.md` with the new + trust boundary and request flow. +- Extend `docs/extensibility/supervisor-middleware.mdx` with agent-hook bindings + and signed egress verification. +- Update `docs/reference/gateway-config.mdx` for registration/config changes. +- Add Helm values and rendering if operator middleware registration must work in + Kubernetes deployments. +- Update the relevant agent skills when commands or workflows change. + +## Test plan + +### Canonicalization and signature tests + +- Stable hashes across Rust and TypeScript fixtures. +- Ordered messages, repeated roles, Unicode, and empty string content. +- Unsupported multipart content, tools, and unknown request fields fail closed. +- Mutation of any covered field invalidates the attestation. +- Unknown schema versions and key identifiers fail closed. + +### Hook tests + +- A message without `sandbox` passes unchanged and receives an attestation. +- Every exact `sandbox` occurrence becomes `REDACTED` in Pi and in the provider + request, including multiple occurrences and earlier messages. +- Hook timeout, malformed result, oversized result, and service outage fail + closed. +- Pi started without the extension cannot reach a protected inference endpoint. + +### Egress tests + +- Valid matching request is forwarded after the internal header is stripped. +- Missing, invalid, expired, wrong-sandbox, wrong-destination, and wrong-policy + attestations are denied. +- Changing, adding, deleting, or reordering a model-visible message is denied. +- Unsupported or uninspectable provider traffic is denied. +- OpenShell-managed credentials remain unavailable to agent-hook and verifier + middleware. +- Identical retries behave according to the documented MVP replay policy. + +### Deployment tests + +- The first vertical slice covers combined supervisor topology. +- Kubernetes network-sidecar and Docker, Podman, and VM paths are required before + production use, not before the smallest prototype proof. +- Sandbox e2e test proves the original `sandbox` text never reaches upstream. + +## Acceptance criteria + +- Pi normally records and uses the replacement messages returned through its + hooks. +- Every protected inference request carries an attestation for its complete + model-visible conversation. +- The egress verifier rejects any request whose conversation differs from the + signed sanitized conversation. +- The internal attestation never reaches the provider. +- Disabling or omitting the Pi extension prevents protected inference egress. +- The provider can stream a response and Pi can continue subsequent turns. +- Audit outputs contain decisions and safe metadata without leaking raw content + into OpenShell logs. +- No transcript hash chain or conversation-state service is required for the + MVP. +- Mutation and signing occur in the standalone operator gRPC server, not in the + Pi extension or supervisor bridge. + +## Open questions + +- Does Pi persist an `input` transformation exactly as required, including RPC, + steering, and follow-up inputs? +- Do `input` and `message_end` replacements cover every Pi persistence path, and + how should resumed sessions created before extension installation be migrated? +- Which additional model-visible fields should be admitted after the narrow + string-content Chat Completions prototype? +- For production, should the same operator service continue to verify its own + attestations, or should OpenShell distribute verifier public keys? +- What attestation encoding and maximum header size should the contract use? +- Do identical retries need replay protection in the first production release? +- How should the loopback bridge route to the network sidecar without exposing + existing privileged control sockets? + +## Likely code and repository impact + +OpenShell core changes will likely touch: + +- `proto/supervisor_middleware.proto` +- `proto/sandbox.proto` +- `crates/openshell-supervisor-middleware/` +- `crates/openshell-policy/src/middleware.rs` +- `crates/openshell-server/src/middleware.rs` +- `crates/openshell-server/src/grpc/policy.rs` +- `crates/openshell-sandbox/` +- `crates/openshell-supervisor-network/` + +The bundled Pi extension belongs in the separate OpenShell Community repository's +Pi sandbox image. Initial packaging should use a pinned, read-only artifact; +portable automatic injection remains future work. + +## Follow-up work + +- Tamper-evident audit hash chaining. +- Single-use token identifiers and replay caches. +- Provider-response inspection and mutation. +- OpenAI Responses API and additional provider canonicalizers. +- Generic harness adapters and automatic read-only extension injection. +- Authenticated middleware transport and signing-key rotation tooling. diff --git a/crates/openshell-pi-conversation-middleware/README.md b/crates/openshell-pi-conversation-middleware/README.md index 601d9fbe73..97a0b048bb 100644 --- a/crates/openshell-pi-conversation-middleware/README.md +++ b/crates/openshell-pi-conversation-middleware/README.md @@ -9,6 +9,9 @@ agent operation replaces every exact, case-sensitive `sandbox` substring with denies requests with missing, invalid, expired, or mismatched attestations and removes the internal attestation header from allowed requests. +For the demo, each mutation is logged at `INFO` as pretty JSON containing the +complete original and replacement conversations. + Run the service on an address reachable from the gateway and sandbox supervisors: @@ -21,26 +24,30 @@ Register it in the gateway TOML before starting the gateway: ```toml [[openshell.supervisor.middleware]] name = "pi-conversation-prototype" -grpc_endpoint = "http://host.openshell.internal:50061" +grpc_endpoint = "http://:50061" max_body_bytes = 262144 timeout = "5s" ``` -The same registered service must be selected as fail-closed network middleware -for the protected provider host. The supervisor prototype bridge is enabled by -setting these variables in the sandbox supervisor environment: +The gateway calls `Describe` during startup, and sandbox supervisors later use +the same endpoint. On Docker Desktop for macOS, use the Mac's active LAN IPv4 +address; `host.openshell.internal` resolves inside Docker sandboxes but not in +the host-side gateway process. -```shell -OPENSHELL_PI_CONVERSATION_MIDDLEWARE=pi-conversation-prototype -OPENSHELL_PI_CONVERSATION_PROVIDER_HOST=api.openai.com -``` +The same registered service must be selected as fail-closed network middleware +for exactly one non-wildcard provider host. The supervisor discovers the Pi +bridge from the service's complete Pi agent-hook bindings and the effective +middleware policy; no user-supplied `OPENSHELL_*` variables are required or +accepted. The bridge reuses the selected network middleware's validated `config` for hook evaluation, so signing and egress verification use the same policy revision. -When enabled, the supervisor injects the stable -`OPENSHELL_PI_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation` -address into the workload environment. Load +The prototype extension defaults to the stable +`http://127.0.0.1:8193/v1/agent/conversation` address. The supervisor may also +inject that address as `OPENSHELL_PI_CONVERSATION_URL`, but the extension does +not require it. This matters for SSH sessions, which construct a fresh workload +environment rather than inheriting the entrypoint environment. Load `examples/pi-conversation-middleware/pi-extension.ts` as a Pi extension. ## Prototype limitations diff --git a/crates/openshell-pi-conversation-middleware/src/lib.rs b/crates/openshell-pi-conversation-middleware/src/lib.rs index be52b1cada..a08be11a59 100644 --- a/crates/openshell-pi-conversation-middleware/src/lib.rs +++ b/crates/openshell-pi-conversation-middleware/src/lib.rs @@ -155,6 +155,26 @@ struct ChatMessageBody { content: String, } +fn conversation_json(conversation: &ConversationRequestV1) -> serde_json::Value { + serde_json::json!({ + "model": conversation.model, + "messages": conversation.messages.iter().map(|message| serde_json::json!({ + "role": message.role, + "content": message.content, + })).collect::>(), + }) +} + +fn redact_conversation(conversation: &mut ConversationRequestV1) -> u32 { + let mut replacements = 0u32; + for message in &mut conversation.messages { + let count = message.content.matches("sandbox").count(); + replacements = replacements.saturating_add(u32::try_from(count).unwrap_or(u32::MAX)); + message.content = message.content.replace("sandbox", "REDACTED"); + } + replacements +} + fn validate_conversation(conversation: &ConversationRequestV1) -> Result<(), &'static str> { if conversation.model.is_empty() || conversation.messages.is_empty() { return Err("model and messages must be non-empty"); @@ -336,11 +356,19 @@ impl SupervisorMiddleware for PrototypeService { return Ok(Response::new(deny_agent("unsupported_conversation_shape"))); } - let mut replacements = 0u32; - for message in &mut conversation.messages { - let count = message.content.matches("sandbox").count(); - replacements = replacements.saturating_add(u32::try_from(count).unwrap_or(u32::MAX)); - message.content = message.content.replace("sandbox", "REDACTED"); + let original = conversation_json(&conversation); + let replacements = redact_conversation(&mut conversation); + if replacements > 0 { + let formatted = serde_json::to_string_pretty(&serde_json::json!({ + "hook": target.hook, + "original": original, + "replacement": conversation_json(&conversation), + })) + .map_err(|error| Status::internal(error.to_string()))?; + tracing::info!( + replacement_count = replacements, + "Pi conversation mutation\n{formatted}" + ); } let issued_at = self.now(); let claims = AttestationClaims { diff --git a/crates/openshell-sandbox/src/agent_bridge.rs b/crates/openshell-sandbox/src/agent_bridge.rs index 1d0b76637d..6aeb06d304 100644 --- a/crates/openshell-sandbox/src/agent_bridge.rs +++ b/crates/openshell-sandbox/src/agent_bridge.rs @@ -22,8 +22,6 @@ pub const BRIDGE_ADDR: &str = "127.0.0.1:8193"; pub const BRIDGE_PATH: &str = "/v1/agent/conversation"; pub const BRIDGE_URL: &str = "http://127.0.0.1:8193/v1/agent/conversation"; pub const BRIDGE_URL_ENV: &str = "OPENSHELL_PI_CONVERSATION_URL"; -pub const MIDDLEWARE_ENV: &str = "OPENSHELL_PI_CONVERSATION_MIDDLEWARE"; -pub const PROVIDER_HOST_ENV: &str = "OPENSHELL_PI_CONVERSATION_PROVIDER_HOST"; const MAX_BRIDGE_BODY_BYTES: usize = 256 * 1024; diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 894f803eed..b42129431e 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -61,7 +61,9 @@ pub(crate) use openshell_ocsf::ctx::ctx as ocsf_ctx; use openshell_core::denial::DenialEvent; use openshell_core::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPolicy}; use openshell_core::proposals::AgentProposals; +use openshell_core::proto::NetworkMiddlewareConfig; use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_supervisor_middleware::ChainRunner; use openshell_supervisor_network::opa::OpaEngine; use openshell_supervisor_process::process::ProcessEnforcementMode; pub use openshell_supervisor_process::process::{ProcessHandle, ProcessStatus}; @@ -77,6 +79,49 @@ const SIDECAR_CA_BUNDLE: &str = "ca-bundle.pem"; const SIDECAR_PROCESS_PROXY_ADDR: &str = "127.0.0.1:3128"; const SIDECAR_READY_TIMEOUT_SECS: u64 = 120; +const PI_AGENT_HOOKS: &[&str] = &["input", "before_agent_start", "message_end", "context"]; + +struct PiConversationBridgeSelection { + middleware_name: String, + provider_host: String, + middleware_config: prost_types::Struct, +} + +async fn select_pi_conversation_bridge( + runner: &ChainRunner, + configs: &std::collections::HashMap, +) -> Result> { + let agent_middlewares = runner + .agent_conversation_middleware_names("pi", "v1", PI_AGENT_HOOKS) + .await?; + let mut matching_configs = configs + .iter() + .filter(|(_, config)| agent_middlewares.contains(&config.middleware)); + let Some((config_name, config)) = matching_configs.next() else { + return Ok(None); + }; + if matching_configs.next().is_some() { + return Err(miette::miette!( + "Pi conversation prototype requires exactly one network middleware config with Pi agent hook bindings" + )); + } + let endpoints = config.endpoints.as_ref().ok_or_else(|| { + miette::miette!( + "Pi conversation middleware config '{config_name}' requires an endpoint selector" + ) + })?; + if endpoints.include.len() != 1 || endpoints.include[0].contains('*') { + return Err(miette::miette!( + "Pi conversation middleware config '{config_name}' must include exactly one non-wildcard provider host" + )); + } + Ok(Some(PiConversationBridgeSelection { + middleware_name: config.middleware.clone(), + provider_host: endpoints.include[0].clone(), + middleware_config: config.config.clone().unwrap_or_default(), + })) +} + /// Run a command in the sandbox. /// /// # Errors @@ -399,66 +444,45 @@ pub async fn run_sandbox( None }; - // Prototype Pi hook bridge. It binds inside the workload network namespace - // and proxies typed hook requests through the supervisor-owned middleware - // registry. The operator service must also be selected as network - // middleware so it is present in the synchronized registry. - if let Ok(middleware_name) = std::env::var(agent_bridge::MIDDLEWARE_ENV) { - if middleware_name.trim().is_empty() { - return Err(miette::miette!( - "{} cannot be empty", - agent_bridge::MIDDLEWARE_ENV - )); + // Prototype Pi hook bridge. Discover it from the supervisor-owned registry + // and effective middleware policy rather than accepting reserved workload + // environment variables. The selected policy config supplies the sole + // exact provider host used in the signed conversation target. + let pi_bridge = match (opa_engine.as_ref(), retained_proto.as_ref()) { + (Some(engine), Some(proto)) => { + let runner = engine.middleware_runner()?; + select_pi_conversation_bridge(&runner, &proto.network_middlewares) + .await? + .map(|selection| (selection, runner)) } + _ => None, + }; + if let Some((selection, runner)) = pi_bridge { if sidecar_network_enforcement { return Err(miette::miette!( "Pi conversation bridge is not yet supported in sidecar topology" )); } - let engine = opa_engine.as_ref().ok_or_else(|| { - miette::miette!("Pi conversation bridge requires a middleware registry") - })?; - let proto = retained_proto.as_ref().ok_or_else(|| { - miette::miette!("Pi conversation bridge requires gateway policy data") - })?; - let mut matching_configs = proto - .network_middlewares - .values() - .filter(|config| config.middleware == middleware_name); - let middleware_config = matching_configs - .next() - .ok_or_else(|| { - miette::miette!( - "Pi conversation middleware '{middleware_name}' must be selected by network policy" - ) - })? - .config - .clone() - .unwrap_or_default(); - if matching_configs.next().is_some() { - return Err(miette::miette!( - "Pi conversation prototype requires exactly one network policy config for '{middleware_name}'" - )); - } #[cfg(target_os = "linux")] let listener = netns .as_ref() .ok_or_else(|| miette::miette!("Pi conversation bridge requires network enforcement"))? .bind_tcp_in_netns(agent_bridge::BRIDGE_ADDR) - .await?; + .await + .into_diagnostic() + .wrap_err("failed to bind Pi conversation bridge listener")?; #[cfg(not(target_os = "linux"))] let listener = tokio::net::TcpListener::bind(agent_bridge::BRIDGE_ADDR) .await .into_diagnostic()?; agent_bridge::spawn( listener, - engine.middleware_runner()?, + runner, agent_bridge::BridgeConfig { - middleware_name, + middleware_name: selection.middleware_name, sandbox_id: sandbox_id.clone().unwrap_or_default(), - provider_host: std::env::var(agent_bridge::PROVIDER_HOST_ENV) - .unwrap_or_else(|_| "api.openai.com".into()), - middleware_config, + provider_host: selection.provider_host, + middleware_config: selection.middleware_config, }, ); provider_env.insert( @@ -3594,6 +3618,35 @@ mod tests { } } + #[tokio::test] + async fn pi_bridge_selection_comes_from_bindings_and_policy_host() { + let runner = ChainRunner::new(Arc::new( + openshell_pi_conversation_middleware::PrototypeService::new(), + )); + let configs = std::collections::HashMap::from([( + "pi-conversation".to_string(), + NetworkMiddlewareConfig { + middleware: openshell_pi_conversation_middleware::SERVICE_NAME.to_string(), + endpoints: Some(openshell_core::proto::MiddlewareEndpointSelector { + include: vec!["inference-api.nvidia.com".to_string()], + exclude: Vec::new(), + }), + ..Default::default() + }, + )]); + + let selection = select_pi_conversation_bridge(&runner, &configs) + .await + .expect("select Pi bridge") + .expect("Pi bridge should be selected"); + + assert_eq!( + selection.middleware_name, + openshell_pi_conversation_middleware::SERVICE_NAME + ); + assert_eq!(selection.provider_host, "inference-api.nvidia.com"); + } + #[test] fn sidecar_process_policy_sets_loopback_proxy_addr() { let policy = proxy_policy(None); diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 1c225021a0..1937dc564b 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -956,6 +956,34 @@ impl ChainRunner { }) } + /// Return attachment names that advertise every requested agent hook for + /// one harness/schema pair. Supervisors use this to discover hook bridges + /// from the validated registry instead of accepting workload environment + /// variables that name privileged middleware. + pub async fn agent_conversation_middleware_names( + &self, + harness: &str, + schema_version: &str, + hooks: &[&str], + ) -> Result> { + let manifests = self.manifests().await?; + Ok(manifests + .iter() + .filter(|(_, manifest)| { + hooks.iter().all(|hook| { + manifest.bindings.iter().any(|binding| { + binding.operation == AGENT_CONVERSATION_OPERATION as i32 + && binding.phase == AGENT_CONTEXT_PHASE as i32 + && binding.harness == harness + && binding.hook == *hook + && binding.schema_version == schema_version + }) + }) + }) + .map(|(state, manifest)| Self::attachment_name(state, manifest).to_string()) + .collect()) + } + /// Evaluate one complete Pi conversation through the named registered /// middleware. The caller supplies supervisor-stamped identity and target /// fields; failures are returned so the local bridge can fail closed. diff --git a/examples/pi-conversation-middleware/README.md b/examples/pi-conversation-middleware/README.md index d163810a6f..d96c822f39 100644 --- a/examples/pi-conversation-middleware/README.md +++ b/examples/pi-conversation-middleware/README.md @@ -14,26 +14,28 @@ openshell sandbox create \ --name pi-redaction-demo ``` -The `models.json` file contains the literal `$OPENAI_API_KEY` environment +The `models.json` file contains the literal `$NV_IAPI_API_KEY` environment reference, not a credential. Attach an OpenShell provider when creating the sandbox so Pi receives an opaque credential placeholder at runtime. Load the extension from inside a Pi-capable sandbox: ```shell -pi --no-tools --extension /path/to/pi-extension.ts +pi --offline --no-tools --extension /path/to/pi-extension.ts ``` -The extension calls only the stable supervisor URL supplied in -`OPENSHELL_PI_CONVERSATION_URL`; it does not know the operator gRPC endpoint. -The supervisor proxies each hook call through its registered middleware client, -stamps sandbox and provider identity, and returns the replacement conversation -plus opaque attestation. +The extension calls only the stable supervisor URL at +`http://127.0.0.1:8193/v1/agent/conversation`; it does not know the operator +gRPC endpoint. `OPENSHELL_PI_CONVERSATION_URL` may override that prototype +default. The supervisor proxies each hook call through its registered +middleware client, stamps sandbox and provider identity, and returns the +replacement conversation plus opaque attestation. For the smallest proof, select an `openai-completions` model, disable Pi tools, -and use text-only messages. A prompt such as `describe a sandbox` is stored and -sent as `describe a REDACTED`. Any attempt to send the original body, alter the -signed message list, or omit the internal header is denied at egress. +and use the configured NVIDIA Inference API GPT-5.6 Sol model with text-only +messages. A prompt such as `describe a sandbox` is stored and sent as `describe +a REDACTED`. Any attempt to send the original body, alter the signed message +list, or omit the internal header is denied at egress. This example is deliberately not a general Pi integration. Review the limitations in the crate README before using it. diff --git a/examples/pi-conversation-middleware/TESTING.md b/examples/pi-conversation-middleware/TESTING.md new file mode 100644 index 0000000000..4b9d2f95eb --- /dev/null +++ b/examples/pi-conversation-middleware/TESTING.md @@ -0,0 +1,366 @@ +# Pi conversation middleware manual test + +This guide exercises the narrow Pi conversation middleware prototype end to +end: + +1. Pi sends its hook conversation to the stable supervisor HTTP bridge. +2. The supervisor proxies the hook request to the operator gRPC middleware. +3. The middleware replaces `sandbox` with `REDACTED` and signs the sanitized + conversation. +4. The Pi extension applies the replacement and attaches the attestation to the + OpenAI Chat Completions request. +5. Fail-closed HTTP egress middleware verifies the exact signed message array, + strips the internal attestation header, and forwards the request. + +## Important constraints + +- `openshell sandbox create --from ` builds the image + automatically through the local Docker daemon. The complete build context is + checked in under `examples/pi-conversation-middleware`; do not run a separate + `docker build` or generate temporary build files. +- This prototype supports OpenAI Chat Completions only. Pi's Codex subscription + provider uses the Codex Responses API and is intentionally unsupported. +- Use an NVIDIA Inference API key for this test. Do not copy credentials into + the image or sandbox. +- Use text-only input, a non-reasoning model, and no Pi tools. +- Start Pi with `--offline` so it does not attempt unrelated startup downloads + from `pi.dev` or GitHub. This does not disable model inference. +- The local Docker gateway must use the combined supervisor topology. + +## Prerequisites + +- Docker Desktop or another reachable Docker daemon +- The repository development dependencies installed +- An NVIDIA Inference API key that can be supplied to an OpenShell provider + +Run all host commands from the repository root: + +```shell +docker info +``` + +## 1. Inspect the checked-in Docker build context + +The example directory already contains everything needed for the local image +build: + +```shell +ls examples/pi-conversation-middleware +``` + +The relevant files are: + +- `Dockerfile`: installs Pi, declares a non-root OCI user, and copies the Pi + configuration and extension into the image. +- `models.json`: configures GPT-5.6 Sol through the NVIDIA Inference API's + OpenAI Chat Completions endpoint. +- `pi-extension.ts`: forwards Pi hook data to the stable supervisor bridge and + applies the signed mutation. +- `policy.yaml`: attaches fail-closed middleware to + `inference-api.nvidia.com` and grants read-only filesystem access to the + extension under `/opt/pi-conversation`. + +The string `$NV_IAPI_API_KEY` in `models.json` is a literal +environment-variable reference, not a credential value. The build context and +resulting image contain no API key. At runtime, Pi resolves that reference from +the opaque environment placeholder populated by the attached OpenShell +provider. + +## 2. Start the operator gRPC middleware + +In terminal 1: + +```shell +RUST_LOG=info cargo run \ + -p openshell-pi-conversation-middleware \ + -- \ + --listen 0.0.0.0:50061 +``` + +Leave this process running. + +## 3. Prepare and start the local Docker gateway + +The development task builds the gateway and Linux sandbox supervisor and +generates the Docker-driver configuration. It currently rewrites its generated +configuration on every invocation, so run it once, stop it, add the prototype +registration, and then launch the prepared gateway binary directly. + +In terminal 2: + +```shell +mise run gateway:docker +``` + +Wait for `Starting standalone Docker gateway`, then press Ctrl-C. Register the +operator middleware in the generated configuration. The registration endpoint +must be reachable both by the gateway on macOS and by Docker sandboxes. On this +Mac, use the active Wi-Fi IPv4 address: + +```shell +MIDDLEWARE_HOST_IP="$(ipconfig getifaddr en0)" +test -n "$MIDDLEWARE_HOST_IP" || { + echo "Could not find the host IPv4 address on en0" >&2 + return 1 +} +nc -vz "$MIDDLEWARE_HOST_IP" 50061 + +cat >> .cache/gateway-docker/gateway.toml < { + console.log("status:", response.status); + console.log(await response.text()); +}); +NODE +``` + +The request must receive a non-2xx denial because it lacks the internal +`x-openshell-agent-attestation` header. The gateway or sandbox logs should +identify `missing_attestation` as the middleware reason. + +## 8. Cleanup + +Exit the SSH session and remove the sandbox: + +```shell +./scripts/bin/openshell \ + --gateway docker-dev \ + sandbox delete pi-redaction-demo +``` + +Stop the gateway and operator middleware with Ctrl-C in their terminals. + +## Troubleshooting + +### The gateway task overwrote the middleware registration + +Run `mise run gateway:docker`, stop it after startup, append the registration +again, and restart `target/debug/openshell-gateway` directly as shown above. + +### Pi rejects the selected model + +Confirm Pi is using provider `nvidia-inference-api`, API +`openai-completions`, and a model with `reasoning: false`. Pi's built-in +`openai` and `openai-codex` providers use Responses request shapes and will be +rejected by this prototype. + +### Pi tries to download fd or ripgrep + +Use the documented `--offline` flag. Those downloads support optional Pi +features and are unnecessary because this prototype starts Pi with tools +disabled. The sandbox policy intentionally does not allow `pi.dev` or GitHub. + +### The extension reports that the bridge URL is not set + +The current extension has the stable loopback bridge URL built in. This error +means the sandbox image contains an older `pi-extension.ts`. Delete and +recreate the sandbox with the checked-in Dockerfile so OpenShell rebuilds the +image from the current example context. + +### Pi cannot read `/opt/pi-conversation/pi-extension.ts` + +The extension directory must appear in `filesystem_policy.read_only` when the +sandbox starts. Filesystem policy is static, so delete and recreate any sandbox +created with an older `policy.yaml`; a policy hot reload cannot repair this +Landlock denial. + +### `--env OPENSHELL_PI_CONVERSATION_*` is rejected + +Remove those arguments. `OPENSHELL_*` is reserved, and the supervisor now +discovers the bridge middleware from its registered agent-hook bindings plus +the selected middleware policy. + +### NVIDIA Inference API key is unavailable + +Confirm `NV_IAPI_API_KEY` is present in the host shell when creating the +`generic` OpenShell provider. Do not pass the key through `--env` or copy it +into the Docker build context. + +### The middleware cannot be reached + +Confirm terminal 1 is listening on `0.0.0.0:50061`, the gateway registration +uses the Mac's active LAN IPv4 address, and `nc -vz "$MIDDLEWARE_HOST_IP" 50061` +succeeds from macOS. `host.openshell.internal` is injected into Docker +sandboxes, but it does not resolve in the host-side gateway process on macOS. diff --git a/examples/pi-conversation-middleware/models.json b/examples/pi-conversation-middleware/models.json index bdc0a54c02..b235047a95 100644 --- a/examples/pi-conversation-middleware/models.json +++ b/examples/pi-conversation-middleware/models.json @@ -1,17 +1,23 @@ { "providers": { - "openai-chat-prototype": { - "baseUrl": "https://api.openai.com/v1", + "nvidia-inference-api": { + "baseUrl": "https://inference-api.nvidia.com/v1", "api": "openai-completions", - "apiKey": "$OPENAI_API_KEY", + "apiKey": "$NV_IAPI_API_KEY", "authHeader": true, + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false, + "supportsUsageInStreaming": false, + "maxTokensField": "max_tokens" + }, "models": [ { - "id": "gpt-4o-mini", - "name": "GPT-4o mini (middleware prototype)", + "id": "azure/openai/gpt-5.6-sol", + "name": "GPT-5.6 Sol (NVIDIA Inference API, Azure)", "reasoning": false, "input": ["text"], - "contextWindow": 128000, + "contextWindow": 272000, "maxTokens": 4096, "cost": { "input": 0, diff --git a/examples/pi-conversation-middleware/pi-extension.ts b/examples/pi-conversation-middleware/pi-extension.ts index a69e087c3d..b8b5173157 100644 --- a/examples/pi-conversation-middleware/pi-extension.ts +++ b/examples/pi-conversation-middleware/pi-extension.ts @@ -4,13 +4,10 @@ const ATTESTATION_HEADER = "x-openshell-agent-attestation"; const HARNESS_VERSION = "prototype-v1"; const REQUEST_TIMEOUT_MS = 5_000; +const DEFAULT_BRIDGE_URL = "http://127.0.0.1:8193/v1/agent/conversation"; function bridgeUrl() { - const value = process.env.OPENSHELL_PI_CONVERSATION_URL; - if (!value) { - throw new Error("OPENSHELL_PI_CONVERSATION_URL is not set"); - } - return value; + return process.env.OPENSHELL_PI_CONVERSATION_URL || DEFAULT_BRIDGE_URL; } function modelId(ctx) { diff --git a/examples/pi-conversation-middleware/policy.yaml b/examples/pi-conversation-middleware/policy.yaml index 3f1277e896..ae415b3214 100644 --- a/examples/pi-conversation-middleware/policy.yaml +++ b/examples/pi-conversation-middleware/policy.yaml @@ -3,6 +3,20 @@ version: 1 +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /etc + - /var/log + - /opt/pi-conversation + read_write: + - /tmp + - /dev/null + network_middlewares: pi-conversation: name: Pi conversation attestation @@ -13,13 +27,13 @@ network_middlewares: on_error: fail_closed endpoints: include: - - api.openai.com + - inference-api.nvidia.com network_policies: - openai-chat-completions: - name: OpenAI Chat Completions + nvidia-inference-chat-completions: + name: NVIDIA Inference API Chat Completions endpoints: - - host: api.openai.com + - host: inference-api.nvidia.com port: 443 protocol: rest rules: diff --git a/tasks/scripts/stage-prebuilt-binaries.sh b/tasks/scripts/stage-prebuilt-binaries.sh index 331d45a5b4..074d318ef9 100755 --- a/tasks/scripts/stage-prebuilt-binaries.sh +++ b/tasks/scripts/stage-prebuilt-binaries.sh @@ -208,7 +208,15 @@ build_component_for_arch() { if [[ -n "${OPENSHELL_CARGO_VERSION:-}" ]]; then export GIT_DIR=/nonexistent fi - CARGO_INCREMENTAL=0 mise x -- "${cargo_subcommand[@]}" "${args[@]}" + if [[ "${cargo_subcommand[*]}" == "cargo zigbuild" ]]; then + # mise.toml sets RUSTC_WRAPPER=sccache, so unset it inside `mise x` after + # mise has loaded the project environment. sccache cannot reliably wrap + # cargo-zigbuild's generated C compiler wrappers (used by crates such as + # aws-lc-sys), which otherwise makes the cross-compile fail before rustc. + CARGO_INCREMENTAL=0 mise x -- env -u RUSTC_WRAPPER "${cargo_subcommand[@]}" "${args[@]}" + else + CARGO_INCREMENTAL=0 mise x -- "${cargo_subcommand[@]}" "${args[@]}" + fi ) binary_path="${ROOT}/target/${target}/release/${binary}"