From 00f02127adefb7e64fd19e4747c74bc15a2a9d88 Mon Sep 17 00:00:00 2001 From: Polite_realism <119441270+politerealism@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:29:04 +0000 Subject: [PATCH 01/10] test(supervisor-network): show response body on ssrf_denied assertion failure (#3290) forward_handler_preserves_ssrf_response_and_denial_stage's ssrf_denied check was the only assertion in the test without a failure message, unlike its sibling assertions. This test has failed intermittently in CI with no way to see what response was actually returned, since the message is where that gets surfaced. Signed-off-by: politerealism --- crates/openshell-supervisor-network/src/proxy.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index e3a481941..221038075 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -11023,7 +11023,10 @@ network_policies: response.starts_with("HTTP/1.1 403 Forbidden"), "internal forward destination must get the SSRF 403; got: {response:?}" ); - assert!(response.contains("ssrf_denied")); + assert!( + response.contains("ssrf_denied"), + "expected the SSRF-specific denial body; got: {response:?}" + ); assert!( response.contains("GET 127.0.0.1:80 blocked: declared endpoint check failed"), "an explicit loopback endpoint must fail declared-endpoint validation; got: {response:?}" From bcf4558cfc80bba49cdfb390afb1a1c83392a193 Mon Sep 17 00:00:00 2001 From: krishicks Date: Fri, 11 Sep 2026 20:38:01 +0000 Subject: [PATCH 02/10] fix(mise): run mise lock --platform linux-x64 (#3291) Signed-off-by: Kris Hicks --- mise.lock | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mise.lock b/mise.lock index 522084b55..6e0d6d4ef 100644 --- a/mise.lock +++ b/mise.lock @@ -134,6 +134,18 @@ checksum = "sha256:b8514ed7552e148b0a032114f745118dcb801791adafafeaf9935e4bfb0ed url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.16.0-x86_64-pc-windows-msvc.zip" url_api = "https://api.github.com/repos/mozilla/sccache/releases/assets/452060720" +[[tools."github:mozilla/sccache"]] +version = "0.16.0" +backend = "github:mozilla/sccache" + +[tools."github:mozilla/sccache".options] +asset_pattern = "sccache-v*x86_64*linux*.tar.gz" + +[tools."github:mozilla/sccache"."platforms.linux-x64"] +checksum = "sha256:aec995a83ad3dff3d14b6314e08858b7b73d35ca85a5bcf3d3a9ec07dee35588" +url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.16.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/mozilla/sccache/releases/assets/452060682" + [[tools."github:nextest-rs/nextest"]] version = "cargo-nextest-0.9.143" backend = "github:nextest-rs/nextest" From 5b57f0d1549677c7547862d26ff9491285ea0fbc Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 11 Sep 2026 21:40:08 +0000 Subject: [PATCH 03/10] fix(ci): restore Windows test portability (#3288) * fix(ci): restore Windows test portability Signed-off-by: Piotr Mlocek * fix(tasks): skip Unix lockfile check on Windows Signed-off-by: Piotr Mlocek * fix(tasks): use buf shim on Windows Signed-off-by: Piotr Mlocek --------- Signed-off-by: Piotr Mlocek --- crates/openshell-core/src/driver_utils.rs | 1 + crates/openshell-ocsf/tests/event_identity.rs | 7 ++++++- tasks/rust.toml | 1 + tasks/typescript.toml | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index f4da644d9..a0c46729c 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -1248,6 +1248,7 @@ mod tests { assert!(validate_guest_spiffe_tcp_endpoint("unix:/run/spire/agent.sock", true).is_err()); } + #[cfg(unix)] #[test] fn credential_file_rejects_fifo_without_hanging() { // A FIFO with no writer would block a blocking open() forever. The diff --git a/crates/openshell-ocsf/tests/event_identity.rs b/crates/openshell-ocsf/tests/event_identity.rs index 9fa51cc8b..34a3868ca 100644 --- a/crates/openshell-ocsf/tests/event_identity.rs +++ b/crates/openshell-ocsf/tests/event_identity.rs @@ -68,7 +68,12 @@ fn device_identifies_the_sandbox_environment() { assert_eq!(json["device"]["type_id"], 99); assert_eq!(json["device"]["type"], "Sandbox"); - assert_eq!(json["device"]["os"]["name"], "Linux"); + let expected_os = if cfg!(target_os = "windows") { + "Windows" + } else { + "Linux" + }; + assert_eq!(json["device"]["os"]["name"], expected_os); } #[test] diff --git a/tasks/rust.toml b/tasks/rust.toml index e8c45fd54..548e302f1 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -13,6 +13,7 @@ hide = true ["rust:lockfiles:check"] description = "Verify all tracked Cargo lockfiles are current" run = "tasks/scripts/check-cargo-lockfiles.sh" +run_windows = "echo Skipping rust:lockfiles:check: Cargo lockfile validation uses a Unix shell helper." hide = true ["rust:lint"] diff --git a/tasks/typescript.toml b/tasks/typescript.toml index 823a88135..06f6e8c4f 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -25,6 +25,7 @@ run = "npm run gen" description = "Lint proto/ with buf (repo-level buf.yaml)" depends = ["sdk:ts:install"] run = "./sdk/typescript/node_modules/.bin/buf lint" +run_windows = "sdk\\typescript\\node_modules\\.bin\\buf.cmd lint" ["sdk:ts:typecheck"] description = "Type-check the TypeScript SDK" From 5b9daab9351b1e053f9a5e0ce4c899f5d3f674b0 Mon Sep 17 00:00:00 2001 From: krishicks Date: Sat, 12 Sep 2026 00:17:15 +0000 Subject: [PATCH 04/10] fix(ci): restore mise run ci on macOS (#3294) - Replace BSD-incompatible in-place sed calls with portable temp-file rewrites. - Remove test-only shell interception and capture generated gateway config directly. - Allow parity tests to use supplied supervisor binaries without resolving a Linux target. - Normalize temporary-directory paths and use portable RPM config installation. - Set a valid setuptools-scm version for Python protobuf generation in Jujutsu checkouts. Signed-off-by: Kris Hicks --- deploy/rpm/migrate-gateway-config.sh | 4 +++- e2e/parity/run.sh | 8 +++++--- e2e/parity/test.sh | 4 +++- e2e/support/podman-gateway-config.sh | 7 +++++-- tasks/python.toml | 1 + tasks/scripts/stage-prebuilt-binaries.sh | 4 +++- tasks/scripts/test-gateway-config.sh | 7 +++---- 7 files changed, 23 insertions(+), 12 deletions(-) diff --git a/deploy/rpm/migrate-gateway-config.sh b/deploy/rpm/migrate-gateway-config.sh index 4794ffa45..54e421a06 100755 --- a/deploy/rpm/migrate-gateway-config.sh +++ b/deploy/rpm/migrate-gateway-config.sh @@ -27,7 +27,9 @@ if [ -L "$destination" ] || { [ -e "$destination" ] && [ ! -f "$destination" ]; fi if [ ! -e "$destination" ]; then - install -Dm 0644 "$current_default" "$destination" + destination_dir=$(dirname "$destination") + mkdir -p "$destination_dir" + install -m 0644 "$current_default" "$destination" exit 0 fi diff --git a/e2e/parity/run.sh b/e2e/parity/run.sh index 6cf3f5144..2ebbbe468 100755 --- a/e2e/parity/run.sh +++ b/e2e/parity/run.sh @@ -290,11 +290,13 @@ supervisor_target_triple() { build_supervisor() { local variant=$1 source_root=$2 target_dir=$3 override=$4 output_var=$5 local binary target jobs=() - target="$(supervisor_target_triple)" target_dir="${target_dir:-${ROOT}/target/parity/${variant}}" case "${target_dir}" in /*) ;; *) target_dir="${ROOT}/${target_dir}" ;; esac - binary="${override:-${target_dir}/${target}/release/openshell-sandbox}" - if [ -z "${override}" ]; then + if [ -n "${override}" ]; then + binary="${override}" + else + target="$(supervisor_target_triple)" + binary="${target_dir}/${target}/release/openshell-sandbox" require_clean_source "${variant}" "${source_root}" if [ -n "${CARGO_BUILD_JOBS:-}" ]; then jobs=(-j "${CARGO_BUILD_JOBS}"); fi echo "Building ${variant} supervisor in ${target_dir}..." diff --git a/e2e/parity/test.sh b/e2e/parity/test.sh index 2b8124ccd..93137a986 100755 --- a/e2e/parity/test.sh +++ b/e2e/parity/test.sh @@ -8,7 +8,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/openshell-parity-test.XXXXXX")" +TMP_ROOT="${TMPDIR:-/tmp}" +TMP_ROOT="${TMP_ROOT%/}" +WORKDIR="$(mktemp -d "${TMP_ROOT}/openshell-parity-test.XXXXXX")" trap 'rm -rf "${WORKDIR}"' EXIT fail() { echo "FAIL: $*" >&2; exit 1; } diff --git a/e2e/support/podman-gateway-config.sh b/e2e/support/podman-gateway-config.sh index dcf52d09c..1f604ebcf 100755 --- a/e2e/support/podman-gateway-config.sh +++ b/e2e/support/podman-gateway-config.sh @@ -128,9 +128,12 @@ e2e_write_podman_gateway_config() { # A remote UDS driver owns all runtime options. Keep the selected # gateway table transport-only so no in-tree setting can be mistaken # for executed external-driver configuration. - sed -i '/^health_check_interval_secs = /d' "${output}" + sed '/^health_check_interval_secs = /d' "${output}" >"${output}.updated" + mv "${output}.updated" "${output}" elif [ "${option_profile}" = "podman-options" ]; then - sed -i 's/^health_check_interval_secs = .*/health_check_interval_secs = 7/' "${output}" + sed 's/^health_check_interval_secs = .*/health_check_interval_secs = 7/' \ + "${output}" >"${output}.updated" + mv "${output}.updated" "${output}" fi # The v2 template opens the Podman table. Insert gateway-owned TLS # before it rather than reopening [openshell.gateway] later. diff --git a/tasks/python.toml b/tasks/python.toml index 9623bb226..aacb005c0 100644 --- a/tasks/python.toml +++ b/tasks/python.toml @@ -60,5 +60,6 @@ hide = true ["python:proto"] description = "Generate Python protobuf stubs from .proto files" +env = { SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL = "0.0.0" } run = "uv run --frozen python tasks/scripts/generate_python_proto.py" hide = true diff --git a/tasks/scripts/stage-prebuilt-binaries.sh b/tasks/scripts/stage-prebuilt-binaries.sh index 8f40788ff..9687e24fd 100755 --- a/tasks/scripts/stage-prebuilt-binaries.sh +++ b/tasks/scripts/stage-prebuilt-binaries.sh @@ -149,7 +149,9 @@ patch_workspace_version() { cargo_toml_backup="$(mktemp)" cp "$cargo_toml" "$cargo_toml_backup" restore_cargo_toml=1 - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' "$cargo_toml" + sed -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' \ + "$cargo_toml" >"${cargo_toml}.updated" + mv "${cargo_toml}.updated" "$cargo_toml" } restore_workspace_version() { diff --git a/tasks/scripts/test-gateway-config.sh b/tasks/scripts/test-gateway-config.sh index 850cccc0b..9541aa68f 100755 --- a/tasks/scripts/test-gateway-config.sh +++ b/tasks/scripts/test-gateway-config.sh @@ -23,12 +23,11 @@ printf '%s\n' 'import sys, tomllib' 'from pathlib import Path' 'assert tomllib.l mkdir -p "${WORK}/bin" ln -s /usr/bin/true "${WORK}/bin/mise" ln -s /usr/bin/false "${WORK}/bin/lsof" -ln -s /bin/bash "${WORK}/bin/gateway" - -printf '%s\n' 'exec() {' ' config=""' ' while [ "$#" -gt 0 ]; do' ' if [ "$1" = "--config" ]; then config="$2"; shift 2; else shift; fi' ' done' ' cp "${config}" "${CAPTURED_CONFIG}"' ' builtin exit 0' '}' > "${WORK}/fake-gateway-env" +printf '%s\n' '#!/usr/bin/env bash' 'config=""' 'while [ "$#" -gt 0 ]; do' ' if [ "$1" = "--config" ]; then config="$2"; shift 2; else shift; fi' 'done' 'if [ -n "${config}" ]; then cp "${config}" "${CAPTURED_CONFIG}"; fi' > "${WORK}/bin/gateway" +chmod +x "${WORK}/bin/gateway" CAPTURED_CONFIG="${WORK}/generated.toml" -BASH_ENV="${WORK}/fake-gateway-env" CAPTURED_CONFIG="${CAPTURED_CONFIG}" PATH="${WORK}/bin:${PATH}" KUBERNETES_SERVICE_HOST=fixture OPENSHELL_GATEWAY_BIN=/bin/true OPENSHELL_GATEWAY_STATE_DIR="${WORK}/state" OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=IfNotPresent OPENSHELL_GRPC_ENDPOINT=https://callback.example.test:9443 bash "${ROOT}/tasks/scripts/gateway.sh" +CAPTURED_CONFIG="${WORK}/generated.toml" PATH="${WORK}/bin:${PATH}" KUBERNETES_SERVICE_HOST=fixture OPENSHELL_GATEWAY_BIN="${WORK}/bin/gateway" OPENSHELL_GATEWAY_STATE_DIR="${WORK}/state" OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=IfNotPresent OPENSHELL_GRPC_ENDPOINT=https://callback.example.test:9443 bash "${ROOT}/tasks/scripts/gateway.sh" printf '%s\n' 'import sys, tomllib' 'from pathlib import Path' 'config = tomllib.loads(Path(sys.argv[1]).read_text())' 'gateway = config["openshell"]["gateway"]' 'driver = config["openshell"]["drivers"]["kubernetes"]' 'assert config["openshell"]["version"] == 2' 'assert gateway["compute_driver"] == "kubernetes"' 'assert "compute_drivers" not in gateway' 'assert driver["image_pull_policy"] == "if_not_present"' 'assert driver["grpc_endpoint"] == "https://callback.example.test:9443"' > "${WORK}/check_generated.py" "${UV}" run --no-project python "${WORK}/check_generated.py" "${CAPTURED_CONFIG}" From 8d19308c083b794928fb94d00274b337a07c14f7 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 14 Sep 2026 16:49:44 +0000 Subject: [PATCH 05/10] fix(bootstrap): emit RFC 5280 extensions on generated gateway PKI (#3286) generate_pki minted a CA with no key usage and server and client leaves with no Authority Key Identifier. RFC 5280 requires both, and verifiers that enforce it reject the chain: OpenSSL X509_STRICT fails with "Missing Authority Key Identifier", and Python 3.13 turned that flag on by default in ssl.create_default_context(). rustls and BoringSSL do not enforce it, so gRPC clients kept working while an HTTPS client built on Python 3.13 (for example a platform proxying to an exposed sandbox service) could not complete a handshake with a pkiInitJob-provisioned gateway at all. cert-manager PKI was unaffected. Set keyCertSign and cRLSign on the CA and use_authority_key_identifier on both leaves, matching what the sandbox L7 CA already does. Add a test that parses the bundle and asserts the extensions, including that each leaf AKI matches the CA SKI. Verified: openssl verify -x509_strict accepts both leaves, and a strict Python 3.13 client completes an mTLS handshake against a server using the new bundle where the previous bundle reproduces the failure. Signed-off-by: Max Dubrinsky --- Cargo.lock | 1 + crates/openshell-bootstrap/Cargo.toml | 1 + crates/openshell-bootstrap/src/pki.rs | 59 ++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index c633b7f26..e34d588c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3949,6 +3949,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "x509-parser", ] [[package]] diff --git a/crates/openshell-bootstrap/Cargo.toml b/crates/openshell-bootstrap/Cargo.toml index 435024e30..3705bdced 100644 --- a/crates/openshell-bootstrap/Cargo.toml +++ b/crates/openshell-bootstrap/Cargo.toml @@ -22,6 +22,7 @@ tokio = { workspace = true } tracing = { workspace = true } [dev-dependencies] +x509-parser = "0.16" [lints] workspace = true diff --git a/crates/openshell-bootstrap/src/pki.rs b/crates/openshell-bootstrap/src/pki.rs index ed6e839bf..5e5839a11 100644 --- a/crates/openshell-bootstrap/src/pki.rs +++ b/crates/openshell-bootstrap/src/pki.rs @@ -3,7 +3,9 @@ use crate::jwt::{JwtKeyMaterial, generate_jwt_key}; use miette::{IntoDiagnostic, Result, WrapErr}; -use rcgen::{BasicConstraints, CertificateParams, DnType, Ia5String, IsCa, KeyPair, SanType}; +use rcgen::{ + BasicConstraints, CertificateParams, DnType, Ia5String, IsCa, KeyPair, KeyUsagePurpose, SanType, +}; use std::net::IpAddr; /// All PEM-encoded materials produced by [`generate_pki`]. @@ -59,6 +61,7 @@ pub fn generate_pki(extra_sans: &[String]) -> Result { .into_diagnostic() .wrap_err("failed to create CA params")?; ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; ca_params .distinguished_name .push(DnType::OrganizationName, "openshell"); @@ -80,6 +83,7 @@ pub fn generate_pki(extra_sans: &[String]) -> Result { .into_diagnostic() .wrap_err("failed to create server cert params")?; server_params.subject_alt_names = server_sans; + server_params.use_authority_key_identifier_extension = true; server_params .distinguished_name .push(DnType::CommonName, "openshell-server"); @@ -96,6 +100,7 @@ pub fn generate_pki(extra_sans: &[String]) -> Result { let mut client_params = CertificateParams::new(Vec::::new()) .into_diagnostic() .wrap_err("failed to create client cert params")?; + client_params.use_authority_key_identifier_extension = true; client_params .distinguished_name .push(DnType::CommonName, "openshell-client"); @@ -178,6 +183,58 @@ mod tests { assert!(bundle.server_cert_pem.contains("BEGIN CERTIFICATE")); } + #[test] + fn generate_pki_emits_strict_verifier_extensions() { + use x509_parser::pem::parse_x509_pem; + use x509_parser::prelude::{FromDer, ParsedExtension, X509Certificate}; + + let bundle = generate_pki(&[]).expect("generate_pki failed"); + let parse = |pem: &str| -> Vec { + parse_x509_pem(pem.as_bytes()) + .expect("valid PEM") + .1 + .contents + }; + let ca_der = parse(&bundle.ca_cert_pem); + let ca = X509Certificate::from_der(&ca_der).expect("valid CA cert").1; + let ca_key_usage = ca + .key_usage() + .expect("readable key usage") + .expect("CA has a key usage extension"); + assert!(ca_key_usage.value.key_cert_sign()); + assert!(ca_key_usage.value.crl_sign()); + let ca_ski = ca + .get_extension_unique(&x509_parser::oid_registry::OID_X509_EXT_SUBJECT_KEY_IDENTIFIER) + .expect("readable SKI") + .expect("CA has a Subject Key Identifier"); + + for (name, pem) in [ + ("server", &bundle.server_cert_pem), + ("client", &bundle.client_cert_pem), + ] { + let der = parse(pem); + let cert = X509Certificate::from_der(&der).expect("valid leaf cert").1; + let aki = cert + .get_extension_unique( + &x509_parser::oid_registry::OID_X509_EXT_AUTHORITY_KEY_IDENTIFIER, + ) + .expect("readable AKI") + .unwrap_or_else(|| panic!("{name} cert has no Authority Key Identifier")); + let ( + ParsedExtension::AuthorityKeyIdentifier(aki), + ParsedExtension::SubjectKeyIdentifier(ski), + ) = (aki.parsed_extension(), ca_ski.parsed_extension()) + else { + panic!("{name}: unexpected extension shapes"); + }; + let key_id = aki + .key_identifier + .as_ref() + .unwrap_or_else(|| panic!("{name} AKI has no key identifier")); + assert_eq!(key_id.0, ski.0, "{name} AKI must match the CA SKI"); + } + } + #[test] fn build_server_sans_includes_defaults_and_extras() { let extras = vec!["192.168.1.100".to_string(), "remote.host".to_string()]; From cc780d4e17512e8f804f68c153c033fe6584ae3a Mon Sep 17 00:00:00 2001 From: Brandon Squizzato <35474886+bsquizz@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:39:15 +0000 Subject: [PATCH 06/10] feat(helm): add BackendTLSPolicy support (#2728) * feat(helm): add optional BackendTLSPolicy for e2e TLS Add grpcRoute.backendTLSPolicy values to optionally create a BackendTLSPolicy resource that enables end-to-end TLS between the Gateway proxy and the OpenShell gateway pod. The Gateway proxy terminates client-facing TLS and re-encrypts when connecting to the backend, validating the pod's certificate against a user-supplied CA ConfigMap. This removes the requirement to set server.disableTls=true when using HTTPS at the Gateway listener. Supported on OpenShift 4.22+ and other platforms with BackendTLSPolicy support in the Gateway API implementation. Update OpenShift and ingress documentation with e2e TLS instructions. Signed-off-by: Brandon Squizzato * feat(helm,server): auto-create backend CA ConfigMap in certgen hook Extend the generate-certs command with --backend-ca-configmap-name and --backend-ca-source-secret flags. When BackendTLSPolicy is enabled, the certgen pre-install hook creates the CA ConfigMap automatically: - pkiInitJob mode (default): uses the CA from the generated PKI bundle. Fully automatic on first install. - cert-manager mode: reads ca.crt from the server TLS Secret. On first install the Secret does not exist yet (cert-manager reconciles after templates are applied), so the ConfigMap is created on the first helm upgrade. Logs a warning on the initial skip. The caCertificateConfigMapName value now defaults to -backend-ca when empty, so users only need to set backendTLSPolicy.enabled=true. Update certgen RBAC to include configmaps get/create when the feature is enabled. Add CLI arg parsing tests for the new flags. Signed-off-by: Brandon Squizzato * refactor(helm): add server.tls.enableMtls flag for mTLS control Replace automatic mTLS disabling based on BackendTLSPolicy with an explicit server.tls.enableMtls flag that defaults to true. The user is now responsible for setting this to false when using BackendTLSPolicy, as ingress proxies cannot present client certificates to backends. Updated: - values.yaml: Added server.tls.enableMtls (default true) - gateway-config.yaml: Check enableMtls instead of backendTLSPolicy - _gateway-workload.tpl: Check enableMtls for client CA mount - Tests: Updated to use enableMtls flag - Docs: Added enableMtls=false to BackendTLSPolicy examples - README: Document new flag and BackendTLSPolicy requirement Signed-off-by: Brandon Squizzato * docs(helm): clarify cert-manager backend CA ConfigMap workflow Update documentation to explain the two-step install process required when using cert-manager with BackendTLSPolicy: 1. helm install - cert-manager issues the server certificate, but the certgen hook can't create the backend CA ConfigMap yet (cert-manager reconciles after templates are applied) 2. helm upgrade - certgen hook reads the CA from the cert-manager-issued certificate and creates the ConfigMap Previously, the docs said "created on first upgrade" without explaining why or that the feature won't work until then. The updated docs now: - Explain the timing issue (cert-manager reconciles after chart install) - Provide clear steps for the cert-manager workflow - Note that pkiInitJob (default) creates it immediately on install - Clarify that users must wait for the Certificate to be Ready before running the second upgrade Signed-off-by: Brandon Squizzato * fix(docs): remove incorrect external hostname requirement for BackendTLSPolicy BackendTLSPolicy validates the backend certificate against the service FQDN (e.g., openshell.openshell.svc.cluster.local), not the external hostname. The external hostname only needs to be on the Gateway listener certificate for client-facing TLS. The default certManager.serverDnsNames already includes all required service FQDN variants, so no configuration is needed for BackendTLSPolicy to work. Fixed incorrect documentation that claimed: - "The server certificate SAN list must include the external hostname" - Users need to "configure certManager.serverDnsNames with the external hostname" Removed the unnecessary pkiInitJob.serverDnsNames override from the example and clarified that: - Gateway listener certificate needs the external hostname (for clients) - Backend certificate needs the service FQDN (for Gateway proxy) - The service FQDN is already in the defaults Signed-off-by: Brandon Squizzato * docs: clarify ACME with LetsEncrypt reference Change all references from "ACME issuer" to "LetsEncrypt/ACME issuer" to help users understand that LetsEncrypt is the most common ACME provider and what ACME means in practice. Updated: - docs/kubernetes/managing-certificates.mdx - docs/kubernetes/openshift.mdx - deploy/helm/openshell/values.yaml - deploy/helm/openshell/README.md - deploy/helm/openshell/ci/values-openshift-route-cert-manager.yaml Signed-off-by: Brandon Squizzato * docs(openshift): restructure end-to-end TLS options and clarify Gateway hostname Reorganize the OpenShift production deployment documentation: 1. Changed main section from "Production Deployments" to "Options for end-to-end TLS" for better clarity 2. Renamed subsections for consistency and clarity: - "End-to-end TLS using Gateway API and BackendTLSPolicy (OpenShift 4.22+)" - "End-to-end TLS using pass-through Route (all OpenShift versions)" 3. Clarified that the Gateway hostname is typically a wildcard: "typically a wildcard like *.openshell-ingress-gw.example.com" 4. Removed the recommendation to copy the cluster's wildcard certificate from openshift-ingress namespace, as this is not a recommended security best practice These changes make it clearer that users have two end-to-end TLS options and help them understand the typical naming pattern for Gateway hostnames. Signed-off-by: Brandon Squizzato * feat(helm): eliminate two-stage install for BackendTLSPolicy with cert-manager When using BackendTLSPolicy with cert-manager, the certgen hook now polls for up to 90 seconds waiting for cert-manager to issue the TLS certificate before creating the backend CA ConfigMap. This eliminates the need for a second `helm upgrade` in most cases. The hook polls every 2 seconds with progress logging every 10 seconds. If cert-manager takes longer than 90 seconds, the hook times out gracefully and logs a warning, preserving the fallback to manual ConfigMap creation or a second upgrade. The Job's activeDeadlineSeconds is 120s, so the 90s timeout leaves 30s margin for ConfigMap creation and hook completion. Signed-off-by: Brandon Squizzato * feat(helm): add configurable timeout for certgen hook Add `pkiInitJob.timeoutSeconds` Helm value (default 120) to control how long the certgen hook Job can run. When using cert-manager with BackendTLSPolicy, the hook polls for (timeoutSeconds - 30) seconds to leave margin for ConfigMap creation and cleanup. This allows users to increase the timeout for environments where cert-manager takes longer than 90 seconds to issue certificates, without requiring code changes. Example usage: ```yaml pkiInitJob: timeoutSeconds: 180 # Hook polls for 150 seconds ``` Signed-off-by: Brandon Squizzato * docs(helm): document configurable certgen timeout Update documentation to mention the pkiInitJob.timeoutSeconds value and how it affects the cert-manager polling behavior when using BackendTLSPolicy. Signed-off-by: Brandon Squizzato * feat(helm): add configurable failure behavior for certgen timeout Add `pkiInitJob.failOnTimeout` Helm value (default false) to control whether the certgen hook fails or succeeds when cert-manager does not issue a certificate within the polling timeout. When false (default), the hook succeeds with a warning and users can run `helm upgrade` after cert-manager issues the certificate to create the backend CA ConfigMap. This provides backwards-compatible behavior. When true, the hook fails immediately if the timeout is reached, providing clear feedback that BackendTLSPolicy is non-functional. This is useful for strict validation requirements where incomplete installs should fail fast. Example usage: ```yaml pkiInitJob: timeoutSeconds: 180 failOnTimeout: true # Fail install if cert-manager takes >150s ``` Signed-off-by: Brandon Squizzato * feat(helm): change failOnTimeout default to true and add troubleshooting docs Change `pkiInitJob.failOnTimeout` default from false to true to provide immediate feedback when cert-manager does not issue certificates within the polling timeout. This prevents silent failures where BackendTLSPolicy is non-functional but the install appears to succeed. Add comprehensive troubleshooting section to docs/kubernetes/ingress.mdx documenting the specific error "TLS error: Secret is not supplied by SDS" that occurs when the backend CA ConfigMap is missing, with step-by-step resolution instructions. Updated comments in values.yaml to clearly document the default behavior and explain when administrators might see connectivity errors if they override the default to failOnTimeout=false. BREAKING CHANGE: pkiInitJob.failOnTimeout now defaults to true. Helm installs will fail if cert-manager takes longer than (timeoutSeconds - 30) seconds to issue certificates. To restore the old behavior of allowing installs to succeed with a warning, set `pkiInitJob.failOnTimeout=false`. Signed-off-by: Brandon Squizzato * fix(helm): make cert-manager resources pre-install hooks to fix ordering Make Certificate and Issuer resources run as pre-install/pre-upgrade hooks with weight -30, before the certgen hook (weight -20). This fixes the chicken-and-egg problem where the certgen hook was waiting for Secrets created by Certificates that hadn't been created yet. **Hook ordering:** 1. Certificate and Issuer resources created (weight -30) 2. cert-manager issues certificates and creates Secrets 3. certgen hook runs (weight -20), finds Secrets, creates ConfigMap 4. Main resources (StatefulSet, Service, etc.) created Previously, the certgen pre-install hook would run before any resources were created, poll for a non-existent Secret, timeout, and fail. The Certificate resources would never get created because Helm waits for all pre-install hooks to succeed before creating main resources. This fix allows single-stage installs to work reliably as long as cert-manager can issue certificates within the polling timeout. Signed-off-by: Brandon Squizzato * feat(helm): add validation to prevent enableMtls with BackendTLSPolicy Add Helm chart validation that fails the install if both server.tls.enableMtls=true and grpcRoute.backendTLSPolicy.enabled=true are set, since this is an invalid configuration. BackendTLSPolicy requires mTLS to be disabled because the Gateway proxy cannot present client certificates to the backend. This validation provides immediate, clear feedback at install time rather than allowing the misconfiguration to be discovered through runtime errors. Example error message: ``` Error: grpcRoute.backendTLSPolicy requires mTLS to be disabled because the Gateway proxy cannot present client certificates to the backend; set server.tls.enableMtls=false ``` Also updated documentation to mention this validation check. Signed-off-by: Brandon Squizzato * docs(helm): clarify pkiInitJob.timeoutSeconds polling behavior Improve documentation to clearly explain that pkiInitJob.timeoutSeconds controls the Job deadline, but the actual polling timeout is (timeoutSeconds - 30) to reserve 30 seconds for ConfigMap creation and cleanup. Added concrete example: "timeoutSeconds=180 allows 150 seconds of polling" to make the relationship explicit and avoid confusion where users might expect the hook to poll for the full timeout value. Updated both values.yaml inline comments and ingress.mdx documentation for consistency. Signed-off-by: Brandon Squizzato * docs(openshift): remove outdated two-stage install instructions Update OpenShift documentation to reflect that single-stage installs now work with cert-manager and BackendTLSPolicy. The Certificate resources run as pre-install hooks (weight -30) before certgen (weight -20), allowing the hook to poll for and find the issued certificates. Removed the outdated two-step process: 1. helm install (cert-manager issues cert, hook logs warning) 2. helm upgrade (hook creates ConfigMap) Replaced with current single-stage behavior: - Certificate resources created as pre-install hooks - certgen hook polls for up to 90 seconds (configurable) - Single helm install succeeds in most cases - Fails fast by default if timeout reached This brings openshift.mdx in line with the already-updated ingress.mdx documentation. Signed-off-by: Brandon Squizzato * fix(helm): make pkiInitJob.timeoutSeconds the actual polling duration The timeout value now represents the actual polling time that users experience when waiting for cert-manager to issue certificates. The Job activeDeadlineSeconds is set to (timeoutSeconds + 30) to allow buffer time for ConfigMap creation and cleanup. Previously, the hook polled for (timeoutSeconds - 30) seconds, which was confusing when users set timeoutSeconds=180 and only got 150 seconds of actual polling. Updated documentation in values.yaml, ingress.mdx, and openshift.mdx to reflect the clearer behavior. Signed-off-by: Brandon Squizzato * docs(helm): update values.yaml and README with correct polling duration Updated the caCertificateConfigMapName description to reflect that the hook polls for exactly pkiInitJob.timeoutSeconds seconds, not (timeoutSeconds - 30) seconds. Regenerated README.md with helm-docs. Signed-off-by: Brandon Squizzato * docs(kubernetes): add OIDC configuration to helm install and CLI examples Updated all helm install and openshell gateway add examples in ingress.mdx and openshift.mdx to include OIDC issuer and audience configuration. Examples now use concrete placeholder values: - OIDC issuer: https://keycloak.example.com/realms/openshell - OIDC audience: openshell-cli - Hostname: gateway.example.com - ClusterIssuer: letsencrypt-prod This makes it clearer how to configure OIDC authentication, which is required when using BackendTLSPolicy or HTTPS termination since the Gateway proxy cannot present client certificates to the backend. Signed-off-by: Brandon Squizzato * docs(kubernetes): explicitly list OIDC client ID in gateway add examples Added --oidc-client-id openshell-cli to all openshell gateway add commands in ingress.mdx and openshift.mdx, making the default client ID explicit in the examples even though it's the CLI default. This improves clarity and helps users understand the complete OIDC configuration needed for gateway registration. Signed-off-by: Brandon Squizzato * fix(helm): address PR review feedback for BackendTLSPolicy - Read backend CA from the authoritative server Secret instead of the in-memory PKI bundle so enabling BackendTLSPolicy on an existing release uses the CA that actually signed the server certificate. - Reconcile the backend CA ConfigMap on every hook run (compare and update) instead of skipping when it already exists, so CA rotations propagate automatically. - Remove hook annotations from cert-manager Issuer/Certificate resources so they remain regular release objects managed by Helm lifecycle. Split the cert-manager backend CA ConfigMap creation into a separate post-install/post-upgrade hook Job that polls after cert-manager Certificate resources are applied. - Update architecture/gateway.md, docs/reference/gateway-config.mdx, debug-openshell-cluster skill, and helm-dev-environment skill with BackendTLSPolicy, backend CA ConfigMap, enableMtls, and timeout documentation. Signed-off-by: Brandon Squizzato Signed-off-by: Brandon Squizzato * fix(docs): resolve markdown lint errors in helm README and kubernetes docs Escape inline HTML angle brackets in README.md template placeholders, remove trailing spaces, and add blank lines around fenced code blocks in numbered lists. Signed-off-by: Brandon Squizzato * Update docs * fix(helm): escape inline HTML in values.yaml descriptions and sync mise lockfile Wrap `` and `` template placeholders in backticks so markdownlint does not flag them as inline HTML (MD033). Regenerate mise.lock to match current mise.toml after rebase onto main. Signed-off-by: Brandon Squizzato * Run 'mise lock' * fix: align mise.lock with CI mise version output The lockfile was regenerated locally with mise 2026.8.10 which resolves uv Linux artifacts to gnu variants and adds provenance_verified fields, but CI uses v2026.4.25 which produces musl variants without those fields. Signed-off-by: Brandon Squizzato * fix(docs): correct cert-manager hook ordering and clientCaSecretName comment Update ingress.mdx and openshift.mdx to describe Certificate resources as regular release objects with a post-install/post-upgrade Job, matching the current implementation and architecture/gateway.md. Fix values.yaml clientCaSecretName comment to state that "" disables client certificate verification, matching the helper and access-control docs. Signed-off-by: Brandon Squizzato --------- Signed-off-by: Brandon Squizzato Signed-off-by: Brandon Squizzato Signed-off-by: Brandon Squizzato --- .agents/skills/helm-dev-environment/SKILL.md | 29 +++ architecture/gateway.md | 19 ++ crates/openshell-server/src/certgen.rs | 196 +++++++++++++++++- crates/openshell-server/src/cli.rs | 53 +++++ deploy/helm/openshell/README.md | 13 +- deploy/helm/openshell/README.md.gotmpl | 3 + .../values-openshift-route-cert-manager.yaml | 2 +- deploy/helm/openshell/templates/_helpers.tpl | 8 + .../templates/backend-tls-policy.yaml | 29 +++ deploy/helm/openshell/templates/certgen.yaml | 68 +++++- deploy/helm/openshell/templates/gateway.yaml | 4 +- .../openshell/tests/gateway_config_test.yaml | 18 ++ .../tests/statefulset_client_ca_test.yaml | 19 ++ deploy/helm/openshell/values.yaml | 58 +++++- docs/kubernetes/ingress.mdx | 77 ++++++- docs/kubernetes/managing-certificates.mdx | 2 +- docs/kubernetes/openshift.mdx | 137 ++++++++++-- docs/reference/gateway-config.mdx | 3 + mise.lock | 8 - skills/debug-openshell-cluster/SKILL.md | 25 +++ 20 files changed, 735 insertions(+), 36 deletions(-) create mode 100644 deploy/helm/openshell/templates/backend-tls-policy.yaml diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index 780cf8b9c..15198a891 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -225,6 +225,35 @@ Envoy Gateway is already installed by Skaffold (the `envoy-gateway` Helm release service for the proxy; klipper-lb binds it to hostPort 80, reachable via the `8080:80` load balancer port mapping. +### BackendTLSPolicy (end-to-end TLS) + +To enable end-to-end TLS between the Gateway proxy and the gateway pod, add +BackendTLSPolicy values to the Helm install: + +```bash +helm upgrade --install openshell deploy/helm/openshell \ + --set grpcRoute.enabled=true \ + --set grpcRoute.backendTLSPolicy.enabled=true \ + --set server.tls.enableMtls=false \ + ... +``` + +This requires `server.tls.enableMtls=false` because ingress proxies cannot +present client certificates to the backend. The certgen hook creates a backend +CA ConfigMap from the server Secret's `ca.crt` key. With cert-manager, a +separate post-install Job polls for the cert-manager-issued certificate (up to +`pkiInitJob.timeoutSeconds`); with built-in PKI the ConfigMap is created in the +same pre-install hook. The ConfigMap is reconciled on every upgrade so CA +rotations propagate automatically. + +Key Helm values: +- `grpcRoute.backendTLSPolicy.enabled`: create the BackendTLSPolicy resource +- `grpcRoute.backendTLSPolicy.caCertificateConfigMapName`: override ConfigMap name +- `grpcRoute.backendTLSPolicy.hostname`: override backend validation hostname +- `server.tls.enableMtls`: must be `false` for BackendTLSPolicy +- `pkiInitJob.timeoutSeconds`: polling duration for cert-manager mode +- `pkiInitJob.failOnTimeout`: fail install if cert-manager times out + ### Keycloak OIDC One-time setup — only needed once per cluster lifetime: diff --git a/architecture/gateway.md b/architecture/gateway.md index 113eb5603..3881618db 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -762,6 +762,25 @@ requested present -> generate and write. This guards continuity across restarts and upgrades while still recovering cleanly if an operator deletes everything and starts over. +When `grpcRoute.backendTLSPolicy.enabled=true`, the certgen hook also creates a +`ConfigMap` containing the CA certificate (`ca.crt`) used by the Gateway proxy +to validate the backend pod's TLS certificate. The CA is always read from the +authoritative server Secret (not the in-memory bundle) so that enabling +BackendTLSPolicy on an existing release uses the CA that actually signed the +server certificate. The ConfigMap is reconciled on every hook run: if the CA +changes (rotation, re-issue), the ConfigMap is updated in place. In built-in PKI +mode the ConfigMap is created in the same pre-install hook. In cert-manager +mode, a separate post-install/post-upgrade hook Job polls for the cert-manager- +issued server Secret and then creates or updates the ConfigMap, because +cert-manager Certificate resources are regular release objects applied after +pre-install hooks. + +The `server.tls.enableMtls` value controls whether the gateway requires client +certificates. When `enableMtls` is `false`, the gateway runs HTTPS-only without +client certificate verification (use OIDC for identity instead). BackendTLSPolicy +requires `enableMtls=false` because the ingress proxy cannot present client +certificates to the backend. + Operators who manage TLS PKI with cert-manager enable `certManager.enabled`; cert-manager takes precedence over built-in TLS generation and the chart still renders the JWT-only hook. Operators who pre-create all TLS and JWT Secrets can diff --git a/crates/openshell-server/src/certgen.rs b/crates/openshell-server/src/certgen.rs index 1f3ffca02..764ceba98 100644 --- a/crates/openshell-server/src/certgen.rs +++ b/crates/openshell-server/src/certgen.rs @@ -24,7 +24,7 @@ use clap::Args; use k8s_openapi::ByteString; -use k8s_openapi::api::core::v1::Secret; +use k8s_openapi::api::core::v1::{ConfigMap, Secret}; use kube::Client; use kube::api::{Api, ObjectMeta, PostParams}; use miette::{IntoDiagnostic, Result, WrapErr}; @@ -78,6 +78,33 @@ pub struct CertgenArgs { /// For local debugging. #[arg(long)] dry_run: bool, + + /// Name of a `ConfigMap` to create containing the CA certificate (key: ca.crt) + /// for `BackendTLSPolicy` backend validation. The CA is always read from the + /// authoritative server Secret: --server-secret-name in full PKI mode, + /// --backend-ca-source-secret in --jwt-only mode. + #[arg(long, value_name = "NAME")] + backend_ca_configmap_name: Option, + + /// Name of an existing `Secret` containing a ca.crt key to populate the + /// backend CA `ConfigMap` from. Required with --jwt-only when + /// --backend-ca-configmap-name is set (typically the server TLS `Secret` + /// created by cert-manager). + #[arg(long, value_name = "NAME", requires = "backend_ca_configmap_name")] + backend_ca_source_secret: Option, + + /// Maximum time in seconds to poll for the backend CA source `Secret` when + /// using cert-manager. Defaults to 90 seconds. The Helm chart sets this to + /// (Job activeDeadlineSeconds - 30) to leave margin for `ConfigMap` creation. + #[arg(long, value_name = "SECONDS", default_value = "90")] + backend_ca_poll_timeout_seconds: u64, + + /// Fail with an error if the backend CA source `Secret` is not found within + /// the polling timeout. When false (default), the hook succeeds with a warning + /// and the `ConfigMap` is not created. The Helm chart sets this based on + /// pkiInitJob.failOnTimeout. + #[arg(long)] + backend_ca_fail_on_timeout: bool, } pub async fn run(args: CertgenArgs) -> Result<()> { @@ -97,7 +124,13 @@ pub async fn run(args: CertgenArgs) -> Result<()> { run_local(dir, &args.server_sans) } else { let bundle = generate_pki(&args.server_sans)?; - run_kubernetes(&args, &bundle).await + run_kubernetes(&args, &bundle).await?; + + if let Some(ref cm_name) = args.backend_ca_configmap_name { + create_backend_ca_configmap_if_needed(&args, cm_name).await?; + } + + Ok(()) } } @@ -293,6 +326,165 @@ async fn create_tls_secrets( Ok(()) } +fn extract_ca_from_secret(secret: &Secret, name: &str) -> Result { + let data = secret + .data + .as_ref() + .ok_or_else(|| miette::miette!("secret {name} has no data"))?; + let ca = data + .get("ca.crt") + .ok_or_else(|| miette::miette!("secret {name} has no ca.crt key"))?; + String::from_utf8(ca.0.clone()) + .into_diagnostic() + .wrap_err("ca.crt is not valid UTF-8") +} + +async fn create_backend_ca_configmap_if_needed( + args: &CertgenArgs, + configmap_name: &str, +) -> Result<()> { + let namespace = args + .namespace + .as_deref() + .ok_or_else(|| miette::miette!("--namespace is required (or set POD_NAMESPACE)"))?; + + let client = Client::try_default() + .await + .into_diagnostic() + .wrap_err("failed to construct Kubernetes client for backend CA ConfigMap")?; + let secret_api: Api = Api::namespaced(client.clone(), namespace); + let cm_api: Api = Api::namespaced(client, namespace); + + // Resolve the CA from the authoritative server Secret rather than the + // in-memory bundle so upgrades that enable BackendTLSPolicy on an + // existing release use the CA that actually signed the server cert. + let ca_pem = if let Some(source_secret) = &args.backend_ca_source_secret { + let poll_timeout = std::time::Duration::from_secs(args.backend_ca_poll_timeout_seconds); + let poll_interval = std::time::Duration::from_secs(2); + let start = std::time::Instant::now(); + + let secret = loop { + match secret_api + .get_opt(source_secret) + .await + .into_diagnostic() + .wrap_err_with(|| format!("failed to read secret {source_secret}"))? + { + Some(secret) => break secret, + None if start.elapsed() >= poll_timeout => { + let msg = format!( + "Backend CA source secret {source_secret} not found after {timeout_secs}s; \ + ConfigMap {configmap_name} not created. This is expected if cert-manager \ + is still issuing the certificate.", + timeout_secs = poll_timeout.as_secs() + ); + if args.backend_ca_fail_on_timeout { + return Err(miette::miette!( + "{msg} Install failed due to --backend-ca-fail-on-timeout." + )); + } + warn!( + secret = %source_secret, + configmap = %configmap_name, + timeout_secs = poll_timeout.as_secs(), + "{msg} Run helm upgrade after the TLS secret exists or the BackendTLSPolicy \ + will remain non-functional until the ConfigMap is created manually." + ); + return Ok(()); + } + None => { + if start.elapsed().as_secs().is_multiple_of(10) { + info!( + secret = %source_secret, + elapsed_secs = start.elapsed().as_secs(), + "Waiting for cert-manager to issue TLS certificate..." + ); + } + tokio::time::sleep(poll_interval).await; + } + } + }; + + info!( + secret = %source_secret, + elapsed_secs = start.elapsed().as_secs(), + "cert-manager TLS certificate found." + ); + extract_ca_from_secret(&secret, source_secret)? + } else if let Some(server_secret) = &args.server_secret_name { + let secret = secret_api + .get(server_secret) + .await + .into_diagnostic() + .wrap_err_with(|| format!("failed to read server secret {server_secret}"))?; + extract_ca_from_secret(&secret, server_secret)? + } else { + return Err(miette::miette!( + "--backend-ca-source-secret or --server-secret-name is required \ + with --backend-ca-configmap-name" + )); + }; + + // Reconcile: create or update so the backend CA stays current across + // CA rotations and upgrades. + if let Some(existing) = cm_api + .get_opt(configmap_name) + .await + .into_diagnostic() + .wrap_err_with(|| format!("failed to read configmap {configmap_name}"))? + { + let up_to_date = existing + .data + .as_ref() + .and_then(|d| d.get("ca.crt")) + .map(String::as_str) + == Some(&ca_pem); + if up_to_date { + info!( + namespace = %namespace, + configmap = %configmap_name, + "Backend CA ConfigMap is up-to-date, skipping." + ); + return Ok(()); + } + let mut updated = existing; + updated.data = Some(BTreeMap::from([("ca.crt".to_string(), ca_pem)])); + cm_api + .replace(configmap_name, &PostParams::default(), &updated) + .await + .into_diagnostic() + .wrap_err_with(|| format!("failed to update configmap {configmap_name}"))?; + info!( + namespace = %namespace, + configmap = %configmap_name, + "Backend CA ConfigMap updated with current CA." + ); + return Ok(()); + } + + let configmap = ConfigMap { + metadata: ObjectMeta { + name: Some(configmap_name.to_string()), + ..Default::default() + }, + data: Some(BTreeMap::from([("ca.crt".to_string(), ca_pem)])), + ..Default::default() + }; + + cm_api + .create(&PostParams::default(), &configmap) + .await + .into_diagnostic() + .wrap_err_with(|| format!("failed to create configmap {configmap_name}"))?; + + info!( + namespace = %namespace, + configmap = %configmap_name, + "Backend CA ConfigMap created." + ); + Ok(()) +} + fn tls_secret(name: &str, crt_pem: &str, key_pem: &str, ca_pem: &str) -> Secret { let mut data = BTreeMap::new(); data.insert( diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index c11d89561..9b01b749a 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -2314,6 +2314,59 @@ mod tests { .expect("complete package-generated TLS paths may not exist before certificate generation"); } + #[test] + fn generate_certs_backend_ca_configmap_flags_parse() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); + let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); + + let cli = Cli::try_parse_from([ + "openshell-gateway", + "generate-certs", + "--namespace", + "openshell", + "--jwt-only", + "--jwt-secret-name", + "openshell-jwt-keys", + "--backend-ca-configmap-name", + "openshell-backend-ca", + "--backend-ca-source-secret", + "openshell-server-tls", + ]) + .expect("backend CA ConfigMap flags should parse with --jwt-only"); + + assert!(matches!( + cli.command, + Some(super::Commands::GenerateCerts(_)) + )); + } + + #[test] + fn generate_certs_backend_ca_source_secret_requires_configmap_name() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); + let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); + + let err = Cli::try_parse_from([ + "openshell-gateway", + "generate-certs", + "--namespace", + "openshell", + "--jwt-only", + "--jwt-secret-name", + "openshell-jwt-keys", + "--backend-ca-source-secret", + "openshell-server-tls", + ]) + .expect_err("--backend-ca-source-secret should require --backend-ca-configmap-name"); + + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + #[test] fn bare_invocation_with_no_db_url_parses_for_runtime_defaults() { // db_url is Option at the clap level so subcommand parsing diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 92bd2a9db..3c32e24b6 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -56,6 +56,9 @@ helm install openshell oci://ghcr.io/nvidia/openshell/helm-chart --version -backend-ca` when empty. The certgen hook auto-creates this: with pkiInitJob (default), immediately on install/upgrade; with cert-manager, the hook polls for pkiInitJob.timeoutSeconds seconds waiting for cert-manager to issue the server certificate, then creates the ConfigMap. A single install usually succeeds; if cert-manager takes longer, increase pkiInitJob.timeoutSeconds. By default (pkiInitJob.failOnTimeout=true), the install fails if the timeout is reached; set failOnTimeout=false to allow the install to succeed and run `helm upgrade` after the certificate is issued. | +| grpcRoute.backendTLSPolicy.enabled | bool | `false` | Create a BackendTLSPolicy resource for end-to-end TLS between the Gateway proxy and the OpenShell gateway pod. The traffic flow is: client → HTTPS → Gateway (terminate) → TLS (re-encrypt) → gateway pod. Requires server.disableTls=false and server.tls.enableMtls=false. The certgen hook auto-creates the backend CA ConfigMap. | +| grpcRoute.backendTLSPolicy.hostname | string | `""` | Hostname the Gateway proxy validates against the backend's TLS certificate SAN. Defaults to the service FQDN (`..svc.cluster.local`) when empty, which matches the SAN included by both cert-manager and the pkiInitJob. | | grpcRoute.enabled | bool | `false` | Create a Gateway API GRPCRoute for the gateway service. | | grpcRoute.gateway.className | string | `"eg"` | GatewayClass to reference. Envoy Gateway installs one named "eg". | | grpcRoute.gateway.create | bool | `false` | When true, a Gateway resource is created in the release namespace. Set to false and provide name/namespace to attach to a pre-existing Gateway. | @@ -204,8 +210,10 @@ discovery endpoint or its TLS CA. | openshiftRoute.enabled | bool | `false` | Create an OpenShift Route with TLS passthrough. | | openshiftRoute.host | string | `""` | Hostname for the Route. Must match a SAN on the gateway's server cert. | | pkiInitJob.enabled | bool | `true` | Run a pre-install/pre-upgrade Job that creates gateway and client mTLS Secrets. When certManager.enabled=true, cert-manager owns TLS and this same hook runs in JWT-only mode even if pkiInitJob.enabled remains true. | +| pkiInitJob.failOnTimeout | bool | `true` | Fail the helm install/upgrade if cert-manager does not issue the certificate within the polling timeout. When true (default), the install fails immediately if the timeout is reached, providing clear feedback that BackendTLSPolicy is non-functional. When false, the hook succeeds with a warning and you can run `helm upgrade` after cert-manager issues the certificate to create the backend CA ConfigMap. If you set this to false and see "TLS error: Secret is not supplied by SDS" when connecting to the gateway, check if the TLS secret exists and run `helm upgrade` to create the ConfigMap. | | pkiInitJob.serverDnsNames | list | `[]` | Extra DNS SANs to append to the server certificate. | | pkiInitJob.serverIpAddresses | list | `[]` | Extra IP SANs to append to the server certificate. | +| pkiInitJob.timeoutSeconds | int | `120` | Maximum time in seconds for the certgen hook to poll for cert-manager certificates. When using cert-manager with BackendTLSPolicy, the hook polls for this many seconds waiting for the certificate to be issued, then creates the backend CA ConfigMap. The Job deadline is set to (timeoutSeconds + 30) to allow time for ConfigMap creation and cleanup. Increase this if cert-manager takes longer than 120 seconds to issue certificates. | | podAnnotations | object | `{}` | Extra annotations to add to the gateway pod. | | podLabels | object | `{}` | Extra labels to add to the gateway pod. | | podLifecycle.terminationGracePeriodSeconds | int | `5` | Grace period, in seconds, before Kubernetes terminates the gateway pod. | @@ -286,8 +294,9 @@ discovery endpoint or its TLS CA. | server.sandboxNamespace | string | `""` | Namespace where sandbox pods are created. Defaults to the Helm release namespace (.Release.Namespace) when left empty. | | server.telemetryEnabled | bool | `true` | Enable anonymous OpenShell telemetry from the gateway and the sandbox supervisors it launches. | | server.tls.certSecretName | string | `"openshell-server-tls"` | K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server. | -| server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). Do not set to null; omit the key to use the default secret name above. | +| server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Only used when enableMtls is true. Set to "" to disable client certificate verification for HTTPS-only mode. | | server.tls.clientTlsSecretName | string | `"openshell-client-tls"` | K8s secret mounted into sandbox pods for mTLS to the server. | +| server.tls.enableMtls | bool | `true` | Enable mTLS client certificate authentication. When false, the gateway runs HTTPS-only without requiring client certificates (use OIDC for auth instead). Must be false when using BackendTLSPolicy because ingress proxies cannot present client certificates to the backend. | | server.workspaceDefaultStorageSize | string | `""` | Default storage size for the workspace PVC in sandbox pods. Uses Kubernetes quantity syntax (e.g. "2Gi", "10Gi", "500Mi"). Empty = built-in default (2Gi). | | server.workspaceStorageClass | string | `""` | Kubernetes StorageClass for the workspace PVC in sandbox pods. Empty (default) = omit storageClassName, using the cluster's default StorageClass. Set this on clusters with no default StorageClass, otherwise the workspace PVC stays Pending and the sandbox never starts. | | service.healthPort | int | `8081` | Gateway health service port. | diff --git a/deploy/helm/openshell/README.md.gotmpl b/deploy/helm/openshell/README.md.gotmpl index cf8677741..6e6f2012f 100644 --- a/deploy/helm/openshell/README.md.gotmpl +++ b/deploy/helm/openshell/README.md.gotmpl @@ -56,6 +56,9 @@ helm install openshell oci://ghcr.io/nvidia/openshell/helm-chart --version -backend-ca` when empty. The certgen hook auto-creates this: + # with pkiInitJob (default), immediately on install/upgrade; with + # cert-manager, the hook polls for pkiInitJob.timeoutSeconds seconds + # waiting for cert-manager to issue the server certificate, then creates the + # ConfigMap. A single install usually succeeds; if cert-manager takes longer, + # increase pkiInitJob.timeoutSeconds. By default (pkiInitJob.failOnTimeout=true), + # the install fails if the timeout is reached; set failOnTimeout=false to allow + # the install to succeed and run `helm upgrade` after the certificate is issued. + caCertificateConfigMapName: "" + # -- Hostname the Gateway proxy validates against the backend's TLS + # certificate SAN. Defaults to the service FQDN + # (`..svc.cluster.local`) when empty, which matches + # the SAN included by both cert-manager and the pkiInitJob. + hostname: "" # OpenShift Route with TLS passthrough. The gateway terminates its own # TLS/mTLS; the router only forwards based on SNI, so it never sees plaintext diff --git a/docs/kubernetes/ingress.mdx b/docs/kubernetes/ingress.mdx index 51284465d..85d12fc8d 100644 --- a/docs/kubernetes/ingress.mdx +++ b/docs/kubernetes/ingress.mdx @@ -120,8 +120,9 @@ helm upgrade --install openshell \ --set grpcRoute.gateway.listener.port=443 \ --set 'grpcRoute.gateway.listener.tls.certificateRefs[0].name=openshell-ingress-tls' \ --set server.disableTls=true \ - --set server.oidc.issuer=https:// \ - --set 'grpcRoute.hostnames[0]=' + --set server.oidc.issuer=https://keycloak.example.com/realms/openshell \ + --set server.oidc.audience=openshell-cli \ + --set 'grpcRoute.hostnames[0]=gateway.example.com' ``` Keep the certificate Secret in the release namespace. Referencing a Secret in another namespace requires a `ReferenceGrant`. @@ -129,12 +130,82 @@ Keep the certificate Secret in the release namespace. Referencing a Secret in an ### Register over HTTPS ```shell -openshell gateway add https:// --name production --oidc-issuer https:// +openshell gateway add https://gateway.example.com \ + --name production \ + --oidc-issuer https://keycloak.example.com/realms/openshell \ + --oidc-client-id openshell-cli openshell status ``` See [Authentication](/kubernetes/setup) for OIDC issuer, audience, and roles configuration. +## End-to-end TLS (BackendTLSPolicy) + +As an alternative to the plaintext backend path above, the chart can create a `BackendTLSPolicy` that tells the Gateway proxy to re-encrypt traffic when connecting to the OpenShell gateway pod: + +```text +client → HTTPS → Gateway (terminate TLS) → TLS (re-encrypt) → openshell gateway pod +``` + +This keeps TLS on the gateway pod rather than disabling it with `server.disableTls=true`. The Gateway proxy validates the backend's certificate against a CA ConfigMap that the certgen hook auto-creates. + +BackendTLSPolicy is a standard Gateway API resource. It is supported on OpenShift 4.22+ (via the OpenShift gateway controller) and on other platforms where the Gateway API implementation supports it (check your controller's documentation). + +### Install with e2e TLS + +The certgen hook automatically creates the backend CA ConfigMap when `backendTLSPolicy` is enabled: + +```shell +helm upgrade --install openshell \ + oci://ghcr.io/nvidia/openshell/helm-chart \ + --version \ + --namespace openshell \ + --set server.tls.enableMtls=false \ + --set grpcRoute.enabled=true \ + --set grpcRoute.gateway.create=true \ + --set grpcRoute.gateway.className=eg \ + --set grpcRoute.gateway.listener.protocol=HTTPS \ + --set grpcRoute.gateway.listener.port=443 \ + --set 'grpcRoute.gateway.listener.tls.certificateRefs[0].name=openshell-ingress-tls' \ + --set grpcRoute.backendTLSPolicy.enabled=true \ + --set server.oidc.issuer=https://keycloak.example.com/realms/openshell \ + --set server.oidc.audience=openshell-cli \ + --set 'grpcRoute.hostnames[0]=gateway.example.com' +``` + +Note that `server.disableTls` is **not** set — the gateway pod continues to serve TLS — but `server.tls.enableMtls=false` disables mTLS client certificate authentication because the Gateway proxy cannot present a client certificate to the backend. The chart will fail the install if you try to enable both `grpcRoute.backendTLSPolicy.enabled=true` and `server.tls.enableMtls=true` simultaneously. The BackendTLSPolicy hostname defaults to the service FQDN, which matches the SAN on the server certificate. Use OIDC for authentication (configured via `server.oidc.issuer`). + +The example above uses the default `pkiInitJob` for TLS, which creates the backend CA ConfigMap immediately. If using cert-manager instead (`--set certManager.enabled=true`), the Certificate resources are regular release objects, and a separate post-install/post-upgrade Job (`-certgen-backend-ca`) polls for up to 120 seconds waiting for cert-manager to issue the server certificate, then creates the backend CA ConfigMap. This means a single `helm install` is sufficient in most cases. + +If cert-manager takes longer than 120 seconds to issue certificates, increase the polling timeout with `--set pkiInitJob.timeoutSeconds=`. The hook polls for exactly this many seconds. For example, `timeoutSeconds=180` polls for 180 seconds. By default (`pkiInitJob.failOnTimeout=true`), the install fails if the timeout is reached, providing clear feedback that the BackendTLSPolicy is non-functional. + +### Troubleshooting + +If you see the error `remote connection failure, transport failure reason: TLS error: Secret is not supplied by SDS` when connecting through the Gateway: + +1. Check if the backend CA ConfigMap exists: + + ```shell + kubectl get configmap -backend-ca -n + ``` + +2. If the ConfigMap is missing, verify the TLS secret exists: + + ```shell + kubectl get secret -server-tls -n + ``` + +3. If the secret exists but the ConfigMap doesn't, run `helm upgrade` to create it: + + ```shell + helm upgrade oci://ghcr.io/nvidia/openshell/helm-chart \ + --reuse-values --namespace + ``` + +This situation can occur if you set `pkiInitJob.failOnTimeout=false` and cert-manager issued the certificate after the hook timed out. + +For OpenShift 4.22+, see [OpenShift](/kubernetes/openshift#end-to-end-tls-openshift-422) for platform-specific instructions including Gateway and GatewayClass setup. + ## SSH Relay Sandbox SSH uses the gateway endpoint registered with the CLI. No separate Helm SSH host or port values are required. diff --git a/docs/kubernetes/managing-certificates.mdx b/docs/kubernetes/managing-certificates.mdx index c4cb07f57..d97c1e24b 100644 --- a/docs/kubernetes/managing-certificates.mdx +++ b/docs/kubernetes/managing-certificates.mdx @@ -66,7 +66,7 @@ By default, cert-manager issues both the server and client certificates from a self-signed CA the chart creates — this rotates automatically, but the server certificate is still not publicly trusted. `certManager.serverIssuerRef` overrides the `issuerRef` on the server `Certificate` resource to point at a -real `Issuer` or `ClusterIssuer` instead, for example an ACME issuer: +real `Issuer` or `ClusterIssuer` instead, for example a LetsEncrypt/ACME issuer: ```shell helm upgrade --install openshell \ diff --git a/docs/kubernetes/openshift.mdx b/docs/kubernetes/openshift.mdx index 43e7d0338..cfae001a3 100644 --- a/docs/kubernetes/openshift.mdx +++ b/docs/kubernetes/openshift.mdx @@ -87,13 +87,123 @@ openshell gateway add http://127.0.0.1:8080 --local --name openshift openshell status ``` -## Production: expose externally with a real certificate +## Options for end-to-end TLS -The steps above run the gateway over plaintext HTTP for quick evaluation. For -a real deployment, cert-manager can issue the gateway's server certificate -from a real Issuer or ClusterIssuer (for example, an ACME issuer), and an -OpenShift Route with TLS passthrough exposes it externally while the gateway -keeps terminating its own TLS and mTLS. +The steps above run the gateway over plaintext HTTP for quick evaluation. For production deployments, choose one of the approaches below based on your OpenShift version and preferences. + +### End-to-end TLS using Gateway API and BackendTLSPolicy (OpenShift 4.22+) + +OpenShift 4.22 and later support `BackendTLSPolicy` in the Gateway API, enabling end-to-end TLS between the OpenShift router and the OpenShell gateway pod. The traffic flow is: + +```text +client → HTTPS → OpenShift Gateway (terminate TLS) → TLS (re-encrypt) → openshell gateway pod +``` + +This removes the requirement to run the gateway with `server.disableTls=true`. The OpenShift router terminates client-facing TLS at the listener and re-encrypts when connecting to the backend service, validating the backend's certificate against a CA you provide. + +#### Prerequisites + +- OpenShift 4.22+ cluster with the Gateway API enabled +- cert-manager installed (recommended) or the built-in pkiInitJob for server certificates +- A `GatewayClass` registered for the OpenShift gateway controller + +#### Create the GatewayClass + +If your cluster does not already have an OpenShift GatewayClass, create one: + +```shell +oc apply -f - <<'EOF' +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: openshift-default +spec: + controllerName: openshift.io/gateway-controller/v1 +EOF +``` + +#### Create the Gateway + +Create a Gateway resource in the `openshift-ingress` namespace. Replace `` with your cluster's route hostname (typically a wildcard like `*.openshell-ingress-gw.example.com`): + +```shell +oc apply -f - <<'EOF' +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: openshell-gateway + namespace: openshift-ingress +spec: + gatewayClassName: openshift-default + listeners: + - name: grpc + hostname: "" + port: 443 + protocol: HTTPS + tls: + mode: Terminate + certificateRefs: + - name: + kind: Secret + allowedRoutes: + namespaces: + from: Selector + selector: + matchLabels: + kubernetes.io/metadata.name: openshell +EOF +``` + +The listener TLS Secret should contain the certificate for the external hostname. + +#### Install with e2e TLS + +Install the chart with the GRPCRoute and BackendTLSPolicy enabled. The certgen hook automatically creates the backend CA ConfigMap from the generated PKI bundle: + +```shell +helm install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ + --version \ + --namespace openshell \ + --set podSecurityContext.fsGroup=null \ + --set securityContext.runAsUser=null \ + --set server.tls.enableMtls=false \ + --set grpcRoute.enabled=true \ + --set grpcRoute.gateway.name=openshell-gateway \ + --set grpcRoute.gateway.namespace=openshift-ingress \ + --set 'grpcRoute.hostnames[0]=gateway.example.com' \ + --set grpcRoute.backendTLSPolicy.enabled=true \ + --set server.oidc.issuer=https://keycloak.example.com/realms/openshell \ + --set server.oidc.audience=openshell-cli +``` + +| Override | Reason | +|---|---| +| `podSecurityContext.fsGroup=null` / `securityContext.runAsUser=null` | Let OpenShift's SCC admission assign UIDs. | +| `server.tls.enableMtls=false` | Disable mTLS client certificate authentication. BackendTLSPolicy only validates the server certificate; the ingress proxy cannot present a client certificate to the backend. Use OIDC for authentication instead. | +| `grpcRoute.enabled=true` | Create a GRPCRoute pointing at the external Gateway. | +| `grpcRoute.gateway.name` / `namespace` | Reference the Gateway created above in `openshift-ingress`. | +| `grpcRoute.backendTLSPolicy.enabled=true` | Create a BackendTLSPolicy for TLS re-encryption to the gateway pod. The certgen hook auto-creates the backend CA ConfigMap. The Gateway proxy validates the backend certificate against the service FQDN, which is already in the default server certificate SANs. | +| `grpcRoute.hostnames` | External hostname for the GRPCRoute. This goes on the Gateway listener certificate, not the backend certificate. | + +Note that `server.disableTls` is **not** set — the gateway pod serves TLS over HTTPS without requiring client certificates. Use OIDC for authentication (see [Access Control](/kubernetes/access-control)). + +**Using cert-manager instead of pkiInitJob:** Add `--set certManager.enabled=true` to the install command. The default `certManager.serverDnsNames` already includes the service FQDN needed for BackendTLSPolicy validation. The Certificate resources are regular release objects, and a separate post-install/post-upgrade Job (`-certgen-backend-ca`) polls for up to 120 seconds waiting for cert-manager to issue the server certificate, then creates the backend CA ConfigMap. A single `helm install` is sufficient in most cases. + +If cert-manager takes longer than 120 seconds to issue certificates, increase the polling timeout with `--set pkiInitJob.timeoutSeconds=`. The hook polls for exactly this many seconds. For example, `timeoutSeconds=180` polls for 180 seconds. By default (`pkiInitJob.failOnTimeout=true`), the install fails if the timeout is reached, providing clear feedback that the BackendTLSPolicy is non-functional. + +#### Register over HTTPS + +```shell +openshell gateway add https://gateway.example.com \ + --name openshift \ + --oidc-issuer https://keycloak.example.com/realms/openshell \ + --oidc-client-id openshell-cli +openshell status +``` + +### End-to-end TLS using pass-through Route (all OpenShift versions) + +For OpenShift versions prior to 4.22, or when you prefer Route-based ingress, cert-manager can issue the gateway's server certificate from a real Issuer or ClusterIssuer (for example, a LetsEncrypt/ACME issuer), and an OpenShift Route with TLS passthrough exposes it externally while the gateway keeps terminating its own TLS and mTLS. Install cert-manager and configure a working `ClusterIssuer` first — see [Managing Certificates](/kubernetes/managing-certificates) for the @@ -110,13 +220,13 @@ helm install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ --set securityContext.runAsUser=null \ --set server.disableTls=false \ --set certManager.enabled=true \ - --set certManager.serverIssuerRef.name= \ + --set certManager.serverIssuerRef.name=letsencrypt-prod \ --set certManager.serverIssuerRef.kind=ClusterIssuer \ - --set certManager.serverDnsNames[0]= \ + --set certManager.serverDnsNames[0]=gateway.example.com \ --set openshiftRoute.enabled=true \ - --set openshiftRoute.host= \ - --set server.oidc.issuer= \ - --set server.oidc.audience= + --set openshiftRoute.host=gateway.example.com \ + --set server.oidc.issuer=https://keycloak.example.com/realms/openshell \ + --set server.oidc.audience=openshell-cli ``` | Override | Reason | @@ -129,9 +239,10 @@ Register the gateway with the CLI over OIDC. Remote gateways authenticate CLI users via OIDC, not mTLS — see [Access Control](/kubernetes/access-control): ```shell -openshell gateway add https:// \ +openshell gateway add https://gateway.example.com \ --name openshift \ - --oidc-issuer + --oidc-issuer https://keycloak.example.com/realms/openshell \ + --oidc-client-id openshell-cli openshell gateway login openshift ``` diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index e2d48087c..f9a3f0032 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -522,6 +522,9 @@ compute_driver = "kubernetes" [openshell.gateway.tls] cert_path = "/etc/openshell-tls/server/tls.crt" key_path = "/etc/openshell-tls/server/tls.key" +# client_ca_path is only rendered when server.tls.enableMtls is true (the +# default). When enableMtls is false — required for BackendTLSPolicy — the +# gateway runs HTTPS-only and this line is omitted by Helm. client_ca_path = "/etc/openshell-tls/client-ca/ca.crt" # When cert-manager serverIssuerRef is configured, these are populated by Helm: # external_cert_path = "/etc/openshell-tls/server-external/tls.crt" diff --git a/mise.lock b/mise.lock index 6e0d6d4ef..a5e38be2c 100644 --- a/mise.lock +++ b/mise.lock @@ -168,12 +168,6 @@ url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9 url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/501885637" provenance = "github-attestations" -[tools."github:nextest-rs/nextest"."platforms.windows-arm64"] -checksum = "sha256:58c1637ba2396e6c556aa0092f9aa4388695594b8ddda5a4b8b39212574678ce" -url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-aarch64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/501886128" -provenance = "github-attestations" - [tools."github:nextest-rs/nextest"."platforms.windows-x64"] checksum = "sha256:c670ba18e8731fd2eff33a47af33a0fa53d1afa6d0678344e82dc6f8fc7344ac" url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-x86_64-pc-windows-msvc.zip" @@ -463,13 +457,11 @@ backend = "aqua:astral-sh/uv" [tools.uv."platforms.linux-arm64"] checksum = "sha256:55bd1c1c10ec8b95a8c184f5e18b566703c6ab105f0fc118aaa4d748aabf28e4" url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491942" provenance = "github-attestations" [tools.uv."platforms.linux-x64"] checksum = "sha256:adccf40b5d1939a5e0093081ec2307ea24235adf7c2d96b122c561fa37711c46" url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491998" provenance = "github-attestations" [tools.uv."platforms.macos-arm64"] diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 360bf28ee..577b801a8 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -446,6 +446,31 @@ label, supervisor env vars `OPENSHELL_K8S_SA_TOKEN_FILE` and `OPENSHELL_PROVIDER_SPIFFE_WORKLOAD_API_SOCKET`, plus both the projected `openshell-sa-token` volume and the `spiffe-workload-api` CSI volume. +If `grpcRoute.backendTLSPolicy.enabled=true`, the Gateway proxy validates the +backend pod's TLS certificate against a CA in a ConfigMap. Check that the +ConfigMap exists and contains the correct CA, that `enableMtls` is disabled, +and that the BackendTLSPolicy resource is present: + +```bash +kubectl -n openshell get backendtlspolicy +kubectl -n openshell get configmap openshell-backend-ca -o yaml +helm -n openshell get values openshell | grep -E 'backendTLSPolicy|enableMtls|failOnTimeout|timeoutSeconds|caCertificateConfigMapName' +``` + +If the ConfigMap is missing after a cert-manager install, the post-install +certgen hook may have timed out waiting for cert-manager to issue the server +certificate. Check the certgen Job logs: + +```bash +kubectl -n openshell get jobs | grep certgen +kubectl -n openshell logs job/openshell-certgen-backend-ca +``` + +Increase `pkiInitJob.timeoutSeconds` and run `helm upgrade` to retry. If the +Gateway proxy reports `TLS error: Secret is not supplied by SDS` or similar +backend TLS errors, the ConfigMap CA likely does not match the server +certificate CA — verify both are from the same issuer. + Check the image references currently used by the gateway deployment: ```bash From 42e9bcf2b018787ce1621c3ababe0c51c661543e Mon Sep 17 00:00:00 2001 From: Jorge Date: Mon, 14 Sep 2026 21:55:43 +0000 Subject: [PATCH 07/10] feat(e2e): support the Vault credential-driver lane on OpenShift (#3312) Implements https://github.com/NVIDIA/OpenShell/issues/3212 Running e2e:kubernetes with the Vault credential driver failed on OpenShift in two ways: the OpenBao fixture pod was rejected by the restricted-v2 SCC, and provider-creating tests hit HTTP 403 from auth/kubernetes/login because OpenBao's Kubernetes auth was provisioned only inside a single feature-gated test. - Deploy OpenBao with the chart's OpenShift mode (global.openshift=true) when OpenShift is detected, so the pod inherits a namespace-assigned, SCC-compliant security context with no manual SCC grant. Hoist OpenShift detection ahead of the credential-driver fixtures so the flag is set before the fixture is deployed. - Provision the OpenBao KV store, Kubernetes auth method, storage policy, and gateway login role in the harness (deploy_vault_fixture), making a Vault-backed gateway usable by the whole suite instead of only the credential_drivers test. Remove the now-redundant configure_vault_storage helper from the test. - Harden openbao_exec so it tolerates only the idempotent "path is already in use" error on reruns and fails fast with output on any other error, instead of a blanket `|| true` that masked genuine failures (e.g. an unresponsive pod) until a later cryptic write. - Document the OpenShift Vault credential-store SCC and Kubernetes-auth 403 troubleshooting in the debug-openshell-cluster skill. Signed-off-by: Jorge Garcia Oncins --- e2e/rust/tests/credential_drivers.rs | 84 ------------------------- e2e/with-kube-gateway.sh | 75 +++++++++++++++++++--- skills/debug-openshell-cluster/SKILL.md | 2 + 3 files changed, 70 insertions(+), 91 deletions(-) diff --git a/e2e/rust/tests/credential_drivers.rs b/e2e/rust/tests/credential_drivers.rs index fd8ebb060..6c4088a2f 100644 --- a/e2e/rust/tests/credential_drivers.rs +++ b/e2e/rust/tests/credential_drivers.rs @@ -12,17 +12,8 @@ use openshell_e2e::harness::cli::run_cli; use openshell_e2e::harness::output::strip_ansi; use openshell_e2e::harness::sandbox::SandboxGuard; use sha2::{Digest, Sha256}; -use tokio::io::AsyncWriteExt; const CREDENTIAL_KEY: &str = "OPENAI_API_KEY"; -const VAULT_POLICY: &str = r#"path "secret/data/openshell/provider-credentials/*" { - capabilities = ["create", "read", "update", "delete"] -} - -path "secret/metadata/openshell/provider-credentials/*" { - capabilities = ["read", "delete", "list"] -} -"#; fn unique_suffix() -> String { let millis = SystemTime::now() @@ -153,49 +144,6 @@ async fn bao(args: &[&str]) -> Result { Ok(combined) } -async fn bao_with_stdin(args: &[&str], stdin: &str) -> Result { - let namespace = vault_namespace(); - let pod = vault_pod(); - let token = vault_token(); - let token_env = format!("BAO_TOKEN={token}"); - let mut command = kubectl_command(); - command.args([ - "-n", &namespace, "exec", "-i", &pod, "--", "env", &token_env, "bao", - ]); - command.args(args); - command.stdin(Stdio::piped()); - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); - - let mut child = command - .spawn() - .map_err(|err| format!("failed to spawn bao {args:?}: {err}"))?; - let mut child_stdin = child - .stdin - .take() - .ok_or_else(|| "failed to open bao stdin".to_string())?; - child_stdin - .write_all(stdin.as_bytes()) - .await - .map_err(|err| format!("failed to write bao stdin: {err}"))?; - drop(child_stdin); - - let output = child - .wait_with_output() - .await - .map_err(|err| format!("failed to wait for bao {args:?}: {err}"))?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - if !output.status.success() { - return Err(format!( - "bao {args:?} failed (exit {:?}):\n{combined}", - output.status.code() - )); - } - Ok(combined) -} - async fn delete_provider(name: &str) { let mut cmd = openshell_cmd(); cmd.arg("provider") @@ -304,33 +252,6 @@ async fn assert_provider_placeholder_available_in_sandbox( Ok(()) } -async fn configure_vault_storage() -> Result<(), String> { - let _ = bao(&["secrets", "enable", "-path=secret", "kv-v2"]).await; - let _ = bao(&["auth", "enable", "kubernetes"]).await; - bao(&[ - "write", - "auth/kubernetes/config", - "kubernetes_host=https://kubernetes.default.svc", - "kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", - ]) - .await?; - bao_with_stdin( - &["policy", "write", "openshell-provider-storage", "-"], - VAULT_POLICY, - ) - .await?; - bao(&[ - "write", - "auth/kubernetes/role/openshell-gateway", - "bound_service_account_names=openshell", - &format!("bound_service_account_namespaces={}", namespace()), - "policies=openshell-provider-storage", - "ttl=1h", - ]) - .await?; - Ok(()) -} - async fn assert_kubernetes_secret_stored( identity: &ProviderIdentity, provider_name: &str, @@ -457,11 +378,6 @@ async fn provider_credentials_are_stored_in_configured_backend() { let secret_value = format!("example-e2e-{driver_slug}-{suffix}"); delete_provider(&provider_name).await; - if driver == "vault" { - configure_vault_storage() - .await - .expect("configure Vault storage fixture"); - } let result: Result = async { create_provider(&provider_name, &secret_value).await?; diff --git a/e2e/with-kube-gateway.sh b/e2e/with-kube-gateway.sh index 8a9eaf3df..069acc0f0 100755 --- a/e2e/with-kube-gateway.sh +++ b/e2e/with-kube-gateway.sh @@ -202,6 +202,12 @@ cleanup_postgres_fixture() { deploy_vault_fixture() { echo "Deploying OpenBao fixture for Vault credential-driver validation..." + local openshift_flag="false" + if [ "${OPENSHIFT_DETECTED}" = "1" ]; then + echo "Enabling OpenBao chart OpenShift mode for restricted-v2 compatibility." + openshift_flag="true" + fi + helmctl repo add openbao https://openbao.github.io/openbao-helm \ >/dev/null 2>&1 || true helmctl repo update openbao >/dev/null @@ -211,6 +217,7 @@ deploy_vault_fixture() { --set "server.dev.enabled=true" \ --set "server.dev.devRootToken=${VAULT_DEV_ROOT_TOKEN}" \ --set "injector.enabled=false" \ + --set "global.openshift=${openshift_flag}" \ --wait --timeout 5m VAULT_FIXTURE_DEPLOYED=1 @@ -219,11 +226,61 @@ deploy_vault_fixture() { -l "app.kubernetes.io/name=openbao,component=server" \ --timeout=300s + provision_vault_auth + export OPENSHELL_E2E_VAULT_NAMESPACE="${VAULT_NAMESPACE}" export OPENSHELL_E2E_VAULT_POD="${VAULT_RELEASE_NAME}-0" export OPENSHELL_E2E_VAULT_TOKEN="${VAULT_DEV_ROOT_TOKEN}" } +# Run a `bao` command in the fixture pod. Tolerates the "path is already in use" +# error from re-enabling a mount on rerun, but surfaces any other failure. +openbao_exec() { + local out + if out="$(kctl -n "${VAULT_NAMESPACE}" exec "${VAULT_RELEASE_NAME}-0" -- \ + env "BAO_TOKEN=${VAULT_DEV_ROOT_TOKEN}" bao "$@" 2>&1)"; then + [ -n "${out}" ] && printf '%s\n' "${out}" + return 0 + fi + case "${out}" in + *"path is already in use"*) return 0 ;; + *) printf '%s\n' "${out}" >&2; return 1 ;; + esac +} + +# Provision the KV store, Kubernetes auth method, storage policy, and login role +# the gateway's Vault credential driver uses, so every provider-creating test in +# the suite can authenticate. The role binds ServiceAccount `openshell` in the +# gateway namespace, matching ci/values-credential-driver-vault.yaml. +provision_vault_auth() { + echo "Provisioning OpenBao Kubernetes auth for the gateway service account..." + + openbao_exec secrets enable -path=secret kv-v2 >/dev/null + openbao_exec auth enable kubernetes >/dev/null + + openbao_exec write auth/kubernetes/config \ + kubernetes_host=https://kubernetes.default.svc \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + >/dev/null + + printf '%s\n' \ + 'path "secret/data/openshell/provider-credentials/*" {' \ + ' capabilities = ["create", "read", "update", "delete"]' \ + '}' \ + 'path "secret/metadata/openshell/provider-credentials/*" {' \ + ' capabilities = ["read", "delete", "list"]' \ + '}' \ + | kctl -n "${VAULT_NAMESPACE}" exec -i "${VAULT_RELEASE_NAME}-0" -- \ + env "BAO_TOKEN=${VAULT_DEV_ROOT_TOKEN}" \ + bao policy write openshell-provider-storage - >/dev/null + + openbao_exec write auth/kubernetes/role/openshell-gateway \ + bound_service_account_names=openshell \ + "bound_service_account_namespaces=${NAMESPACE}" \ + policies=openshell-provider-storage \ + ttl=1h >/dev/null +} + cleanup_vault_fixture() { [ -n "${KUBE_CONTEXT}" ] || return 0 [ -n "${VAULT_NAMESPACE}" ] || return 0 @@ -866,6 +923,16 @@ fi AGENT_SANDBOX_VERSION="${AGENT_SANDBOX_VERSION}" \ bash "${ROOT}/e2e/support/install-agent-sandbox.sh" --context "${KUBE_CONTEXT}" +# Detect OpenShift up front so fixtures deployed below can apply SCC-compatible +# handling; the gateway setup further down reuses this flag. +if kctl api-resources --api-group=route.openshift.io --no-headers 2>/dev/null | grep -q .; then + OPENSHIFT_DETECTED=1 + if ! command -v oc >/dev/null 2>&1; then + echo "ERROR: oc CLI is required for OpenShift SCC management but was not found." >&2 + exit 2 + fi +fi + ACTIVE_CREDENTIAL_DRIVER="${OPENSHELL_E2E_CREDENTIAL_DRIVER:-kubernetes-secrets}" if [ "${OPENSHELL_E2E_CREDENTIAL_DRIVERS:-0}" = "1" ] \ && [ "${ACTIVE_CREDENTIAL_DRIVER}" = "vault" ]; then @@ -890,16 +957,10 @@ if [ -n "${HOST_GATEWAY_IP}" ]; then fi helm_values_args=(--values "${ROOT}/deploy/helm/openshell/ci/values-skaffold.yaml") -if kctl api-resources --api-group=route.openshift.io --no-headers 2>/dev/null | grep -q .; then - OPENSHIFT_DETECTED=1 +if [ "${OPENSHIFT_DETECTED}" = "1" ]; then echo "OpenShift detected — applying SCC-compatible security context overrides." helm_values_args+=(--values "${ROOT}/deploy/helm/openshell/ci/values-openshift-scc.yaml") - if ! command -v oc >/dev/null 2>&1; then - echo "ERROR: oc CLI is required for OpenShift SCC management but was not found." >&2 - exit 2 - fi - kctl create namespace "${NAMESPACE}" --dry-run=client -o yaml | kctl apply -f - echo "Granting privileged SCC to openshell-sandbox in namespace ${NAMESPACE}..." diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 577b801a8..7530fc5c7 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -767,6 +767,8 @@ configuration — check that the gateway spawned the driver binary you expect | Kubernetes gateway pod crash loops | Missing secret, bad DB URL, bad TLS config | `kubectl -n openshell logs deployment/openshell -c openshell-gateway` or `kubectl -n openshell logs statefulset/openshell -c openshell-gateway` | | OpenShift gateway pod fails to start with an SCC/`runAsUser` error (e.g. `unable to validate against any security context constraint`) | Chart's default `podSecurityContext`/`securityContext` hardcodes `runAsUser`/`fsGroup`, which the restricted-v2 SCC rejects; it must instead inject the namespace-assigned UID/GID range | `oc -n openshell describe pod `; deploy with `podSecurityContext: null` and clear `securityContext.runAsUser` (see `deploy/helm/openshell/ci/values-openshift-scc.yaml`) | | OpenShift sandbox pod fails to start (`unable to validate against any security context constraint`) | The `openshell-sandbox` service account lacks the privileged SCC it needs | `oc adm policy add-scc-to-user privileged -z openshell-sandbox -n openshell`; remove with `remove-scc-from-user` when done | +| OpenShift self-hosted Vault/OpenBao credential store pod never schedules (waits time out with `no matching resources found`) | The store's Helm chart pins `runAsUser`/`fsGroup`/seccomp, which restricted-v2 rejects, so the StatefulSet controller never creates the pod | Deploy the store's chart in its OpenShift mode (`--set global.openshift=true` for the OpenBao/Vault chart) so the namespace SCC assigns a compliant security context — no manual SCC grant needed | +| Vault credential driver returns HTTP 403 / `Vault Kubernetes auth denied the configured role` on provider create | Vault's `auth/kubernetes` method or the gateway login role is not provisioned, or the role is not bound to the gateway service account and namespace | In Vault: `bao auth enable kubernetes` and `bao write auth/kubernetes/config kubernetes_host=... kubernetes_ca_cert=@...`; ensure the login role's `bound_service_account_names`/`bound_service_account_namespaces` match the gateway SA and namespace and its policy grants the credential paths | | CLI TLS error | Local mTLS bundle does not match server cert/CA | Check `~/.config/openshell/gateways//mtls/` | | Edge or OIDC gateway returns `Unauthenticated` | Stored login expired, audience/scopes mismatch, or gateway auth configuration changed | `openshell gateway info`, `openshell gateway login `, gateway auth logs | | Gateway fails before serving health after enabling an interceptor | Interceptor endpoint unavailable or manifest/binding validation failed | Gateway and interceptor logs; interceptor socket; `binding_policy`, phases, and failure policy | From 2d5db4c5bd6506eb68668175c9e2707e0403a4e2 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 14 Sep 2026 22:11:42 +0000 Subject: [PATCH 08/10] chore(security): document Kubernetes runtime RBAC (#3328) Signed-off-by: Drew Newberry --- .trivyignore.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.trivyignore.yaml b/.trivyignore.yaml index 5e6afc010..4cc4e32ff 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -32,3 +32,13 @@ misconfigurations: - "**/deployment.yaml" statement: >- Images come from ghcr.io/nvidia/openshell, this project's own registry. + + # The Kubernetes compute driver creates its runtime infrastructure and the + # per-sandbox outer egress fence in the configured sandbox namespace. + - id: KSV-0056 + paths: + - "**/role.yaml" + statement: >- + The namespace-scoped gateway role can create Services and NetworkPolicy + resources so the compute driver can connect each sandbox runtime to its + supervisor while denying direct workload egress. From 26f2f963936f68c0d5be36b34cda570d8f79f315 Mon Sep 17 00:00:00 2001 From: araza008 <159492532+araza008@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:58:49 +0000 Subject: [PATCH 09/10] feat(mxc): add Windows host proxy for MXC sandbox network egress (#3163) * Implement Windows host proxy integration and update dependencies for OpenShell * Update README and gateway config to clarify egress proxy address handling and allocation * Refactor ProxyIdentityMode to return Result for static_binary and add tests for binary path and SHA256 hash * Enhance platform_hosts_path for Windows to use SystemRoot and improve error handling for hosts file reading * Refactor FileFingerprint to use Option for mtime and ctime, simplifying metadata handling * Add conditional compilation for Windows host module * add unit tests for OPA policy evaluation and identity handling * remove openshell-supervisor-network from unsupported driver package test exclusion list * feat(mxc): enable host proxy TLS state generation Generate per-sandbox TLS state for the MXC host proxy so HTTPS L7 enforcement can use the same MITM path as Linux. Grant generated CA material to the MXC process and inject standard trust env vars, while matching Linux behavior by disabling TLS termination on CA setup failure and relying on proxy fail-closed handling. * fix(docs): remove outdated notes on governed egress from docs * fix(tests): update TLS environment variable paths to use temporary directory * fix(examples): make run-mxc-e2e harness correct and orphan-free The MXC e2e harness never actually exercised the fs scenarios: it started the gateway once and patched agent_command per scenario AFTERWARDS, so the running gateway kept launching the default demo agent (not shipped in the kit) and every fs scenario failed with CreateProcessW error:2. It also scored on the `sandbox create` exit code (non-zero due to the harmless interactive attach), wrote sandbox records to the persistent gateway DB (leaving orphans that collided on later runs), and its deny scenarios never proved denial. Changes: - Start a FRESH gateway per scenario so each scenario's agent_command is actually loaded (root cause of CreateProcessW error:2). - Score by on-disk artifact / expected outcome, not `sandbox create` exit. - Real deny assertions: a control write to a granted path must succeed (proves the agent ran) while the denied write must be absent. fs-empty probes an ungranted out-of-share path (share_dir is mapped rw by design). - Run the gateway on an ephemeral in-memory DB (sqlite::memory:) so the harness never writes to the persistent store and cannot leave orphan sandbox records; also use unique per-run sandbox names + pre-delete. - Fix the process_container probe: use a real cwd + absolute cmd.exe (canonical wxc-exec does not expand %TEMP% -> 0x8007010B). - Fix summary counts (@() so a single FAIL is counted and exit is non-zero). Verified PASS=4 FAIL=0 on 7F203-MXC-003 (no BaseContainer velocity keys) using a canonical wxc-exec build (AppContainer fallback). Signed-off-by: Akber Raza * fix(e2e): probe timeout is milliseconds (10ms->30000ms) MXC process.timeout is wall-clock ms (wire.rs). The 10 value meant 10ms, which the base-container tier (7F203-MXC-001/.181) enforced strictly and timed the probe out. AppContainer path (.18/-003) happened to slip under it. Bump to 30000ms so the process_container preflight probe is reliable across both tiers. Signed-off-by: Akber Raza * fix(mxc): use native paths in real runtime probes Signed-off-by: Shailendra Singh Signed-off-by: Akber Raza * fix(mxc): make processcontainer work with mxc-latest-released wxc-exec Three fixes to support the release wxc-exec binary (BaseContainer dispatcher) in addition to mxc-fixes-env-vars: 1. Seed process env from host (driver.rs) ProcessContainer starts with a completely blank environment -- no PATH, SystemRoot, or anything. Seed the process env from the gateway host environment so the agent binary can locate DLLs and run. Skip internal Windows drive-letter variables (keys starting with '=') which cause CreateProcessW to return ERROR_ENVVAR_NOT_FOUND. User agent_env entries and TLS CA vars are applied as overrides on top of the host env. 2. Remove TLS readonly_paths grant (driver.rs) The release wxc-exec (BaseContainer dispatcher) requires write-DAC permission on every path in readonly_paths to set up AppContainer ACLs. Adding the proxy's temp TLS directory caused a DACL error and exit -1. The CA cert paths remain available to the agent via TLS env vars. 3. Remove allowedHosts from network JSON (mxc.rs) The release wxc-exec rejects network.allowedHosts / network.blockedHosts on Windows with "not yet supported". Removed the loopback exemption attempt (127.0.0.1, ::1, localhost) from the network section. Intra-container loopback works natively in the release binary without it -- the spawner can connect to the server at 127.0.0.1:22000 directly. Additional changes: - mxc-ws-agent.rs: add relay-debug.txt error capture and relay-ready.txt marker for reliable timing of host client connections. - mxc-ws-gateway.toml: debug = true for JSON config dump during diagnosis. - run-ws-agent-test.ps1: default port changed to 17670 (gateway default); relay-ready.txt polling before ws-echo to avoid connecting before the spawner has established the proxy bridge. Signed-off-by: Prashant Khodade Signed-off-by: Akber Raza * fix(e2e): address CodeRabbit review on run-mxc-e2e.ps1 (MR !46) Four robustness/correctness fixes from CodeRabbit: 1. Start-Gw: kill the spawned gateway before the "did not start within 30s" throw. If the process is alive but never binds the port, $gw is not yet assigned in the caller, so the finally block cannot reap it -> orphan gateway holding the port for the next run. 2. create-fail scoring: a non-zero `sandbox create` exit alone is not proof of a policy rejection (gateway-registration/transport/fixture errors also exit non-zero and would false-pass). PASS now requires a genuine rejection signal (network / invalid_argument / network_policies) AND that it is not an infrastructure failure; other non-zero exits go to FAIL with output captured. 3. deny scenarios (ControlTarget path): snapshot the deny target AFTER Wait-File lands the control artifact, so a late denied write (enforcement regression racing the control write) can no longer be recorded as PASS. 4. -KeepRunning: break out of the scenario loop after the first scenario so a later scenario does not start a second gateway on the same port (previously a reliable port collision instead of a usable debug mode). Re-verified PASS=4 FAIL=0 on both boxes (7F203-MXC-001 base-container and 7F203-MXC-003 AppContainer fallback); network-policy-rejected correctly scores as "policy rejection". Signed-off-by: Akber Raza * feat(mxc-e2e): collect run-mxc-e2e output into a results bundle Mirror the sibling run-*.ps1 scripts by collecting every run's logs into a timestamped results-e2e-\ folder and zipping it. The bundle contains the console transcript, per-scenario gateway stdout/stderr, the exact TOML rendered for each scenario, the policy fixture used, and a summary.txt with the verdict table. Per-scenario gateway logs now land in gateway..log/.err.log inside the bundle instead of a single fixed gateway.e2e.log in the script directory. Wrap pre-flight, mode setup, scenario definitions, and the scenario loop in a single try/catch/finally so the finally always writes the summary, stops the transcript, and zips the bundle -- even on a pre-flight failure. The existing per-scenario gateway-cleanup try/finally stays nested inside. All scenario logic, scoring rules, and comments are preserved. Signed-off-by: Prashant S Khodade Signed-off-by: Akber Raza * fix(mxc-e2e): address CodeRabbit review on run-mxc-e2e.ps1 - Require -Scenario when -KeepRunning: the loop breaks after the first scenario, so a full-suite run would execute only one scenario yet still report the suite as PASS. Fail fast so a partial run can't be mislabeled complete. - Start-Transcript now runs inside the guarded try block with a $transcriptStarted flag; Stop-Transcript is only called when it actually started, so a Start-Transcript failure still yields the results bundle. - Wrap the -Scenario filter in @() so a single exact match stays an array (reliable .Count and a proper array for the scenario loop on PS 5.1). Signed-off-by: Prashant S Khodade Signed-off-by: Akber Raza * fix(examples): pass gateway config via OPENSHELL_GATEWAY_CONFIG for spaced paths Start-Process -ArgumentList does not quote array elements, so launching the gateway with a bare --config token split on any space in the install path (e.g. C:\Users\First Last\...), and clap rejected the fragment with 'unrecognized subcommand'. Every MXC example launcher that started the gateway hit this when the kit was unzipped under a path containing a space. Pass the config path through the OPENSHELL_GATEWAY_CONFIG env var (which the gateway already reads via clap) and drop the --config token. Env vars carry spaces safely. Affected: run-ocsf-audit, run-mxc-e2e, run-demo, run-inference-test, run-ollama-test. run-mtls-test was not affected (its launch passes no config path). Root-caused and fix-verified on 7F203-MXC-003 from a spaced path. Signed-off-by: Akber Raza * fix(run-mxc-e2e): improve scoring logic and enhance command execution handling * fix(mxc): reconcile proxy support after rebase Restore the proxy-enabled OCSF audit example removed by 13185f6e now that the host CONNECT proxy is present. Adapt the proxy lifecycle test to the target branch's DriverSandboxSpec policy delivery contract. Signed-off-by: Akber Raza * fix(mxc): grant sandbox access to proxy CA - Share the per-sandbox public CA bundle with the AppContainer - Add real wxc-exec HTTPS proxy coverage and document trust isolation Signed-off-by: Akber Raza * fix(mxc): reject unsupported network middleware - Reject middleware-bearing MXC policies before sandbox lifecycle begins - Guard host proxy startup and document the unsupported registry path - Add mapper, lifecycle, and host proxy regression coverage Signed-off-by: Akber Raza * fix(mxc): reconcile host proxy with main - remove obsolete inference routing from the host proxy adapter - use the workspace AWS-LC provider in host-proxy tests - adapt the forward-proxy test to ProxyIdentityMode Signed-off-by: Akber Raza * fix(build): switch to bundled Z3 for Windows MSVC builds * fix(mxc): reconcile host proxy after rebase Signed-off-by: Drew Newberry --------- Signed-off-by: Akber Raza Signed-off-by: Shailendra Singh Signed-off-by: Prashant Khodade Signed-off-by: Prashant S Khodade Signed-off-by: Drew Newberry Co-authored-by: Jamie King Co-authored-by: Shailendra Singh Co-authored-by: Prashant Khodade Co-authored-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 20 +- .../build-openshell-mxc-windows/reference.md | 10 +- .github/workflows/windows-msvc.yml | 3 - CONTRIBUTING.md | 29 +- Cargo.lock | 3 + architecture/sandbox.md | 14 + architecture/windows-msvc-build.md | 25 +- crates/openshell-driver-mxc/Cargo.toml | 5 +- crates/openshell-driver-mxc/README.md | 69 +- .../examples/README-ocsf-audit.txt | 10 +- .../examples/mxc-ocsf-audit.toml | 16 +- .../examples/ocsf-audit.yaml | 4 + .../examples/probe-mxc-host.ps1 | 24 +- .../examples/run-mxc-e2e.ps1 | 886 +++++++++++------- .../examples/run-ocsf-audit.ps1 | 26 +- crates/openshell-driver-mxc/src/driver.rs | 477 +++++++++- crates/openshell-driver-mxc/src/mxc.rs | 24 +- crates/openshell-driver-mxc/src/policy.rs | 35 +- .../src/policy_map/loss.rs | 2 +- .../src/policy_map/map.rs | 45 +- .../src/policy_map/mod.rs | 2 +- .../tests/policy_mapper_examples.rs | 39 +- .../tests/policy_mapper_matrix.rs | 17 +- .../tests/wxc_exec_real.rs | 393 ++++++-- .../openshell-supervisor-network/Cargo.toml | 4 +- .../openshell-supervisor-network/src/host.rs | 358 +++++++ .../src/identity.rs | 41 +- .../openshell-supervisor-network/src/lib.rs | 2 + .../openshell-supervisor-network/src/proxy.rs | 368 ++++++-- .../src/proxy/tests/compatibility.rs | 21 +- .../openshell-supervisor-network/src/run.rs | 36 +- docs/reference/gateway-config.mdx | 17 + tasks/scripts/windows-msvc.ps1 | 26 +- 33 files changed, 2365 insertions(+), 686 deletions(-) create mode 100644 crates/openshell-supervisor-network/src/host.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index cd5689d7b..32647a1cb 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -115,7 +115,7 @@ The lane targets a Windows host with Visual Studio Build Tools and rustup. | Visual C++ ARM64 tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 -property installationPath` | Required for native ARM64 check, build, and tests and for x64-to-ARM64 check/build. Tests always require a native runner. | | Visual C++ ARM64 Spectre-mitigated libraries | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Runtimes.ARM64.Spectre -property installationPath` | Required by `regorus` through `msvc_spectre_libs`; the build fails when the selected MSVC toolset lacks `lib\spectre\arm64`. | | Visual C++ Clang tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath` | Provides host-native `libclang.dll` for `bindgen` and `clang-cl.exe` for ARM64 crypto dependencies such as `aws-lc-sys`. On ARM64, the wrapper uses `VC\Tools\Llvm\Arm64\bin`. | -| Visual C++ CMake tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.CMake.Project -property installationPath` | Provides CMake and Ninja for native dependencies. The x64-to-ARM64 path adds Ninja to `PATH`; Z3 uses an architecture-specific prebuilt release. | +| Visual C++ CMake tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.CMake.Project -property installationPath` | Provides CMake and Ninja for bundled Z3 and other native dependencies. The x64-to-ARM64 path adds Ninja to `PATH`; Z3 uses MSVC's Visual Studio generator. | | Windows SDK | `where.exe rc.exe` from a Developer PowerShell | Install an SDK containing target libraries and ARM64 tools. | | Rust via rustup | `rustc --version` | Add each target being validated: `x86_64-pc-windows-msvc` and/or `aarch64-pc-windows-msvc`. The wrapper also adds the selected target. | | mise | `mise --version` | Used as a task runner only. | @@ -135,8 +135,6 @@ from this skill. | `CARGO_TARGET_DIR` | `target` under repo root | Override Cargo output location. Use a short absolute path when x64-to-ARM64 builds approach Windows path-length limits. | | `Z3_LIBRARY_PATH_OVERRIDE` | unset | Directory containing an x64 system `libz3.lib`; not valid for ARM64. | | `Z3_SYS_Z3_HEADER` | unset | Full `z3.h` path required with a system Z3 library. | -| `Z3_SYS_Z3_VERSION` | `4.16.0` | Pinned official prebuilt Z3 release selected by the wrapper. | -| `READ_ONLY_GITHUB_TOKEN` | unset | Optional token for the Z3 release lookup; GitHub Actions supplies `github.token`. | | `RUSTC_WRAPPER` | inherited | The wrapper resolves an available command to an absolute path. If it is unavailable, the wrapper warns and continues without compiler caching. | Legacy fork variables such as `OPENSHELL_UPSTREAM`, @@ -205,7 +203,8 @@ jobs in the current mirror push run, or push a new mirrored commit. The binaries The ARM64 check/build steps in this x64-host contract are cross-builds. The wrapper discovers and adds host-native LLVM and Ninja to `PATH`, requires the ARM64 compiler and Spectre-mitigated libraries, lets ARM64 crypto crates select -`clang-cl`, and downloads the official prebuilt ARM64 Z3 static library. +`clang-cl`, and builds bundled Z3 with native MSVC `cl.exe` and the Visual +Studio generator. On ARM64 hosts, validate the native ARM64 check, build, and test path. The wrapper rejects test targets that do not match the host architecture, so x64 @@ -312,13 +311,12 @@ Useful log files: | `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-driver contract output. | | `test-aarch64-pc-windows-msvc-unsupported-*.log` | Focused native ARM64 contract output. | -The first check downloads the pinned official Z3 archive for the target -architecture through `z3-sys`. GitHub Actions authenticates the lookup with its -read-only workflow token; local users can set `READ_ONLY_GITHUB_TOKEN` if an -unauthenticated lookup is rate-limited. Cargo stores the extracted library in -its target tree, so the Windows target cache reuses it. The artifact report -computes SHA256 through .NET directly and does not rely on the `Get-FileHash` -module being available inside the mise-launched Windows PowerShell process. +The first check builds bundled Z3 from source through `z3-sys`. Cargo stores the +native build output in its target tree, so the Windows target cache reuses it. +The resulting release executables do not require `libz3.dll`. The artifact +report computes SHA256 through .NET directly and does not rely on the +`Get-FileHash` module being available inside the mise-launched Windows +PowerShell process. ## Common Fix Patterns diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index 6859ce1f7..99acac603 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -68,11 +68,11 @@ file. For ARM64, verify the Visual Studio instance contains the ARM64 MSVC tools, ARM64 Spectre-mitigated libraries, Clang tools, CMake tools, and a Windows SDK. Clang supplies host-native `libclang.dll` for `bindgen` and `clang-cl.exe` for -ARM64 crypto dependencies such as `aws-lc-sys`. Native and -x64-to-ARM64 builds use the official prebuilt Z3 4.16.0 static library for the -target architecture. An x64-to-ARM64 check/build discovers and adds host-native -Ninja to `PATH`, while the crypto crates select `clang-cl`. Use a short -`CARGO_TARGET_DIR` if Windows path-length limits are reached. +ARM64 crypto dependencies such as `aws-lc-sys`. Native builds use the normal +bundled-Z3 CMake path. An x64-to-ARM64 check/build discovers and adds +host-native Ninja to `PATH`, builds bundled Z3 with native MSVC `cl.exe` and +the Visual Studio generator, and lets the crypto crates select `clang-cl`. Use +a short `CARGO_TARGET_DIR` if Windows path-length limits are reached. ## Unsupported Driver Rules diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 00c41d9ca..76ba5075e 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -56,7 +56,6 @@ jobs: rust_target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: - READ_ONLY_GITHUB_TOKEN: ${{ github.token }} SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: windows-msvc-${{ matrix.arch }} SCCACHE_CLIENT_SIDE: "1" @@ -122,7 +121,6 @@ jobs: rust_target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: - READ_ONLY_GITHUB_TOKEN: ${{ github.token }} SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: windows-msvc-${{ matrix.arch }} SCCACHE_CLIENT_SIDE: "1" @@ -180,7 +178,6 @@ jobs: rust_target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: - READ_ONLY_GITHUB_TOKEN: ${{ github.token }} SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: windows-msvc-${{ matrix.arch }} SCCACHE_CLIENT_SIDE: "1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 931d97500..8c519da90 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -340,19 +340,14 @@ cargo build -p openshell-prover --features bundled-z3 For x86-64 and ARM64 Windows MSVC builds, use one of these Z3 paths: -- Prebuilt Z3 (the default for `windows:*` tasks): `z3-sys` downloads the - pinned Z3 4.16.0 GitHub release for the target architecture on the first - build. Cargo reuses the extracted archive from its target directory. Windows - CI authenticates the GitHub API request with `READ_ONLY_GITHUB_TOKEN` and - preserves the archive in the architecture-specific Cargo target cache. For - cold local builds, you may set `READ_ONLY_GITHUB_TOKEN` to avoid anonymous - GitHub API rate limits. +- Bundled Z3 (the default for `windows:*` tasks): `z3-sys` builds Z3 from + source and links it into the OpenShell binaries. This keeps the release + executables independent of `libz3.dll` when they are copied to another host. - System Z3: point `Z3_LIBRARY_PATH_OVERRIDE` at the directory containing the - target-compatible MSVC Z3 library and `Z3_SYS_Z3_HEADER` at the full path to `z3.h`. - The `windows:*` tasks use this path automatically when `Z3_LIBRARY_PATH_OVERRIDE` - is set. -- Bundled Z3: for direct Cargo builds, pass `--features bundled-z3` so `z3-sys` - builds Z3 from source. + target-compatible MSVC Z3 library and `Z3_SYS_Z3_HEADER` at the full path to + `z3.h`. The `windows:*` tasks use this path automatically when + `Z3_LIBRARY_PATH_OVERRIDE` is set. Deploy the matching `libz3.dll` with any + binaries built against a dynamic system Z3. `openshell-prover` itself has no `bindgen`/`libclang` dependency, so building just this crate does not require `LIBCLANG_PATH`: @@ -365,17 +360,17 @@ cargo build -p openshell-prover --target x86_64-pc-windows-msvc --features bundl To build the full set of Windows binaries, including `openshell-gateway.exe` and `openshell.exe`, use the `windows:build:x64` mise task instead of a -single-crate `cargo build`. It downloads the pinned prebuilt Z3 release by default. A -full build also compiles crates that use `bindgen` (e.g. the MXC driver on -Windows), so it requires `libclang.dll`; if LLVM is not on the default search -path, set `LIBCLANG_PATH` to the directory containing `libclang.dll`: +single-crate `cargo build`. It builds Z3 from source (bundled) by default. A full +build also compiles crates that use `bindgen` (e.g. the MXC driver on Windows), +so it requires `libclang.dll`; if LLVM is not on the default search path, set +`LIBCLANG_PATH` to the directory containing `libclang.dll`: ```powershell $env:LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\\VC\Tools\Llvm\x64\bin' mise run --skip-tools windows:build:x64 ``` -To use a local x64 Z3 release instead of the prebuilt download, set +To use a local x64 Z3 release instead of the bundled build, set `Z3_LIBRARY_PATH_OVERRIDE` and `Z3_SYS_Z3_HEADER` before running the task: ```powershell diff --git a/Cargo.lock b/Cargo.lock index e34d588c3..4584ff07c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4175,6 +4175,8 @@ dependencies = [ "openshell-core", "openshell-ocsf", "openshell-policy", + "openshell-supervisor-network", + "rustls", "serde", "serde_json", "tempfile", @@ -5794,6 +5796,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", + "serde_yaml", "spin", "thiserror 2.0.18", ] diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 7be66e97e..5b6245c89 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -49,6 +49,20 @@ OpenShell uses overlapping controls rather than a single sandbox primitive: The supervisor may enrich baseline filesystem allowances for runtime-required paths, such as proxy support files or GPU device paths when a GPU is present. +These internal allowances must stay sandbox-scoped and avoid exposing host +secrets. For example, MXC governed egress grants the generated public CA bundle +while the ephemeral CA private key remains in the host proxy's memory. +This limitation does not prevent MXC sandboxes from launching in general. +Filesystem policies work normally, and a `process_container` sandbox with +governed egress enabled can still enforce `network_policies` through the host +proxy. The limitation applies only when the effective policy contains +`network_middlewares`, which select built-in or remote services that inspect or +transform network traffic. The MXC host proxy does not currently receive the +gateway registry that resolves those services. MXC therefore rejects such a +policy synchronously during `CreateSandbox`, before it inserts runtime state or +invokes `wxc-exec`, rather than running a chain with missing implementations. +Remove the middleware entries or use a compute driver whose sandbox supervisor +receives the gateway middleware registry. ## Network and Provider Access diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index 956b1b3e9..6fb816b44 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -110,16 +110,13 @@ packaging-asset tests; its cross-platform Python, Markdown, license, and documentation checks still run. Test tasks require the Rust target architecture to match the Windows host, so an ARM64 test result is native coverage rather than x64 emulation coverage. -By default it enables the `z3-sys` prebuilt-release feature and pins Z3 4.16.0. -On a clean target directory, `z3-sys` downloads the official static library for -the selected Windows architecture instead of compiling Z3 through -CMake/MSBuild. GitHub Actions supplies its read-only workflow token for the -release lookup, and the Cargo target cache preserves the extracted library for -subsequent runs. When -`Z3_LIBRARY_PATH_OVERRIDE` points at a directory containing `libz3.lib`, the -wrapper uses that system Z3 instead and requires `Z3_SYS_Z3_HEADER` to point at -the full path to `z3.h`. Local clean builds use the unauthenticated GitHub API -unless `READ_ONLY_GITHUB_TOKEN` is set. +By default it enables bundled Z3 for reproducible Windows builds. `z3-sys` +builds Z3 from source and links it into the release binaries, so copying +`openshell-gateway.exe` and `openshell.exe` does not create an undeclared +`libz3.dll` runtime dependency. When `Z3_LIBRARY_PATH_OVERRIDE` points at a +directory containing `libz3.lib`, the wrapper uses that system Z3 instead and +requires `Z3_SYS_Z3_HEADER` to point at the full path to `z3.h`. Binaries built +against a dynamic system Z3 must be deployed with the matching `libz3.dll`. GitHub Actions layers the Cargo target cache with sccache's GitHub Actions backend. The target cache lets Cargo skip intact dependency builds; sccache @@ -139,10 +136,10 @@ Spectre-mitigated libraries, host-native Clang tools, CMake tools, and an ARM64-capable Windows SDK. Clang provides `libclang.dll` for `bindgen` and `clang-cl.exe` for ARM64 crypto dependencies. During x64-to-ARM64 check/build, the wrapper discovers and adds the Visual Studio-bundled Ninja to `PATH` for -native dependencies. Z3 uses the official prebuilt ARM64 static library, so it -does not inherit compiler settings from those native dependencies. Artifact -hashing uses .NET SHA256 directly because module autoloading in the -mise-launched Windows PowerShell process is not guaranteed. +native dependencies. Bundled Z3 uses native MSVC `cl.exe` with the Visual +Studio generator, while the crypto crates select `clang-cl`. Artifact hashing +uses .NET SHA256 directly because module autoloading in the mise-launched +Windows PowerShell process is not guaranteed. The wrapper defaults Cargo compilation to four jobs. Set `OPENSHELL_WINDOWS_BUILD_JOBS` to a positive integer to override that limit. diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 5fd12835f..5907e92a8 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -30,9 +30,9 @@ tracing = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } -# ETW/TDH real-time consumer (Plane A audit). Windows-only so the Linux/WSL -# build stays an empty stub. +# ETW/TDH real-time consumer and host CONNECT proxy integration. [target.'cfg(target_os = "windows")'.dependencies] +openshell-supervisor-network = { path = "../openshell-supervisor-network" } windows = { workspace = true } [dev-dependencies] @@ -47,6 +47,7 @@ openshell-policy = { path = "../openshell-policy" } # base64 and serde_json are already [dependencies] but dev-dependency resolution # is independent; explicit entries make them visible to integration tests. base64 = { workspace = true } +rustls = { workspace = true } serde_json = { workspace = true } # Used by the drift guard test (handled_fields_inventory) to parse YAML into a # generic serde_json::Value for key enumeration. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index aac71ae9a..9c235b85c 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -16,13 +16,17 @@ readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. ## Capability Matrix -| Capability | MXC driver | -|---|---| -| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | -| Network policy | Rejected synchronously during sandbox creation until an enforcing egress path is bound. | -| Process policy | Unsupported; MXC supplies OS isolation only. | -| Interactive exec/connect/forward | Unsupported; the configured workload runs in-driver. | -| Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | +| Capability | MXC driver | Closing it requires | +|---|---|---| +| Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | +| Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; the driver starts a per-sandbox host CONNECT proxy, generates HTTPS MITM trust material, and injects the CA bundle into the sandbox process env | Gateway event-bus wiring follow-on | +| Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | +| Network middleware | ❌ rejected before launch because the MXC host proxy does not receive the gateway middleware registry | Gateway middleware-registry injection | +| Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | +| Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | gateway interactive-exec surgery (follow-on) | +| Bundled agent image | ❌ no OCI image; relies on Windows host install | — | +| Restart durability | ❌ in-memory registry; restart orphans live sessions | follow-on | +| Concurrent sandboxes | ⚠️ isolation_session v1 is single-session | MXC backend feature | The filesystem enforcement proof has two paths: @@ -41,10 +45,18 @@ backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] +# Pattern-C governed egress. The address is a loopback seed; each sandbox +# receives a unique ephemeral proxy port. +egress_proxy = false +egress_proxy_addr = "" debug = false etw_audit = false ``` +When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback +`IP:PORT` seed. The driver preserves the configured IP and allocates a unique +ephemeral port for each sandbox's `network.proxy` redirect. + Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: ```powershell @@ -55,9 +67,9 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Environment variables come from the standard sandbox and template environment maps; the driver never copies values from the gateway host environment. -Network policy support depends on the selected containment path. Policy -replacement and merge updates use the gateway's standard sandbox configuration -revision contract. +The host CONNECT proxy enforces network policy when governed egress is enabled. +Live policy replacement or merge updates remain unsupported; delete and recreate +the sandbox to apply a different policy. When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a @@ -91,6 +103,9 @@ them. Records without matching generation evidence remain unattributed. - Windows 11 Insider build ≥ 26300.8553 - `IsoSessionApp.dll` present and registered - `wxc-exec.exe` built with `--features isolation_session` +- Any enforced App Control policy allows both `openshell-gateway.exe` and + `openshell.exe`. Diagnose executable blocks with event 3077 in the + `Microsoft-Windows-CodeIntegrity/Operational` log. For off-box smoke tests against the in-process mock shim (no `wxc-exec`, no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. @@ -103,9 +118,30 @@ invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` without leaving a partial sandbox. There is no in-process policy side channel or MXC-specific gateway composition variant. -`EmbeddedPolicyMapper` calls the embedded [`policy_map`](src/policy_map/) module directly and normalizes filesystem paths to Windows form. It does not add gateway-configured host paths. The policy supplied for the sandbox is the only source of filesystem grants. +When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` +instead: MXC receives filesystem grants plus a loopback `network.proxy` +redirect, and the driver starts a host CONNECT proxy from the trimmed +network-only `SandboxPolicy`. Policies containing `network_middlewares` are +rejected synchronously until this host-proxy path can receive the gateway's +built-in and remote middleware registry. The proxy uses the configured agent +command as the static sandbox process identity because MXC does not expose +Linux-style procfs socket ownership. For HTTPS L7 inspection, the host proxy generates a +per-sandbox CA and injects `NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, +`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent +process env. It does not add the generated CA directory to MXC read-only grants: +released `wxc-exec` BaseContainer builds require `WRITE_DAC` on every such +grant and reject the user-owned proxy temp directory. Instead, the driver adds +the sandbox-unique directory as an internal read-write share so HTTPS clients +can read the injected paths. The directory contains only public CA certificates; +the ephemeral CA private key remains in the host proxy's memory. The driver +seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the +gateway host before applying sandbox and TLS overrides, so required Windows +bootstrap values remain available without exposing the gateway's full +environment. The development export surface remains the +[`policy-to-mxc`](examples/policy-to-mxc.rs) example; there is no production +`openshell policy export-mxc` subcommand yet. -The mapper retains an internal policy-splitting seam for future development, but the runtime exposes no governed-egress switch. Any network rule fails closed until an enforcing proxy is implemented and bound to the sandbox lifecycle. +If governed egress is disabled, any network rule fails closed rather than launching without an enforcement path. Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. @@ -126,7 +162,7 @@ exits 0 rather than failing. | Task | What it runs | When to use | |---|---|---| -| `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1` | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | +| `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1`, including an HTTPS request through the host proxy | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | | `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | | `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | @@ -135,6 +171,10 @@ exits 0 rather than failing. and a `verdicts` object). Run it before the real-MXC lane to understand what will PASS vs SKIP on a given host: +The probe uses a unique, user-owned Windows temp directory for every run. +MXC treats config paths literally (it does not expand `%TEMP%`), and the +per-run directory keeps AppContainer+DACL fallback mutations narrowly scoped. + ```powershell powershell -NoProfile -ExecutionPolicy Bypass ` -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -149,7 +189,6 @@ velocity keys not enabled, isolation_session absent). ## Deferred work -- **Interactive exec/connect/forward** → `adapt-openshell-gateway-windows` -- **Governed egress** remains fail-closed until an enforcing proxy is implemented and bound to sandbox lifecycle. +- **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) - **Restart durability** (deprovision orphaned sessions on startup) → follow-on - **GPU passthrough** → not pursued in host-side-governance design diff --git a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt index 4d9bb9f55..51bedd26a 100644 --- a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -16,7 +16,7 @@ WHAT THIS PROVES / PRODUCES OCSF classes you will see: [6002] Application Lifecycle - sandbox created - [5019] Device Config State Change - OS policy / hardening / console + [5019] Device Config State Change - OS policy / hardening / proxy / console [1007] Process Activity - in-sandbox process launch (+ executable identity) [2004] Detection Finding - MXC setup activity errors (informational) @@ -46,7 +46,7 @@ WHAT YOU GET BACK FILES IN THIS PACKAGE openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) openshell.exe the CLI - mxc-ocsf-audit.toml gateway/driver config (process_container, etw_audit=true) + mxc-ocsf-audit.toml gateway/driver config (process_container, etw_audit=true, egress proxy) ocsf-audit.yaml sandbox policy (read-write grant to the share dir) run-ocsf-audit.ps1 the orchestrator you run README-ocsf-audit.txt this file @@ -54,6 +54,9 @@ FILES IN THIS PACKAGE USEFUL OPTIONS -SandboxCount Create n sandboxes (default 2). More sandboxes = more events. + -NoProxy Skip the per-sandbox egress proxy. This omits ONLY the + SandboxProxyConfigured config event; everything else is + still produced. (Default is proxy ON for the full set.) -ShareDir Host folder granted read-write to the workload. The script derives a disposable policy and per-sandbox config for it. -WxcExecPath Path to wxc-exec.exe on this box. @@ -66,6 +69,9 @@ NOTES that is unrelated to the OCSF audit path this example exercises. - A "supervisor session not connected" / ssh 255 message during sandbox create is EXPECTED on MXC and harmless - the agent already ran in-driver. + - The proxy path requires the host-side CONNECT proxy and an absolute agent + binary (the packaged config uses C:\Windows\System32\cmd.exe); the run script + handles this for you. - The Sandboxing provider reports the sandbox entry-point process, not the full in-sandbox process tree. Deep process-tree auditing would need a second ETW source (Microsoft-Windows-Kernel-Process) and is out of scope for this trail. diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml index b830a910a..a828264a5 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -9,9 +9,9 @@ # Detection Finding [2004] — written to a durable JSONL log, just like the Linux # OCSF pipeline. # -# run-ocsf-audit.ps1 patches wxc_exec_path, backend, and etw_audit into a -# disposable copy of this file. Workload command and cwd are sandbox-scoped and -# passed separately through --driver-config-json. +# run-ocsf-audit.ps1 patches wxc_exec_path, backend, etw_audit and the +# egress-proxy switch into a disposable copy of this file. Workload command and +# cwd are sandbox-scoped and passed separately through --driver-config-json. [openshell.drivers.mxc] # Path to wxc-exec.exe on the box (patched by the run script; default is the @@ -28,3 +28,13 @@ debug = false # Turn ON the Plane-A ETW -> OCSF audit consumer. This is the core of the example. etw_audit = true + +# Per-sandbox governed egress. Enabling this makes the driver start a host CONNECT +# proxy and hand MXC a `network.proxy` redirect, which is what makes MXC emit the +# SandboxProxyConfigured event — the config event mapped to OCSF CONFIG [5019] +# that completes full event coverage. Requires backend = process_container and a +# loopback (127.0.0.1) seed address; the driver allocates a unique ephemeral port +# per sandbox from this seed. Run-ocsf-audit.ps1 disables this when passed +# -NoProxy. +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml index 9a44ce285..13e7cd571 100644 --- a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -6,6 +6,10 @@ # Minimal filesystem policy granting the workload folder read-write; everything # else is default-deny. run-ocsf-audit.ps1 copies this policy into the result # bundle and replaces the default grant with -ShareDir for that run. +# +# No network_policies block is needed here: the per-sandbox egress proxy is driven +# by `egress_proxy = true` in mxc-ocsf-audit.toml (that is what makes MXC emit the +# SandboxProxyConfigured event we map to OCSF), not by a policy rule. version: 1 filesystem_policy: diff --git a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 index 4a20e7b86..343e88e88 100644 --- a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 +++ b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -119,6 +119,10 @@ $pcTrialMessage = "wxc-exec not found" $isoTrialResult = "absent" $isoTrialMessage = "wxc-exec not found" +$probeTempPath = Join-Path ([System.IO.Path]::GetTempPath()) ("openshell-mxc-probe-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $probeTempPath | Out-Null + +try { if ($wxcInfo.exists) { # --probe $probeResult = Invoke-WxcProbe -wxc $WxcExecPath @@ -131,11 +135,11 @@ if ($wxcInfo.exists) { containment = "processcontainer" process = @{ commandLine = "cmd /c exit 0" - cwd = "%TEMP%" + cwd = $probeTempPath timeout = 0 } filesystem = @{ - readwritePaths = @("%TEMP%") + readwritePaths = @($probeTempPath) } } $dryResult = Invoke-WxcDryRun -wxc $WxcExecPath -config $dryConfig @@ -149,15 +153,22 @@ if ($wxcInfo.exists) { containment = "processcontainer" process = @{ commandLine = "cmd /c exit 0" - cwd = "%TEMP%" - timeout = 10 + cwd = $probeTempPath + timeout = 30000 } filesystem = @{ - readwritePaths = @("%TEMP%") + readwritePaths = @($probeTempPath) } processContainer = @{ leastPrivilege = $false } + # cmd.exe needs the Win32k calls represented by disable = false. + # Filesystem and network restrictions remain default-deny. + ui = @{ + disable = $false + clipboard = "none" + injection = $false + } } $pcResult = Invoke-WxcPhase -wxc $WxcExecPath -config $pcConfig $pcOutput = $pcResult.Output @@ -260,6 +271,9 @@ if ($wxcInfo.exists) { $isoTrialMessage = "exit $($isoResult.ExitCode): $isoOutput" } } +} finally { + Remove-Item -LiteralPath $probeTempPath -Recurse -Force -ErrorAction SilentlyContinue +} # ── Verdicts ────────────────────────────────────────────────────────────────── diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 837913ed1..46536ff4a 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -1,43 +1,45 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # run-mxc-e2e.ps1 - MXC e2e scenario runner. # -# Starts the gateway ONCE, runs a table of policy scenarios, emits per-scenario -# PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero only on -# FAIL. Reuses the gateway-start / CLI-register / teardown pattern from -# run-demo.ps1. +# Runs a table of policy scenarios against the OpenShell MXC driver, emits +# per-scenario PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero +# only on FAIL. # -# PowerShell 5.1-compatible (no && / || / ternary operators). +# Every run collects its logs into a timestamped results-e2e-\ folder and +# zips it (mirrors the sibling run-*.ps1 scripts). The bundle contains the console +# transcript, the per-scenario gateway stdout/stderr, the exact TOML rendered for +# each scenario, the policy fixture used, and a summary.txt with the verdict table. # -# Usage examples: +# The gateway restarts per scenario to keep logs and the in-memory database +# isolated. Workload command/cwd are supplied per sandbox through +# --driver-config-json; they are never patched into gateway configuration. # -# # Real mode (probe-gated — backends that are absent are SKIPped): -# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 +# Scoring (why we do NOT gate on `sandbox create` exit code): +# The ground truth is the on-disk artifact, so positive scenarios pass on +# artifact PRESENT and deny scenarios pass on the denied write being ABSENT. +# A CONTROL write proves the agent ran when the policy grants a writable path; +# an empty policy instead requires explicit driver-launch evidence. # -# # Mock mode (wiring-only; no real wxc-exec or enforcement): -# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -Mock +# PowerShell 5.1-compatible (no && / || / ternary operators). ASCII only. # -# # Choose backend / filter scenarios: -# .\run-mxc-e2e.ps1 -Backend process_container -Scenario fs-rw +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe +# .\run-mxc-e2e.ps1 -Mock # wiring-only, no real backend +# .\run-mxc-e2e.ps1 -Scenario fs-rw # single scenario # # Scenarios & expected verdicts: -# fs-rw - rw grant on DemoDir; in-policy write succeeds. -# Both backends; skipped when backend not live (non-mock). -# fs-readonly - ro grant on a source dir + rw on DemoDir; -# write to ro dir should be denied. -# Both backends; skipped when backend not live. -# fs-default-deny - empty filesystem policy; every write denied. -# processcontainer only (isolation_session has no deny -# primitive); skipped on isolation_session. -# network-reject - rw grant + network_policies rule; -# sandbox create must FAIL (invalid_argument). -# Runs on ANY backend including mock — never skips. +# fs-rw - in-policy write to DemoDir succeeds. +# fs-readonly - write to read-only dir is denied; control write succeeds. +# fs-default-deny - ungranted write is denied after the agent launches. +# processcontainer only. +# network-reject - network_policies rule makes sandbox create fail. [CmdletBinding()] param( [string] $DemoDir = "C:\work\openshell-mxc-e2e", - [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", [ValidateSet("isolation_session", "process_container")] [string] $Backend = "process_container", [string] $Scenario, @@ -50,8 +52,21 @@ param( $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +# --- Results bundle ----------------------------------------------------------- +# Collect every log + the exact rendered config per scenario into a timestamped +# results\ folder, then zip it (mirrors the sibling run-*.ps1 scripts). Created up +# front so the transcript (started inside the guarded region below) captures the +# whole run, including pre-flight failures. +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-e2e-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +$transcriptStarted = $false + function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } function Info([string]$m) { Write-Host " $m" } function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } @@ -59,66 +74,218 @@ function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } function Skip([string]$m) { Write-Host "[SKIP] $m" -ForegroundColor Yellow } function Warn([string]$m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } -# ── Pre-flight ──────────────────────────────────────────────────────────────── +# Double backslashes so a Windows path is a valid TOML/JSON basic-string element. +function Esc([string]$p) { return $p.Replace('\', '\\') } -# In real mode, assert OPENSHELL_MXC_MOCK_WXC is NOT set. -# A stale mock env var would silently re-mock a run that should be real. -if (-not $Mock) { - if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { - throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + - "A stale mock env var would silently re-mock a real run. " + - "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." +# Build one CreateProcess-compatible command-line argument. Windows PowerShell +# 5.1 can split JSON values at embedded spaces when invoking native commands +# through the call operator, even when PowerShell holds the JSON as one string. +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $filePath + $startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { throw "failed to start $filePath" } + } catch { + $message = $_.Exception.Message + if ($message -match '(?i)Application Control policy has blocked this file') { + $sha256 = try { (Get-FileHash -LiteralPath $filePath -Algorithm SHA256).Hash } catch { "unavailable" } + $signature = try { (Get-AuthenticodeSignature -LiteralPath $filePath).Status } catch { "unavailable" } + throw "Application Control blocked CLI launch '$filePath' (SHA256=$sha256; Authenticode=$signature). Review CodeIntegrity/Operational event 3077 to identify the blocking policy, then deploy or allow an approved binary. Original error: $message" + } + throw + } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + + return @{ + ExitCode = $process.ExitCode + Output = @($stdout.Result, $stderr.Result) | Where-Object { $_ } } } -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" -$toml = Join-Path $here "mxc-gateway.toml" +# --- Path variables ----------------------------------------------------------- + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$toml = Join-Path $here "mxc-gateway.toml" $policyDir = Join-Path $here "e2e-policies" -foreach ($f in @($gateway, $cli, $toml)) { - if (-not (Test-Path $f)) { - throw "Missing artifact: $f`nBuild first or run from a demo-package folder." +$cmdExe = "C:\Windows\System32\cmd.exe" +$demoDirFwd = $DemoDir.Replace('\', '/') +$roSrc = "$DemoDir-ro-src" # matches e2e-policies/fs-readonly.yaml read_only path +$denyProbe = "$DemoDir-deny-probe" # ungranted, NOT the share: used to prove default-deny + +$script:registered = $false + +# Pristine TOML captured once inside the try (below); every scenario renders a +# fresh copy from it. Per-scenario gateway logs are assigned inside the loop so +# each scenario's stdout/stderr lands in its own file under $resultDir. +$tomlBase = $null +$gwLog = $null +$gwErrLog = $null + +# --- Helpers ------------------------------------------------------------------ + +# Render host-runtime settings from the pristine base. Sandbox workload +# settings are create-time driver config, not gateway-wide TOML. +function Render-Toml { + $t = $tomlBase + $t = [regex]::Replace($t, '(?m)^\s*#?\s*backend\s*=.*$', "backend = `"$Backend`"") + if (-not $Mock) { + $wxcLine = "wxc_exec_path = `"$(Esc $WxcExecPath)`"" + $t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) } -} -if (-not (Test-Path $policyDir)) { - throw "e2e-policies/ directory not found at $policyDir" + Set-Content $toml -Value $t -Encoding UTF8 } -# ── Backend probe ───────────────────────────────────────────────────────────── +function Start-Gw { + Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + # Ephemeral in-memory DB: this is a test harness, so it must NOT write sandbox + # records to the persistent default store (%LOCALAPPDATA%\openshell\gateway\ + # openshell.db). Without this, sandbox names persist across gateway restarts + # and across runs, colliding on `create` ("already exists") and leaving orphan + # records behind. In-memory means every gateway starts clean and leaves nothing. + # Config path goes through the env var (clap: OPENSHELL_GATEWAY_CONFIG), NOT a + # --config token: Start-Process -ArgumentList does not quote array elements, so a + # config path containing a space gets split and the gateway's arg parser rejects it. + $env:OPENSHELL_GATEWAY_CONFIG = $toml + $p = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", $Port, "--log-level", "info") ` + -WorkingDirectory $here -PassThru -WindowStyle Hidden ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if ($p.HasExited) { + Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + throw "gateway exited early (code $($p.ExitCode)). See $gwLog." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + return $p + } + Start-Sleep -Milliseconds 400 + } + # Timed out but the process is still alive (never bound $Port). $gw is not yet + # assigned in the caller, so the finally block can't reap it - kill it here to + # avoid leaving an orphan gateway holding the port for the next run. + if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } + throw "gateway did not start within 30 s." +} + +function Stop-Gw($p) { + if ($p -and -not $p.HasExited) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Milliseconds 700 # let the listen socket release before the next start +} + +function Register-Cli { + if ($script:registered) { return } + $env:OPENSHELL_GATEWAY = "" + + $addResult = Invoke-NativeCaptured $cli @( + "gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName + ) + $addText = ($addResult.Output -join "`n") + if ($addText) { $addResult.Output | ForEach-Object { Info $_ } } + if ($addResult.ExitCode -ne 0 -and $addText -notmatch '(?i)already exists') { + throw "gateway add failed (exit $($addResult.ExitCode)): $addText" + } + + $selectResult = Invoke-NativeCaptured $cli @("gateway", "select", $GatewayName) + $selectText = ($selectResult.Output -join "`n") + if ($selectText) { $selectResult.Output | ForEach-Object { Info $_ } } + if ($selectResult.ExitCode -ne 0) { + throw "gateway select failed (exit $($selectResult.ExitCode)): $selectText" + } + + $script:registered = $true +} + +function Wait-File([string]$path, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline -and -not (Test-Path $path)) { + Start-Sleep -Milliseconds 400 + } + return (Test-Path $path) +} + +# Detect an agent *launch* failure (binary not found / not implemented) vs a +# legitimate policy denial. Used to avoid false-passing a deny scenario when the +# agent never actually ran. +function Launch-Failed([string]$gwText) { + if ($null -eq $gwText) { return $false } + return ($gwText -match 'CreateProcessW failed error:2' ` + -or $gwText -match 'error:2' ` + -or $gwText -match 'exited -1' ` + -or $gwText -match 'The system cannot find the file' ` + -or $gwText -match 'E_NOTIMPL' ` + -or $gwText -match 'velocity') +} + +function Launch-Succeeded([string]$gwText) { + if ($null -eq $gwText) { return $false } + return ($gwText -match 'MXC agent launched') +} + +# --- Backend probe ------------------------------------------------------------ -# Returns a verdict hash for a given backend: {Live: bool, Reason: string} function Probe-Backend([string] $backendName, [string] $wxc) { - if ($Mock) { - # In mock mode all backends are "live" — enforcement is simulated. - return @{ Live = $true; Reason = "mock mode" } - } - if (-not (Test-Path $wxc)) { - return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } - } + if ($Mock) { return @{ Live = $true; Reason = "mock mode" } } + if (-not (Test-Path $wxc)) { return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } } if ($backendName -eq "process_container") { + # Use a REAL directory + absolute cmd.exe: the canonical wxc-exec passes + # cwd straight to CreateProcessW and does NOT expand %TEMP% (that yields + # 0x8007010B "directory name is invalid"). + $probeDir = Join-Path $env:TEMP "mxc-e2e-probe" + New-Item -ItemType Directory -Force $probeDir | Out-Null $config = @{ version = "0.6.0-alpha" containerId = "e2e-probe-pc" containment = "processcontainer" - process = @{ - commandLine = "cmd /c exit 0" - cwd = "%TEMP%" - timeout = 10 - } - filesystem = @{ readwritePaths = @("%TEMP%") } + process = @{ commandLine = "C:\Windows\System32\cmd.exe /c exit 0"; cwd = $probeDir; timeout = 30000 } # ms (MXC process.timeout is milliseconds) + filesystem = @{ readwritePaths = @($probeDir) } processContainer = @{ leastPrivilege = $false } } - $json = $config | ConvertTo-Json -Depth 20 -Compress - $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) - $b64 = [Convert]::ToBase64String($bytes) + $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($config | ConvertTo-Json -Depth 20 -Compress))) $outObj = & $wxc --config-base64 $b64 2>&1 $exitCode = $LASTEXITCODE $output = ($outObj -join "`n").ToLower() - if ($exitCode -eq 0) { - return @{ Live = $true; Reason = "process_container probe exit 0" } - } + if ($exitCode -eq 0) { return @{ Live = $true; Reason = "process_container probe exit 0" } } $reason = "process_container unavailable: exit $exitCode" if ($output -match "backend_error" -or $output -match "e_notimpl" -or $output -match "velocity") { $reason = "process_container backend_error (velocity keys not enabled)" @@ -151,7 +318,7 @@ function Probe-Backend([string] $backendName, [string] $wxc) { if ($exitCode -ne 0) { return @{ Live = $false; Reason = "isolation_session probe failed: exit $exitCode" } } - # Provision succeeded — deprovision immediately. + # Provision succeeded - deprovision immediately. $sandboxId = $null try { $rawOut = ($outObj -join "`n") @@ -179,303 +346,396 @@ function Probe-Backend([string] $backendName, [string] $wxc) { return @{ Live = $false; Reason = "unknown backend: $backendName" } } -# ── Mode setup ──────────────────────────────────────────────────────────────── +# --- Run ---------------------------------------------------------------------- +# Everything that can throw runs inside this try so the finally always produces +# the results bundle (summary + transcript + zip), even on a pre-flight failure. -$mode = if ($Mock) { "MOCK" } else { "REAL" } -Step "Pre-flight (mode=$mode, backend=$Backend)" - -if ($Mock) { - $env:OPENSHELL_MXC_MOCK_WXC = "1" - Info "OPENSHELL_MXC_MOCK_WXC=1 — mock mode: enforcement simulated" -} else { - Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue - if (-not (Test-Path $WxcExecPath)) { - throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." - } - $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath - Info "wxc-exec: $WxcExecPath" -} - -# Patch the TOML copy for backend + wxc_exec_path (mirrors run-demo.ps1). -$tomlText = Get-Content $toml -Raw -$backendLine = "backend = `"$Backend`"" -if ($tomlText -match '(?m)^\s*#?\s*backend\s*=') { - $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*backend\s*=.*$', $backendLine) -} else { - $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$backendLine") -} -if (-not $Mock) { - $escaped = $WxcExecPath.Replace('\', '\\') - $wxcLine = "wxc_exec_path = `"$escaped`"" - if ($tomlText -match '(?m)^\s*#?\s*wxc_exec_path\s*=') { - $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) - } else { - $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$wxcLine") - } -} -Set-Content $toml -Value $tomlText -Encoding UTF8 -Info "patched $(Split-Path $toml -Leaf): backend=$Backend" - -# Probe backend liveness now (used by scenario gate below). -$backendProbe = Probe-Backend -backendName $Backend -wxc $WxcExecPath -if ($backendProbe.Live) { - Ok "Backend '$Backend' is live: $($backendProbe.Reason)" -} else { - Warn "Backend '$Backend' is not live: $($backendProbe.Reason)" - Warn "Enforcement scenarios will SKIP; network-reject scenario will still run." -} - -# ── Port check ──────────────────────────────────────────────────────────────── - -Step "Check gateway port $Port" -$busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue -if ($busy) { - throw "port $Port in use (pid $($busy.OwningProcess)). Stop stale gateway first." -} -Ok "port $Port free" - -# ── Prepare DemoDir ─────────────────────────────────────────────────────────── - -Step "Prepare DemoDir $DemoDir" -New-Item -ItemType Directory -Force $DemoDir | Out-Null -Ok "DemoDir ready" - -$env:OPENSHELL_COMPUTE_DRIVER = "mxc" -$env:OPENSHELL_MXC_SHARE_DIR = $DemoDir - -# ── Start gateway ───────────────────────────────────────────────────────────── - -Step "Start gateway" -$gwLog = Join-Path $here "gateway.e2e.log" -$gwErrLog = "$gwLog.err" -Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue - -$gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--config", $toml, "--log-level", "info") ` - -WorkingDirectory $here -PassThru -NoNewWindow ` - -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog - -Info "gateway pid $($gw.Id); logs: $gwLog" - -$results = @() +$results = @() +$gw = $null +$harnessError = $null +$backendProbe = @{ Live = $false; Reason = "not probed" } +# Unique per-run suffix so a stale sandbox record from an earlier run can never +# collide with this run's `sandbox create` (the gateway persists names on disk). +# Sandbox names are limited to 19 characters, so keep the timestamp compact. +$runId = Get-Date -Format 'MMddHHmmss' try { - # Wait for listening - $deadline = (Get-Date).AddSeconds(30) - $ready = $false - while ((Get-Date) -lt $deadline) { - if ($gw.HasExited) { - Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } - throw "gateway exited early (code $($gw.ExitCode)). See log." + # Start the transcript inside the guarded region so a Start-Transcript failure + # is caught and the results bundle is still produced. Pre-flight runs + # immediately below, so the transcript still captures the whole run. + Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + $transcriptStarted = $true + + # --- Pre-flight ----------------------------------------------------------- + + if (-not $Mock) { + if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { + throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + + "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." } - if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { - $ready = $true - break - } - Start-Sleep -Milliseconds 500 } - if (-not $ready) { throw "gateway did not start within 30 s." } - Ok "gateway listening on $Port" - # Register CLI - Step "Register CLI" - $env:OPENSHELL_GATEWAY = "" - try { & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } } - catch { Info "gateway add: $($_.Exception.Message) (continuing)" } - try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } - catch { Info "gateway select: $($_.Exception.Message) (continuing)" } - Ok "CLI registered" + # -KeepRunning leaves the gateway up and breaks after the FIRST scenario (so the + # next one cannot collide on the port). A full-suite run would therefore execute + # only one scenario yet still report the suite as PASS. Require a single, + # explicitly-selected scenario so a partial run can never be mislabeled complete. + if ($KeepRunning -and -not $Scenario) { + throw "-KeepRunning requires -Scenario: it stops after the first scenario, so a full-suite run would report PASS on partial results. Re-run with e.g. -Scenario fs-rw-positive-negative -KeepRunning." + } - # ── Scenario definitions ────────────────────────────────────────────────── - # - # Each scenario is a hashtable: - # Name - unique identifier - # PolicyFile - path to the policy YAML fixture - # Backends - list: "both" / "process_container" / "isolation_session" - # ExpectFail - $true means `sandbox create` itself must fail (invalid_argument) - # ExpectArtifact - whether the workload should create its target file - # Description - human-readable label + foreach ($f in @($gateway, $cli, $toml)) { + if (-not (Test-Path $f)) { + throw "Missing artifact: $f`nBuild first or run from a demo-package folder." + } + } + if (-not (Test-Path $policyDir)) { + throw "e2e-policies/ directory not found at $policyDir" + } + + # Capture the pristine TOML once; every scenario renders a fresh copy from this. + $tomlBase = Get-Content $toml -Raw + + # --- Mode setup ----------------------------------------------------------- + + Step "Pre-flight (mode=$(if ($Mock) {'MOCK'} else {'REAL'}), backend=$Backend)" + $cliProbe = Invoke-NativeCaptured $cli @("--version") + $cliProbeText = ($cliProbe.Output -join "`n") + if ($cliProbe.ExitCode -ne 0) { + throw "CLI pre-flight failed for '$cli' (exit $($cliProbe.ExitCode)): $cliProbeText" + } + Ok "CLI executable allowed: $($cliProbe.Output -join ' ')" + + if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + Info "OPENSHELL_MXC_MOCK_WXC=1 - mock mode: enforcement simulated" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if (-not (Test-Path $WxcExecPath)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." + } + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + Info "wxc-exec: $WxcExecPath" + } + + $backendProbe = Probe-Backend -backendName $Backend -wxc $WxcExecPath + if ($backendProbe.Live) { + Ok "Backend '$Backend' is live: $($backendProbe.Reason)" + } else { + Warn "Backend '$Backend' is not live: $($backendProbe.Reason)" + Warn "Enforcement scenarios will SKIP; network-reject scenario will still run." + } + + Step "Check gateway port $Port" + $busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue + if ($busy) { throw "port $Port in use (pid $($busy.OwningProcess)). Stop stale gateway first." } + Ok "port $Port free" + + Step "Prepare DemoDir + read-only source + deny-probe dir" + New-Item -ItemType Directory -Force $DemoDir | Out-Null + New-Item -ItemType Directory -Force $roSrc | Out-Null + New-Item -ItemType Directory -Force $denyProbe | Out-Null + Set-Content -Path (Join-Path $roSrc "seed.txt") -Value "read-only seed" -Encoding UTF8 + Ok "DemoDir=$DemoDir roSrc=$roSrc denyProbe=$denyProbe" + + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + $env:OPENSHELL_MXC_SHARE_DIR = $DemoDir + + # --- Scenario definitions ------------------------------------------------- + # Kind: positive | deny | create-fail + # For deny: ControlTarget (granted, must be PRESENT) + DenyTarget (must be ABSENT). $allScenarios = @( @{ - Name = "fs-rw" - PolicyFile = Join-Path $policyDir "fs-rw.yaml" - Backends = "both" - ExpectFail = $false - ExpectArtifact = $true + Name = "fs-rw"; PolicyFile = Join-Path $policyDir "fs-rw.yaml" + SandboxId = "rw" + Backends = "both"; Kind = "positive" + PosTarget = (Join-Path $DemoDir "fs-rw-result.txt") Description = "rw grant on DemoDir; in-policy write should succeed" }, @{ - Name = "fs-readonly" - PolicyFile = Join-Path $policyDir "fs-readonly.yaml" - Backends = "both" - ExpectFail = $false - ExpectArtifact = $true - Description = "ro grant + rw share; write to ro dir should be denied" + Name = "fs-readonly"; PolicyFile = Join-Path $policyDir "fs-readonly.yaml" + SandboxId = "ro" + Backends = "both"; Kind = "deny" + ControlTarget = (Join-Path $DemoDir "fs-readonly-control.txt") + DenyTarget = (Join-Path $roSrc "fs-readonly-denied.txt") + Description = "write to read-only dir denied; control write to rw dir succeeds" }, @{ - Name = "fs-default-deny" - PolicyFile = Join-Path $policyDir "fs-empty.yaml" - Backends = "process_container" - ExpectFail = $false - ExpectArtifact = $false - Description = "empty filesystem policy; all writes denied (process_container only)" + Name = "fs-default-deny"; PolicyFile = Join-Path $policyDir "fs-empty.yaml" + SandboxId = "fd" + Backends = "process_container"; Kind = "deny" + DenyTarget = (Join-Path $denyProbe "fs-default-deny-denied.txt") + Description = "empty policy; ungranted write denied" }, @{ - Name = "network-reject" - PolicyFile = Join-Path $policyDir "network-reject.yaml" - Backends = "both" - ExpectFail = $true + Name = "network-reject"; PolicyFile = Join-Path $policyDir "network-reject.yaml" + SandboxId = "net" + Backends = "both"; Kind = "create-fail" Description = "network_policies rule causes sandbox create to fail (no live backend needed)" } ) - # Apply optional scenario filter. if ($Scenario) { - $filtered = $allScenarios | Where-Object { $_.Name -eq $Scenario } + # Wrap in @() so an exact single match stays an array: without it a lone + # match is a bare hashtable, its .Count is unreliable on PS 5.1, and + # $allScenarios would no longer be an array for the loop below. + $filtered = @($allScenarios | Where-Object { $_.Name -eq $Scenario }) if ($filtered.Count -eq 0) { throw "Scenario '$Scenario' not found. Available: $(($allScenarios | ForEach-Object { $_.Name }) -join ', ')" } $allScenarios = $filtered } - # ── Run scenarios ───────────────────────────────────────────────────────── + # --- Scenario loop -------------------------------------------------------- - foreach ($sc in $allScenarios) { - Step "Scenario: $($sc.Name)" - Info $sc.Description + try { + foreach ($sc in $allScenarios) { + Step "Scenario: $($sc.Name)" + Info $sc.Description - # Backend gate: skip enforcement scenarios when backend not live (and not mock and not ExpectFail). - $skipReason = $null - if (-not $sc.ExpectFail) { - $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) - if (-not $backendMatches) { - $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" - } elseif (-not $backendProbe.Live -and -not $Mock) { - $skipReason = "backend not live: $($backendProbe.Reason)" + # Backend gate (deny/positive scenarios need a live backend; create-fail does not). + $skipReason = $null + if ($sc.Kind -ne "create-fail") { + $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) + if (-not $backendMatches) { + $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" + } elseif (-not $backendProbe.Live -and -not $Mock) { + $skipReason = "backend not live: $($backendProbe.Reason)" + } } - } - - if ($null -ne $skipReason) { - Skip "$($sc.Name): $skipReason" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "SKIP"; Reason = $skipReason } - continue - } - - # Policy file must exist. - if (-not (Test-Path $sc.PolicyFile)) { - Bad "$($sc.Name): policy fixture not found at $($sc.PolicyFile)" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "policy fixture missing" } - continue - } - - # Build per-sandbox MXC workload config. Commands and working directories - # are create-time inputs, not gateway-wide settings. - $target = Join-Path $DemoDir "$($sc.Name)-result.txt" - Remove-Item $target -Force -ErrorAction SilentlyContinue - $targetFwd = $target.Replace('\', '/') - $demoDirFwd = $DemoDir.Replace('\', '/') - $driverConfig = @{ - mxc = @{ - command = @("cmd", "/c", "echo.ok>$targetFwd") - cwd = $demoDirFwd + if ($null -ne $skipReason) { + Skip "$($sc.Name): $skipReason" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "SKIP"; Reason = $skipReason } + continue } - } | ConvertTo-Json -Compress -Depth 4 - # Windows PowerShell 5.1 removes embedded quotes when it builds the - # native command line. Escape them so the CLI receives valid JSON. - $driverConfigArg = if ($PSVersionTable.PSVersion.Major -lt 7) { - $driverConfig.Replace('"', '\"') - } else { - $driverConfig - } - # Run sandbox create. - $createOut = $null - $createExitCode = 0 - try { - $createOut = & $cli sandbox create --name $sc.Name --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 - $createExitCode = $LASTEXITCODE - } catch { - $createOut = $_.Exception.Message - $createExitCode = 1 - } - $createOutStr = ($createOut -join "`n") - Info "create exit: $createExitCode" - # Delete sandbox (best-effort; no-op if create failed). - try { & $cli sandbox delete $sc.Name 2>&1 | Out-Null } catch {} + if (-not (Test-Path $sc.PolicyFile)) { + Bad "$($sc.Name): policy fixture not found at $($sc.PolicyFile)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "policy fixture missing" } + continue + } - # Evaluate. - if ($sc.ExpectFail) { - # network-reject: create must fail. - if ($createExitCode -ne 0) { - Ok "$($sc.Name): create correctly failed (exit $createExitCode)" - Info "output: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "create failed as expected" } + # Per-scenario gateway logs land in the bundle under their own names. + $gwLog = Join-Path $resultDir "gateway.$($sc.Name).log" + $gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log" + + # Build the per-sandbox workload command and clean prior artifacts. + if ($sc.Kind -eq "positive") { + Remove-Item $sc.PosTarget -Force -ErrorAction SilentlyContinue + $command = @($cmdExe, "/c", "echo ok 1> $($sc.PosTarget.Replace('\', '/'))") + } elseif ($sc.Kind -eq "deny") { + Remove-Item $sc.DenyTarget -Force -ErrorAction SilentlyContinue + $denied = $sc.DenyTarget.Replace('\', '/') + if ($sc.ControlTarget) { + Remove-Item $sc.ControlTarget -Force -ErrorAction SilentlyContinue + $control = $sc.ControlTarget.Replace('\', '/') + $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") + } else { + $command = @($cmdExe, "/c", "echo denied 1> $denied") + } } else { - Bad "$($sc.Name): create succeeded but should have failed" - Info "output: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } + $command = @($cmdExe, "/c", "exit 0") } - } else { - # Wiring check in mock mode: the artifact must match the policy's - # expected outcome. In particular, default-deny passes only when - # the workload cannot create its target file. - if ($Mock) { - if ($sc.ExpectArtifact) { - $deadline = (Get-Date).AddSeconds(10) - while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { - Start-Sleep -Milliseconds 300 + + $driverConfig = @{ + mxc = @{ + command = $command + cwd = $demoDirFwd + } + } | ConvertTo-Json -Compress -Depth 4 + + Render-Toml + # Preserve the exact rendered config + policy fixture used for this scenario. + Copy-Item $toml (Join-Path $resultDir "mxc-gateway.$($sc.Name).toml") -Force -ErrorAction SilentlyContinue + Copy-Item $sc.PolicyFile (Join-Path $resultDir "policy.$($sc.Name).yaml") -Force -ErrorAction SilentlyContinue + + $gw = Start-Gw + Info "gateway pid $($gw.Id)" + Register-Cli + + # Unique per-run sandbox name within the 19-character routable-name limit. + $sandboxName = "mxc-$($sc.SandboxId)-$runId" + try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} + + # Run sandbox create. Its exit status is only authoritative for the + # create-fail scenario; artifacts score workload scenarios. + $createOut = $null; $createExitCode = 0 + try { + $createResult = Invoke-NativeCaptured $cli @( + "sandbox", "create", "--name", $sandboxName, + "--policy", [string]$sc.PolicyFile, + "--driver-config-json", $driverConfig, + "--no-tty" + ) + $createOut = $createResult.Output + $createExitCode = $createResult.ExitCode + } catch { + $createOut = $_.Exception.Message; $createExitCode = 1 + } + $createOutStr = ($createOut -join "`n") + Info "create exit: $createExitCode (not used for scoring on non-create-fail scenarios)" + + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + + # Evaluate. + if ($sc.Kind -eq "create-fail") { + # A non-zero exit alone is NOT sufficient: gateway-registration, + # transport, or malformed-fixture errors also exit non-zero and would + # false-pass this scenario. Require a genuine policy-rejection signal + # (the driver rejects the network rule with invalid_argument naming + # network_policies) AND confirm it is not an infrastructure failure. + $rejected = ($createOutStr -match '(?i)network' ` + -or $createOutStr -match '(?i)invalid[_ -]?argument' ` + -or $createOutStr -match '(?i)policy' ` + -or $gwText -match '(?i)network_policies') + $infraFail = ($createOutStr -match '(?i)connection refused' ` + -or $createOutStr -match '(?i)not registered' ` + -or $createOutStr -match '(?i)transport error' ` + -or $createOutStr -match '(?i)failed to connect') + if ($createExitCode -ne 0 -and $rejected -and -not $infraFail) { + Ok "$($sc.Name): create correctly rejected by policy (exit $createExitCode)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "policy rejection" } + } elseif ($createExitCode -ne 0) { + Bad "$($sc.Name): create failed but not with a policy-rejection signal (possible harness/infra error)" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "non-rejection failure" } + } else { + Bad "$($sc.Name): create succeeded but should have failed" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } + } + } elseif ($sc.Kind -eq "positive") { + $present = Wait-File $sc.PosTarget 30 + if ($present) { + Ok "$($sc.Name): in-policy write produced artifact" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "artifact present" } + } else { + Bad "$($sc.Name): artifact absent ($($sc.PosTarget))" + Info "createOut: $createOutStr" + if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure (not a policy result)" } + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "artifact absent" } + } + } else { + # deny + if ($sc.ControlTarget) { + $controlPresent = Wait-File $sc.ControlTarget 30 + # Snapshot the deny target only AFTER the control artifact lands, so a + # late denied write (enforcement regression racing the control write) + # cannot be recorded as PASS. + $denyPresent = Test-Path $sc.DenyTarget + if ($controlPresent -and -not $denyPresent) { + Ok "$($sc.Name): control write succeeded; denied write correctly blocked" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "control present, deny absent" } + } elseif (-not $controlPresent) { + Bad "$($sc.Name): control write absent - agent did not run correctly (inconclusive denial)" + Info "createOut: $createOutStr" + if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure" } + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "control absent (agent did not run)" } + } else { + Bad "$($sc.Name): denied write was NOT blocked (artifact present)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } + } + } else { + # An empty policy has no writable control path. Require both an absent + # artifact and an explicit driver launch message so launch failures + # cannot false-pass the denial. + Start-Sleep -Seconds 3 + $denyPresent = Test-Path $sc.DenyTarget + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + if ($denyPresent) { + Bad "$($sc.Name): denied write was NOT blocked (artifact present)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } + } elseif (Launch-Failed $gwText) { + Bad "$($sc.Name): artifact absent but agent failed to launch - inconclusive" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "agent launch failed (inconclusive)" } + } elseif (-not (Launch-Succeeded $gwText)) { + Bad "$($sc.Name): artifact absent but no agent-launch evidence was recorded - inconclusive" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "agent launch not confirmed (inconclusive)" } + } else { + Ok "$($sc.Name): write correctly denied (artifact absent, agent launched)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "deny absent (default-deny enforced)" } } } - $artifactExists = Test-Path $target - if ($artifactExists -eq $sc.ExpectArtifact) { - $outcome = if ($artifactExists) { "present" } else { "absent" } - Ok "$($sc.Name): artifact $outcome as expected (mock wiring OK)" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "mock wiring: artifact $outcome as expected" } - } else { - Bad "$($sc.Name): artifact outcome did not match policy (present=$artifactExists, expected=$($sc.ExpectArtifact))" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "mock wiring: artifact present=$artifactExists, expected=$($sc.ExpectArtifact)" } - } + } + + if ($KeepRunning) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stopping after the first scenario so the next one doesn't collide on port $Port" + break } else { - # Real mode: artifact presence == enforcement worked. - $deadline = (Get-Date).AddSeconds(30) - while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { - Start-Sleep -Milliseconds 500 - } - if ($createExitCode -eq 0 -and (Test-Path $target)) { - Ok "$($sc.Name): in-policy write succeeded" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "in-policy write produced artifact" } - } else { - Bad "$($sc.Name): FAIL (create=$createExitCode, artifact=$(Test-Path $target))" - Info "createOut: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create=$createExitCode, artifact=$(Test-Path $target)" } - } + # Keep the sandbox alive until artifact-based scoring finishes. + # Real MXC startup is asynchronous and can otherwise be canceled + # before the workload writes its positive/control proof. + try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} + Stop-Gw $gw + $gw = $null } } - } - -} finally { - if ($KeepRunning) { - Info "leaving gateway pid $($gw.Id) running (-KeepRunning)" - } elseif ($gw -and -not $gw.HasExited) { - Step "Cleanup" - Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue - Info "stopped gateway pid $($gw.Id)" + } finally { + if ($gw -and -not $KeepRunning) { Stop-Gw $gw } + if ($KeepRunning -and $gw) { Info "gateway pid $($gw.Id) left running (-KeepRunning)" } } } +catch { + $harnessError = $_.Exception.Message + Bad "harness error: $harnessError" +} +finally { + # --- Summary + results bundle --------------------------------------------- + Step "Summary" + $results | Format-Table -AutoSize -# ── Summary table ───────────────────────────────────────────────────────────── + # Wrap in @() so a single match still yields an array with a .Count (PS 5.1). + $failCount = @($results | Where-Object { $_.Result -eq "FAIL" }).Count + $passCount = @($results | Where-Object { $_.Result -eq "PASS" }).Count + $skipCount = @($results | Where-Object { $_.Result -eq "SKIP" }).Count + Write-Host "PASS=$passCount FAIL=$failCount SKIP=$skipCount" -Step "Summary" -$results | Format-Table -AutoSize + $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } + $tableText = ($results | Format-Table -AutoSize | Out-String) + $summary = @" +OpenShell MXC e2e scenario run +============================== +timestamp : $stamp +machine : $env:COMPUTERNAME +verdict : $verdict +mode : $(if ($Mock) { 'MOCK' } else { 'REAL' }) +backend : $Backend +backend_live : $($backendProbe.Live) ($($backendProbe.Reason)) +wxc_exec : $WxcExecPath +gateway_port : $Port +totals : PASS=$passCount FAIL=$failCount SKIP=$skipCount +$(if ($harnessError) { "harness_error: $harnessError" }) -$failCount = ($results | Where-Object { $_.Result -eq "FAIL" }).Count -$passCount = ($results | Where-Object { $_.Result -eq "PASS" }).Count -$skipCount = ($results | Where-Object { $_.Result -eq "SKIP" }).Count +Per-scenario results: +$tableText +Files in this bundle ($resultDir): + summary.txt this summary + transcript.txt full console transcript + gateway..log / .err.log per-scenario gateway stdout/stderr + mxc-gateway..toml the exact gateway config rendered per scenario + policy..yaml the exact sandbox policy fixture used per scenario -Write-Host "PASS=$passCount FAIL=$failCount SKIP=$skipCount" +What PASS means: every non-skipped scenario met its expected verdict - positive +writes produced their artifact, deny writes were blocked with either a control +write or driver-launch evidence, and network-reject was refused by policy. +"@ + Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) -if ($failCount -gt 0) { + if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch {} } + + # Zip the bundle for easy return (defensive; never throw out of finally). + try { + $zip = Join-Path $here "results-e2e-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } +} + +if ($harnessError -or $failCount -gt 0) { Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red exit 1 } else { diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index ce8ddaff5..591c38bf2 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -23,6 +23,9 @@ # powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 ` # -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe # +# By default the per-sandbox egress proxy is ON so the full event set (including +# SandboxProxyConfigured) is produced. Pass -NoProxy to omit only that one event. +# # The deliverable is the OCSF audit log (openshell-ocsf..log) inside the # results-*.zip the script produces. Pass -ShareOut '\\server\share' to also copy # the bundle to a shared location (off by default). @@ -35,6 +38,8 @@ param( [string] $ShareDir = "C:\work\openshell-mxc-demo", # How many sandboxes to create (each drives a full event burst). [int] $SandboxCount = 2, + # Disable the per-sandbox egress proxy (omits the SandboxProxyConfigured event). + [switch] $NoProxy, # Gateway bind port (matches the gateway default) + CLI registration name. [int] $Port = 17670, [string] $GatewayName = "openshell-mxc-ocsf", @@ -90,6 +95,7 @@ $helloPath = Join-Path $ShareDir "hello.txt" $gw = $null $gatewayEtwSessions = @() $passed = $true +$proxyOn = -not $NoProxy try { # 1. Validate artifacts + privilege. @@ -127,6 +133,12 @@ try { } else { $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`netw_audit = true") } + $proxyVal = if ($proxyOn) { 'true' } else { 'false' } + if ($tomlText -match '(?m)^\s*#?\s*egress_proxy\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*egress_proxy\s*=.*$', "egress_proxy = $proxyVal") + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`negress_proxy = $proxyVal") + } Set-Content $toml -Value $tomlText -Encoding UTF8 $shareDirPolicy = $ShareDir.Replace('\', '/') @@ -156,7 +168,7 @@ try { $driverConfig } - Info "backend=process_container etw_audit=true" + Info "backend=process_container etw_audit=true egress_proxy=$proxyVal" Info "workload cwd=$shareDirPolicy policy grant=$shareDirPolicy" # 3. Port must be free. Auto-clear a stale OUR-gateway; refuse anything else. @@ -199,6 +211,10 @@ try { $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath $env:OPENSHELL_OCSF_JSON = "1" $env:OPENSHELL_OCSF_LOG_DIR = $resultDir + # Config path goes through the env var (clap: OPENSHELL_GATEWAY_CONFIG), NOT a + # --config token: Start-Process -ArgumentList does not quote array elements, so a + # config path containing a space gets split and the gateway's arg parser rejects it. + $env:OPENSHELL_GATEWAY_CONFIG = $toml Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue # 7. Start the gateway (background, TLS disabled on the loopback control plane). @@ -206,7 +222,7 @@ try { $gwLog = Join-Path $resultDir "gateway.log" $gwErrLog = Join-Path $resultDir "gateway.err.log" $gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--config", $toml, "--port", $Port, "--log-level", "info") ` + -ArgumentList @("--disable-tls", "--port", $Port, "--log-level", "info") ` -WorkingDirectory $here -PassThru -NoNewWindow ` -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog Info "gateway pid $($gw.Id); logs -> $(Split-Path $gwLog -Leaf) (+ .err)" @@ -314,7 +330,9 @@ finally { # Event-type coverage (detected from the human-readable shorthand lines). function Seen([string]$pat) { [bool]($logText | Select-String -Pattern $pat -Quiet) } - # Expected happy-path ETW->OCSF event types for this run. + # Expected happy-path ETW->OCSF event types for THIS run. The egress-proxy + # event only fires when the proxy is enabled, so it only counts toward the + # expected total when -NoProxy was NOT passed. $coreEvents = [ordered]@{ "sandbox lifecycle (start)" = Seen "(?i)ocsf:.*LIFECYCLE:" "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" @@ -324,6 +342,7 @@ finally { "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" "process launch (executable identity)" = Seen "(?i)ocsf:.*PROC:LAUNCH" } + if ($proxyOn) { $coreEvents["egress proxy configured"] = Seen "(?i)ocsf:.*proxy configured" } # Findings are anomaly / fallback signals - reported separately, NOT part of # the expected-coverage denominator (a clean run may emit none). @@ -354,6 +373,7 @@ user : $env:USERNAME (admin=$admin perfLogUsers=$plu) verdict : $verdict event coverage : $coreObserved of $coreExpected expected event types fired (+ $findingsObserved anomaly finding(s)) queue overload : $(if ($consumerOverloaded) { 'YES - ETW records dropped; audit coverage gap' } else { 'no dropped ETW records observed' }) +proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits egress proxy event)' }) workload output : $(if ($workloadCompleted) { $helloPath } else { '(missing)' }) wxc_exec : $WxcExecPath backend : process_container diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 975af1d08..60f824516 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -4,7 +4,7 @@ //! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, //! and self-reported readiness. -use crate::mxc::{MxcFilesystem, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; @@ -17,6 +17,8 @@ use openshell_core::proto::compute::v1::{ use openshell_core::proto_struct::struct_to_json_value; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; use tokio::sync::{Mutex, broadcast, mpsc, watch}; @@ -65,6 +67,14 @@ pub struct MxcComputeConfig { /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, + /// Enable Pattern-C governed egress. When true, MXC receives filesystem + /// grants plus a `network.proxy` redirect and the host CONNECT proxy + /// receives the trimmed network-only policy. + pub egress_proxy: bool, + /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is + /// enabled. The driver preserves the loopback IP and allocates a unique + /// ephemeral port per sandbox. + pub egress_proxy_addr: String, /// Enable `--debug` flag on `wxc-exec` invocations. pub debug: bool, @@ -82,6 +92,8 @@ impl Default for MxcComputeConfig { pc_least_privilege: false, pc_capabilities: Vec::new(), default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), + egress_proxy: false, + egress_proxy_addr: String::new(), debug: false, etw_audit: false, @@ -118,6 +130,9 @@ struct SandboxEntry { lifecycle_gate: Arc>, monitor_cancel: Option>, monitor_task: Option>, + trimmed_policy: Option, + proxy_addr: Option, + host_proxy: Option, } impl std::fmt::Debug for SandboxEntry { @@ -226,15 +241,30 @@ fn sandbox_config(sandbox: &DriverSandbox) -> Result Vec { - let Some(spec) = sandbox.spec.as_ref() else { - return Vec::new(); - }; - let mut environment = spec - .template - .as_ref() - .map_or_else(HashMap::new, |template| template.environment.clone()); - environment.extend(spec.environment.clone()); + // Released wxc-exec ProcessContainer builds start from the explicit + // process environment. Seed only the non-secret Windows bootstrap values; + // copying the gateway's full environment would leak unrelated host secrets + // into untrusted sandbox workloads. + let mut environment = MINIMAL_WINDOWS_BOOTSTRAP_ENV + .iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| ((*key).to_string(), value)) + }) + .collect::>(); + if let Some(spec) = sandbox.spec.as_ref() { + if let Some(template) = spec.template.as_ref() { + environment.extend(template.environment.clone()); + } + environment.extend(spec.environment.clone()); + } let mut environment = environment .into_iter() .map(|(key, value)| format!("{key}={value}")) @@ -243,6 +273,43 @@ fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { environment } +fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { + if !config.egress_proxy { + return Ok(None); + } + if config.backend == MxcBackend::IsolationSession { + return Err(tonic::Status::invalid_argument( + "mxc governed egress requires process_container; network.proxy is not supported on isolation_session until MXC M1 lands", + )); + } + let raw = config.egress_proxy_addr.trim(); + if raw.is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc egress_proxy_addr is required when egress_proxy is enabled", + )); + } + let addr = raw.parse::().map_err(|error| { + tonic::Status::invalid_argument(format!( + "mxc egress_proxy_addr must be an IP:PORT socket address: {error}" + )) + })?; + if addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { + return Err(tonic::Status::invalid_argument(format!( + "mxc egress_proxy_addr must be 127.0.0.1:PORT because MXC 0.6.0-alpha can encode only a localhost proxy port (got {})", + addr.ip() + ))); + } + Ok(Some(addr)) +} + +fn allocate_sandbox_proxy_addr( + configured: SocketAddr, +) -> std::io::Result<(SocketAddr, std::net::TcpListener)> { + let reservation = std::net::TcpListener::bind(SocketAddr::new(configured.ip(), 0))?; + let addr = reservation.local_addr()?; + Ok((addr, reservation)) +} + fn encode_windows_command_line(args: &[String]) -> String { args.iter() .map(|arg| quote_windows_argument(arg)) @@ -276,6 +343,66 @@ fn quote_windows_argument(arg: &str) -> String { quoted.push('"'); quoted } +fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { + config + .command + .first() + .filter(|command| !command.trim().is_empty()) + .map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from) +} + +const TLS_ENV_KEYS: [&str; 6] = [ + "NODE_EXTRA_CA_CERTS", + "DENO_CERT", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", +]; + +fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>) { + let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { + return; + }; + + env.retain(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + !TLS_ENV_KEYS + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) + }); + + let ca_cert_path = ca_cert_path.display().to_string(); + let combined_bundle_path = combined_bundle_path.display().to_string(); + env.extend([ + format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"), + format!("DENO_CERT={ca_cert_path}"), + format!("SSL_CERT_FILE={combined_bundle_path}"), + format!("REQUESTS_CA_BUNDLE={combined_bundle_path}"), + format!("CURL_CA_BUNDLE={combined_bundle_path}"), + format!("GIT_SSL_CAINFO={combined_bundle_path}"), + ]); +} + +fn append_tls_readwrite_grant( + readwrite_paths: &mut Vec, + ca_paths: Option<&(PathBuf, PathBuf)>, +) { + let Some((ca_cert_path, _)) = ca_paths else { + return; + }; + let Some(dir) = ca_cert_path.parent() else { + return; + }; + let dir = dir.display().to_string(); + if !readwrite_paths + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&dir)) + { + readwrite_paths.push(dir); + } +} + impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); @@ -363,13 +490,14 @@ impl MxcComputeBackend { &self, sandbox_id: &str, policy: Option<&SandboxPolicy>, + egress: Option, ) -> Result { self.policy_mapper .map( policy, &MapCtx { sandbox_id: sandbox_id.to_string(), - egress: None, + egress, }, ) .map_err(|error| tonic::Status::invalid_argument(error.to_string())) @@ -378,7 +506,8 @@ impl MxcComputeBackend { pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { Self::validate_sandbox_fields(sandbox)?; let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - self.map_sandbox_policy(&sandbox.id, policy)?; + let egress_addr = configured_egress_addr(&self.config)?; + self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; Ok(()) } pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { @@ -399,12 +528,25 @@ impl MxcComputeBackend { Self::validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; + let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { + Some(configured_addr) => { + let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( + |error| { + tonic::Status::internal(format!( + "failed to allocate sandbox-unique MXC host egress proxy address from {configured_addr}: {error}" + )) + }, + )?; + (Some(addr), Some(reservation)) + } + None => (None, None), + }; // Policy translation is deterministic and side-effect free. Do it before // inserting the registry entry or launching MXC so invalid requests fail // synchronously at the CreateSandbox boundary. let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let mapped = self.map_sandbox_policy(&sandbox_id, policy)?; + let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; if sandbox .spec @@ -452,6 +594,9 @@ impl MxcComputeBackend { lifecycle_gate, monitor_cancel: None, monitor_task: None, + trimmed_policy: None, + proxy_addr: None, + host_proxy: None, }, ); } @@ -472,6 +617,7 @@ impl MxcComputeBackend { sandbox, sandbox_config, mapped, + reserved_proxy_listener, startup_guard, ) .await; @@ -524,6 +670,7 @@ impl MxcComputeBackend { let mut registry = self.registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { entry.isolation_stopped = isolation_stopped; + entry.host_proxy = None; entry.phase_state = PhaseState::Stopped; entry.sandbox = make_sandbox_with_condition( &entry.sandbox, @@ -668,29 +815,102 @@ async fn run_lifecycle( sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, mapped: MappedConfig, + mut reserved_proxy_listener: Option, _startup_guard: tokio::sync::OwnedMutexGuard<()>, ) { let sandbox_id = sandbox.id.clone(); let sandbox_name = sandbox.name.clone(); + let trimmed_policy = mapped.trimmed_policy.clone(); + let proxy_addr = mapped.proxy_addr; + let host_proxy = if !invoker.is_mock() + && let (Some(addr), Some(proxy_policy)) = (proxy_addr, trimmed_policy.clone()) + { + drop(reserved_proxy_listener.take()); + match openshell_supervisor_network::host::start_host_proxy( + openshell_supervisor_network::host::HostProxyConfig { + bind_addr: addr, + policy: proxy_policy, + binary_path: host_proxy_binary_path(&sandbox_config), + sandbox_id: Some(sandbox_id.clone()), + sandbox_name: Some(sandbox_name.clone()), + openshell_endpoint: None, + provider_credentials: None, + agent_proposals: openshell_core::proposals::AgentProposals::default(), + denial_tx: None, + activity_tx: None, + }, + ) + .await + { + Ok(handle) => Some(handle), + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &format!("failed to start MXC host egress proxy at {addr}: {error}"), + ) + .await; + return; + } + } + } else { + None + }; + let host_proxy_ca_paths = host_proxy.as_ref().and_then(|proxy| proxy.ca_file_paths()); + drop(reserved_proxy_listener.take()); + if let Some(addr) = proxy_addr { + { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.trimmed_policy = trimmed_policy; + entry.proxy_addr = Some(addr); + entry.host_proxy = host_proxy; + } + } + let _ = watch_tx.send(platform_event( + sandbox_id.clone(), + "EgressRedirect", + format!("MXC egress redirected to OpenShell host CONNECT proxy at {addr}"), + )); + } + + // Released wxc-exec BaseContainer builds cannot provision the generated + // TLS directory as a read-only share because that path fails its WRITE_DAC + // setup. Grant the sandbox-unique directory read-write instead so the + // AppContainer can actually read the injected trust paths. The directory + // contains only public CA certificates; the CA private key remains in the + // host proxy's in-memory TLS state. + let mut readwrite_paths = mapped.readwrite_paths; + append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); + let readonly_paths = mapped.readonly_paths; let filesystem = MxcFilesystem { - readwrite_paths: mapped.readwrite_paths, - readonly_paths: mapped.readonly_paths, + readwrite_paths, + readonly_paths, // OpenShell's policy model has no explicit deny field; default-deny is // implicit and enforced by processContainer at the OS boundary. denied_paths: Vec::new(), }; let command_line = encode_windows_command_line(&sandbox_config.command); + let mut environment = sandbox_environment(&sandbox); + append_tls_env_vars(&mut environment, host_proxy_ca_paths.as_ref()); + info!(sandbox = %sandbox_name, count = environment.len(), "MXC process env vars"); let process = MxcProcess { command_line: command_line.clone(), cwd: sandbox_config.cwd, - env: sandbox_environment(&sandbox), + env: environment, timeout: 0, }; + let network = proxy_addr.map(|addr| MxcNetwork { + default_policy: "block".into(), + proxy: Some(addr), + }); let child = match config.backend { MxcBackend::IsolationSession => { let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem, None) + .provision(&config.default_configuration_id, filesystem, network) .await { Ok(id) => id, @@ -748,7 +968,7 @@ async fn run_lifecycle( capabilities: config.pc_capabilities.clone(), }; match invoker - .run_oneshot(&sandbox_id, filesystem, process_container, process, None) + .run_oneshot(&sandbox_id, filesystem, process_container, process, network) .await { Ok(child) => child, @@ -890,6 +1110,7 @@ async fn monitor_exec( ); let mut registry = registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.host_proxy = None; entry.sandbox = done.clone(); entry.phase_state = PhaseState::Running; } @@ -917,6 +1138,7 @@ async fn monitor_exec( ); let mut registry = registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.host_proxy = None; entry.sandbox = failed.clone(); entry.phase_state = PhaseState::Failed(format!("exit code {code}")); } @@ -949,6 +1171,7 @@ async fn set_failed( ); let mut reg = registry.lock().await; if let Some(entry) = reg.get_mut(sandbox_id) { + entry.host_proxy = None; entry.sandbox = failed.clone(); entry.phase_state = PhaseState::Failed(message.to_string()); } @@ -992,7 +1215,9 @@ mod lifecycle_tests { use super::*; use futures::StreamExt; use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; - use openshell_core::proto::{FilesystemPolicy, SandboxPolicy}; + use openshell_core::proto::{ + FilesystemPolicy, MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy, + }; use std::time::Duration; fn driver_sandbox(id: &str) -> DriverSandbox { @@ -1071,14 +1296,22 @@ mod lifecycle_tests { #[test] fn mxc_config_defaults_to_default_deny_process_container() { - assert_eq!( - MxcComputeConfig::default().backend, - MxcBackend::ProcessContainer - ); + let config = MxcComputeConfig::default(); + assert_eq!(config.backend, MxcBackend::ProcessContainer); + assert!(!config.egress_proxy); + assert!(config.egress_proxy_addr.is_empty()); } #[test] - fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { + fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { + let configured = "127.0.0.1:18080".parse().unwrap(); + let (addr, _reservation) = allocate_sandbox_proxy_addr(configured).unwrap(); + assert_eq!(addr.ip(), configured.ip()); + assert_ne!(addr.port(), 0); + } + + #[test] + fn sandbox_environment_inherits_host_with_spec_precedence() { let mut sandbox = driver_sandbox("sb-env"); let spec = sandbox.spec.as_mut().unwrap(); spec.template @@ -1088,10 +1321,63 @@ mod lifecycle_tests { .insert("SHARED".into(), "template".into()); spec.environment.insert("SHARED".into(), "spec".into()); spec.environment.insert("TOKEN".into(), "value".into()); - assert_eq!( - sandbox_environment(&sandbox), - vec!["SHARED=spec".to_string(), "TOKEN=value".to_string()] + let environment = sandbox_environment(&sandbox); + assert!(environment.contains(&"SHARED=spec".to_string())); + assert!(environment.contains(&"TOKEN=value".to_string())); + for key in MINIMAL_WINDOWS_BOOTSTRAP_ENV { + if let Ok(value) = std::env::var(key) { + assert!(environment.contains(&format!("{key}={value}"))); + } + } + assert!(environment.iter().all(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + key == "SHARED" || key == "TOKEN" || MINIMAL_WINDOWS_BOOTSTRAP_ENV.contains(&key) + })); + } + + #[test] + fn tls_env_vars_replace_user_trust_overrides() { + let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); + let ca_cert = tls_dir.join("openshell-ca.pem"); + let bundle = tls_dir.join("ca-bundle.pem"); + let ca_cert_path = ca_cert.display().to_string(); + let bundle_path = bundle.display().to_string(); + let mut env = vec![ + "FOO=bar".to_string(), + "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), + "node_extra_ca_certs=C:\\old\\ca.pem".to_string(), + ]; + + append_tls_env_vars(&mut env, Some(&(ca_cert, bundle))); + + assert!(env.contains(&"FOO=bar".to_string())); + assert!( + !env.iter() + .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") ); + assert!( + !env.iter() + .any(|entry| entry == "node_extra_ca_certs=C:\\old\\ca.pem") + ); + assert!(env.contains(&format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"))); + assert!(env.contains(&format!("DENO_CERT={ca_cert_path}"))); + assert!(env.contains(&format!("SSL_CERT_FILE={bundle_path}"))); + assert!(env.contains(&format!("REQUESTS_CA_BUNDLE={bundle_path}"))); + assert!(env.contains(&format!("CURL_CA_BUNDLE={bundle_path}"))); + assert!(env.contains(&format!("GIT_SSL_CAINFO={bundle_path}"))); + } + + #[test] + fn tls_readwrite_grant_adds_ca_directory_once() { + let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); + let ca_cert = tls_dir.join("openshell-ca.pem"); + let bundle = tls_dir.join("ca-bundle.pem"); + let existing = tls_dir.display().to_string().to_ascii_lowercase(); + let mut readwrite = vec![existing.clone()]; + + append_tls_readwrite_grant(&mut readwrite, Some(&(ca_cert, bundle))); + + assert_eq!(readwrite, vec![existing]); } #[test] @@ -1220,6 +1506,116 @@ mod lifecycle_tests { ); } + #[tokio::test] + async fn split_path_provisions_with_proxy_redirect() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let mut config = MxcComputeConfig::default(); + config.backend = MxcBackend::ProcessContainer; + config.egress_proxy = true; + config.egress_proxy_addr = "127.0.0.1:18080".into(); + let backend = MxcComputeBackend::new_mocked(config); + let mut stream = backend.watch_sandboxes().await; + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ports: vec![443], + protocol: "rest".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + let sandbox = with_policy( + driver_sandbox_with_command("sb-egress", &share, cmd), + policy.clone(), + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + + let ready = wait_for(&backend, "sb-egress", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!( + ready.is_some(), + "egress split sandbox should reach Ready=True" + ); + + let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); + assert_eq!(recorded["network"]["defaultPolicy"], "block"); + assert!(recorded["network"].get("allowedHosts").is_none()); + assert!(recorded["network"].get("blockedHosts").is_none()); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + let proxy_port = recorded["network"]["proxy"]["localhost"] + .as_u64() + .expect("proxy localhost port"); + assert!(proxy_port > 0); + assert!(proxy_port <= u64::from(u16::MAX)); + assert!( + recorded["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + recorded["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); + + let reg = backend.registry.lock().await; + let entry = reg.get("sb-egress").expect("registry entry"); + let entry_proxy_addr = entry.proxy_addr.expect("proxy addr"); + assert_eq!( + entry_proxy_addr.ip(), + std::net::IpAddr::from([127, 0, 0, 1]) + ); + assert_eq!(u64::from(entry_proxy_addr.port()), proxy_port); + assert_eq!( + entry.trimmed_policy.as_ref().unwrap().network_policies, + policy.network_policies + ); + drop(reg); + + let mut saw_redirect = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { + Ok(Some(Ok(ev))) => { + if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload + && pe + .event + .as_ref() + .is_some_and(|e| e.reason == "EgressRedirect") + { + saw_redirect = true; + break; + } + } + Ok(_) => break, + Err(_) => continue, + } + } + assert!(saw_redirect, "expected EgressRedirect platform event"); + } + #[tokio::test] async fn negative_out_of_policy_write_is_denied_with_event() { let share_tmp = tempfile::tempdir().unwrap(); @@ -1346,4 +1742,33 @@ mod lifecycle_tests { assert_eq!(error.code(), tonic::Code::InvalidArgument); assert!(backend.get_sandbox("sb-net").await.is_none()); } + + #[tokio::test] + async fn governed_egress_rejects_network_middleware_before_lifecycle() { + let mut config = MxcComputeConfig::default(); + config.egress_proxy = true; + config.egress_proxy_addr = "127.0.0.1:18080".into(); + let backend = MxcComputeBackend::new_mocked(config); + + let mut policy = fs_policy(&[]); + policy.network_middlewares.insert( + "redactor".into(), + NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: "openshell/regex".into(), + on_error: "fail_closed".into(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api.example.com".into()], + exclude: Vec::new(), + }), + ..Default::default() + }, + ); + let sandbox = with_policy(driver_sandbox("sb-middleware"), policy); + + let error = backend.create_sandbox(&sandbox).await.unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("network_middlewares")); + assert!(backend.get_sandbox("sb-middleware").await.is_none()); + } } diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 32d7006d9..0a77565f8 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -99,10 +99,10 @@ fn network_json(network: &MxcNetwork) -> serde_json::Value { // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. // The MxcNetwork.proxy field remains SocketAddr so callers keep full // precision; only the port is serialized into the localhost key. + // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, + // even when they are empty. The host proxy enforces the L7 allowlist. let mut value = serde_json::json!({ "defaultPolicy": network.default_policy.as_str(), - "allowedHosts": [], - "blockedHosts": [], }); if let Some(proxy) = network.proxy { value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); @@ -302,6 +302,10 @@ impl WxcExecInvoker { } } + pub(crate) const fn is_mock(&self) -> bool { + self.mock + } + /// Test-only constructor that forces mock mode without touching the /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across /// parallel tests under edition 2024's `unsafe` `set_var`). @@ -754,18 +758,8 @@ mod tests { let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); assert_eq!(config["network"]["defaultPolicy"], "block"); - assert!( - config["network"]["allowedHosts"] - .as_array() - .unwrap() - .is_empty() - ); - assert!( - config["network"]["blockedHosts"] - .as_array() - .unwrap() - .is_empty() - ); + assert!(config["network"].get("allowedHosts").is_none()); + assert!(config["network"].get("blockedHosts").is_none()); // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. assert_eq!(config["network"]["proxy"]["localhost"], 18080); assert!( @@ -788,6 +782,8 @@ mod tests { proxy: Some("127.0.0.1:18080".parse().unwrap()), }; let value = network_json(&network); + assert!(value.get("allowedHosts").is_none()); + assert!(value.get("blockedHosts").is_none()); assert_eq!(value["proxy"]["localhost"], 18080); assert!(value["proxy"].get("host").is_none()); assert!(value["proxy"].get("port").is_none()); diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index ce0a5a4a3..30a0ca56d 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -189,7 +189,9 @@ impl PolicyMapper for EmbeddedPolicyMapper { #[cfg(test)] mod tests { use super::*; - use openshell_core::proto::FilesystemPolicy; + use openshell_core::proto::{ + FilesystemPolicy, MiddlewareEndpointSelector, NetworkMiddlewareConfig, + }; fn demo_ctx() -> MapCtx { MapCtx { @@ -290,4 +292,35 @@ mod tests { assert_eq!(trimmed.network_policies, policy.network_policies); assert!(trimmed.filesystem.is_none()); } + + #[test] + fn embedded_rejects_network_middleware_on_egress_proxy() { + let mapper = EmbeddedPolicyMapper; + let mut policy = fs_policy(&["C:/work/demo"], &[]); + policy.network_middlewares.insert( + "redactor".into(), + NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: "openshell/regex".into(), + on_error: "fail_closed".into(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api.example.com".into()], + exclude: Vec::new(), + }), + ..Default::default() + }, + ); + let ctx = MapCtx { + sandbox_id: "sb-egress-middleware".into(), + egress: Some("127.0.0.1:18080".parse().unwrap()), + }; + + let error = mapper.map(Some(&policy), &ctx).unwrap_err(); + let MapError::Unsupported(loss) = error else { + panic!("expected unsupported middleware error"); + }; + assert_eq!(loss.len(), 1); + assert_eq!(loss[0].rule_kind, "network_middlewares"); + assert!(loss[0].detail.contains("middleware service registry")); + } } diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs index 9e83c20a7..cf366b0b9 100644 --- a/crates/openshell-driver-mxc/src/policy_map/loss.rs +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Loss-report model shared by the coarse map and the lossless split. +//! Loss-report model shared by the coarse map and the governed-egress split. use std::collections::HashSet; diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 7d9025e5a..4bfcc316a 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -21,7 +21,7 @@ use super::config::{ use super::loss::{LossItem, add_loss}; /// Options controlling the generated MXC config. Fields not relevant to the -/// coarse map (e.g. `proxy_redirect`) are reserved for the lossless split. +/// coarse map (e.g. `proxy_redirect`) are reserved for the governed-egress split. #[derive(Clone, Debug)] pub struct MxcMappingOptions { /// MXC schema version written into `version`. @@ -40,7 +40,7 @@ pub struct MxcMappingOptions { pub timeout_ms: u64, /// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness. pub allow_wildcards: bool, - /// Governed-egress redirect address (used by the lossless split, not the + /// Governed-egress redirect address (used by the governed-egress split, not the /// coarse map). pub proxy_redirect: Option, } @@ -68,16 +68,16 @@ pub struct MxcMappingResult { pub loss: Vec, } -/// Result of the lossless split: the MXC config carries filesystem grants and a +/// Result of the governed-egress split: the MXC config carries filesystem grants and a /// proxy redirect; the full network policy is returned unchanged for the /// `OpenShell` CONNECT proxy to enforce. #[derive(Clone, Debug)] pub struct SplitPolicyResult { /// MXC `ContainerConfig` with filesystem grants and `network.proxy` redirect. /// - /// `network.allowedHosts` is empty — direct egress is blocked at the MXC - /// layer. All outbound connections flow through the proxy; the proxy enforces - /// the full `OpenShell` network policy. + /// Direct egress is blocked at the MXC layer. Unsupported host-list fields + /// are omitted; all outbound connections flow through the proxy, which + /// enforces the full `OpenShell` network policy. pub mxc_config: Value, /// Full `OpenShell` network policy preserved verbatim for the host CONNECT /// proxy. Only `network_policies` is populated; the proxy does not enforce @@ -95,15 +95,16 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin MxcMappingResult { config, loss } } -/// Lossless split: map filesystem + containment to MXC, delegate network to the +/// Governed-egress split: map filesystem + containment to MXC, delegate network to the /// `OpenShell` CONNECT proxy. /// /// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to -/// `opts.proxy_redirect` and leaves `allowedHosts` empty — direct -/// egress is blocked at the MXC layer and all outbound connections flow through -/// the proxy. [`SplitPolicyResult::proxy_policy`] carries the original +/// `opts.proxy_redirect` and omits unsupported host-list fields. Direct egress +/// is blocked at the MXC layer and all outbound connections flow through the +/// proxy. [`SplitPolicyResult::proxy_policy`] carries the original /// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard -/// loss items are generated for the network side. +/// loss items are generated for those rules. Network middleware is rejected +/// until the host proxy can receive the gateway middleware service registry. /// /// Returns `None` if `opts.proxy_redirect` is not set. Use [`map_to_mxc`] /// for the standalone coarse path when no proxy is in the loop. @@ -170,9 +171,23 @@ fn build_split_mxc_config( "The host proxy receives the trimmed policy and enforces network rules.", ); } + if !policy.network_middlewares.is_empty() { + add_loss( + items, + "network_middlewares", + "error", + &format!( + "{} network middleware config(s) cannot be enforced because the MXC host proxy is not connected to the gateway middleware service registry.", + policy.network_middlewares.len() + ), + "network egress middleware", + "The MXC sandbox is rejected before launch instead of bypassing fail-open middleware or failing unrelated allowed traffic.", + ); + } // Direct egress is blocked; all outbound flows through the OpenShell proxy. - // allowedHosts is intentionally empty — the proxy enforces the full policy. + // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, + // even when empty, so the proxy path omits both fields. // // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. // {"host": ..., "port": ...} and every other shape is rejected — verified @@ -191,11 +206,7 @@ fn build_split_mxc_config( "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", ); } - let mut network = json!({ - "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], - }); + let mut network = json!({ "defaultPolicy": "block" }); if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { network["proxy"] = json!({ "localhost": proxy_addr.port() }); } diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs index 52dc7f186..156cd8ea7 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -18,7 +18,7 @@ //! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) //! is recorded in the loss report. Use this when MXC enforces network on its //! own, with no `OpenShell` proxy in the loop. -//! - [`split_policy`] — the *lossless* split for the Windows MXC compute +//! - [`split_policy`] — the governed-egress split for the Windows MXC compute //! driver: MXC handles filesystem + containment + a `network.proxy` redirect, //! while the full `OpenShell` network policy is preserved in a trimmed policy //! enforced by the host CONNECT proxy. diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index 4d3a3cfa5..d8d6a3790 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -142,7 +142,7 @@ fn all_example_policies_map_with_invariants() { } #[test] -fn all_example_policies_split_with_lossless_invariants() { +fn all_example_policies_split_with_expected_invariants() { let root = examples_root(); let mut policies = Vec::new(); discover(&root, &mut policies); @@ -216,12 +216,28 @@ fn all_example_policies_split_with_lossless_invariants() { assert_eq!(cfg["network"]["proxy"]["localhost"], 18080); assert!(cfg["network"]["proxy"].get("host").is_none()); assert!(cfg["network"]["proxy"].get("port").is_none()); - assert!( - result.loss.iter().all(|i| i.severity != "error"), - "processcontainer split must not emit error losses for {}: {:?}", - path.display(), - result.loss - ); + let errors: Vec<_> = result + .loss + .iter() + .filter(|item| item.severity == "error") + .collect(); + if policy.network_middlewares.is_empty() { + assert!( + errors.is_empty(), + "processcontainer split must not emit error losses for {}: {:?}", + path.display(), + result.loss + ); + } else { + assert_eq!( + errors.len(), + 1, + "middleware policy must have one fail-closed loss for {}: {:?}", + path.display(), + result.loss + ); + assert_eq!(errors[0].path, "network_middlewares"); + } } } @@ -297,12 +313,11 @@ fn split_policy_routes_network_to_proxy() { "non-127.0.0.1 redirect must produce an error loss item" ); - // Direct egress is blocked; allowedHosts is empty (proxy enforces the list). + // Direct egress is blocked; unsupported host-list fields are omitted and + // the proxy enforces the full list. assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!( - str_list(&cfg["network"]["allowedHosts"]).is_empty(), - "split path must not populate allowedHosts" - ); + assert!(cfg["network"].get("allowedHosts").is_none()); + assert!(cfg["network"].get("blockedHosts").is_none()); // Filesystem grants are preserved unchanged. assert_eq!( diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index cc0c64859..a4ed71117 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -393,11 +393,9 @@ fn a_split_proxy_localhost_port() { result.mxc_config["network"]["proxy"]["localhost"], 18080, "split must emit network.proxy.localhost == port" ); - // allowedHosts stays empty on the split path. - assert!( - str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), - "split path must have empty allowedHosts" - ); + // Released wxc-exec rejects host-list fields, even when empty. + assert!(result.mxc_config["network"].get("allowedHosts").is_none()); + assert!(result.mxc_config["network"].get("blockedHosts").is_none()); } // ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── @@ -1009,7 +1007,7 @@ fn c_empty_policy_default_deny_posture() { ); } -/// Split path with network rules present: allowedHosts stays empty. +/// Split path with network rules present: unsupported host lists stay absent. #[test] fn c_split_empty_allowed_hosts_with_network_rules() { let mut policy = SandboxPolicy::default(); @@ -1025,11 +1023,8 @@ fn c_split_empty_allowed_hosts_with_network_rules() { }, ); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); - assert!( - str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), - "split path allowedHosts must be empty even with network rules; got: {:?}", - result.mxc_config["network"]["allowedHosts"] - ); + assert!(result.mxc_config["network"].get("allowedHosts").is_none()); + assert!(result.mxc_config["network"].get("blockedHosts").is_none()); // But proxy redirect is present. assert_eq!( result.mxc_config["network"]["proxy"]["localhost"], 18080, diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index dd16836e4..8ac2c7ade 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -15,9 +15,9 @@ //! Two families: //! //! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on -//! schema acceptance. Some `wxc-exec` builds select the DACL fallback during -//! dry-run and validate filesystem grants, so these tests use owned temporary -//! directories with concrete Windows paths. +//! schema acceptance. These pass on this box even though no enforcement +//! backend is live (dry-run validates the JSON schema without spinning up the +//! `AppContainer` or isolation session). //! //! **(b) Enforcement tests** — probe-gated; print a human-readable SKIP reason //! and return early when the backend is not live. The probe distinguishes @@ -31,8 +31,14 @@ #![cfg(target_os = "windows")] use base64::Engine as _; +use openshell_core::proto::compute::v1::{DriverSandbox, DriverSandboxSpec, DriverSandboxTemplate}; +use openshell_core::proto::{ + FilesystemPolicy, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, +}; +use openshell_driver_mxc::{MxcComputeBackend, MxcComputeConfig}; use std::path::PathBuf; use std::process::Command; +use std::time::Duration; // ── Path resolution ────────────────────────────────────────────────────────── @@ -59,6 +65,17 @@ fn wxc_path() -> Option { None } +/// Create a real, user-owned Windows directory for MXC filesystem grants. +/// +/// MXC config values are literal paths: it does not expand `%TEMP%`. A unique +/// directory also keeps AppContainer+DACL fallback mutations scoped to test +/// data the current user owns. +fn temp_fixture() -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().expect("create MXC temp fixture"); + let path = dir.path().to_string_lossy().into_owned(); + (dir, path) +} + // ── Dry-run helper ──────────────────────────────────────────────────────────── /// Invoke `wxc-exec --config-base64 --dry-run` synchronously. @@ -94,19 +111,18 @@ fn dryrun_accepts_minimal_processcontainer_config() { return; }; - let tmpdir = tempfile::tempdir().expect("tempdir"); - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-minimal", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], }, }); @@ -126,24 +142,21 @@ fn dryrun_accepts_network_block_without_proxy() { return; }; - let tmpdir = tempfile::tempdir().expect("tempdir"); - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-net-block", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], }, "network": { "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], }, }); @@ -165,24 +178,21 @@ fn dryrun_accepts_localhost_proxy_shape() { return; }; - let tmpdir = tempfile::tempdir().expect("tempdir"); - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-proxy-localhost", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], }, "network": { "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], "proxy": { "localhost": 18080 }, }, }); @@ -205,22 +215,21 @@ fn dryrun_rejects_host_port_proxy_shape() { return; }; + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-proxy-hostport", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [temp_path], }, "network": { "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], // MXC 0.6.0-alpha rejects {"host","port"} — verified empirically. "proxy": { "host": "127.0.0.1", "port": 18080 }, }, @@ -243,17 +252,18 @@ fn dryrun_rejects_unknown_containment() { return; }; + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-bad-containment", "containment": "nonsense", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [temp_path], }, }); @@ -261,10 +271,9 @@ fn dryrun_rejects_unknown_containment() { assert_ne!(code, 0, "unknown containment 'nonsense' should be rejected"); } -/// The most important dry-run test: parse the quickstart example policy with -/// `openshell_policy`, run `split_policy` (`proxy_redirect` 127.0.0.1:18080, -/// containment "processcontainer"), take the resulting `mxc_config`, inject a -/// real process block with a valid cwd, and verify that `--dry-run` exits 0. +/// The most important dry-run test: build a typed Windows policy, run +/// `split_policy` (proxy_redirect 127.0.0.1:18080, containment +/// "processcontainer"), and verify the resulting config with `--dry-run`. /// /// This proves that the mapper's emitted JSON is accepted by the real binary — /// the central contract of the policy-mapper integration. @@ -276,33 +285,28 @@ fn dryrun_accepts_split_policy_output() { return; }; - // Find the quickstart policy relative to CARGO_MANIFEST_DIR. - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let policy_path = manifest_dir.join("../../examples/sandbox-policy-quickstart/policy.yaml"); - - if !policy_path.exists() { - eprintln!( - "SKIP: quickstart policy not found at {}", - policy_path.display() - ); - return; - } - - let yaml = std::fs::read_to_string(&policy_path).expect("read policy YAML"); - let policy = openshell_policy::parse_sandbox_policy(&yaml).expect("parse quickstart policy"); + let (_tempdir, temp_path) = temp_fixture(); + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: vec![temp_path.clone()], + }), + ..Default::default() + }; let opts = openshell_driver_mxc::MxcMappingOptions { containment: "processcontainer".to_string(), + command: "cmd /c exit 0".to_string(), + container_id: "split-policy-dryrun".to_string(), + cwd: Some(temp_path), proxy_redirect: Some("127.0.0.1:18080".parse().unwrap()), ..Default::default() }; let result = openshell_driver_mxc::split_policy(&policy, &opts) .expect("split_policy must return Some when proxy_redirect is set"); - // The quickstart policy has network_policies with error-level losses on - // isolation_session, but on processcontainer there should be zero error - // losses from the split itself. Warn if there are any error losses so the - // test is informative even when it proceeds. + // There should be no error losses on the processcontainer split path. let error_losses: Vec<_> = result .loss .iter() @@ -316,29 +320,7 @@ fn dryrun_accepts_split_policy_output() { ); } - // Take the mapper's MXC config and inject the required process block. - // The split config does not include a process block (that comes from the - // gateway TOML at runtime); wxc-exec --dry-run requires one. - // - // The quickstart policy uses sandbox-internal Unix paths. Replace only the - // environment-dependent filesystem paths with an owned Windows directory: - // this test verifies the mapper's MXC JSON shape, while mapper unit tests - // cover the exact filesystem translation. - let tmpdir = tempfile::tempdir().expect("tempdir"); - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); - let mut mxc_config = result.mxc_config.clone(); - mxc_config["filesystem"] = serde_json::json!({ - "readwritePaths": [tmpdir_str], - "readonlyPaths": [], - "deniedPaths": [], - }); - mxc_config["process"] = serde_json::json!({ - "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, - "timeout": 0, - }); - // containerId is also required for processcontainer. - mxc_config["containerId"] = serde_json::json!("split-policy-dryrun"); + let mxc_config = result.mxc_config; let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); assert_eq!( @@ -358,32 +340,39 @@ fn dryrun_accepts_split_policy_output() { /// Probe the processcontainer backend. /// -/// Runs a trivial one-shot (`cmd /c exit 0`, owned temporary-directory grant). -/// Returns `Ok(())` when the backend is live, or `Err(reason)` when it is not (the +/// Runs a trivial one-shot (`cmd /c exit 0`, user-owned temp grant). Returns +/// `Ok(())` when the backend is live, or `Err(reason)` when it is not (the /// caller prints SKIP + reason and returns from the test). fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { // Abort early if the mock env var is set — a stale OPENSHELL_MXC_MOCK_WXC // would silently turn this "real" run back into a mock run. - if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|value| value == "1") { + if std::env::var("OPENSHELL_MXC_MOCK_WXC") + .map(|v| v == "1") + .unwrap_or(false) + { return Err( "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" .to_string(), ); } - let tmpdir = tempfile::tempdir().map_err(|error| format!("tempdir failed: {error}"))?; - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "probe-pc", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, - "timeout": 10, + "cwd": temp_path, + "timeout": 30_000, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], + }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, }, }); @@ -406,17 +395,16 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { || combined.contains("not enabled") { // Extract the message if possible for a more useful skip reason. - let reason = + let reason = if let Ok(v) = serde_json::from_str::(&String::from_utf8_lossy(&out.stdout)) - .map_or_else( - |_| "backend_error (velocity keys not enabled)".to_string(), - |value| { - value["error"]["message"] - .as_str() - .unwrap_or("backend_error (E_NOTIMPL)") - .to_string() - }, - ); + { + v["error"]["message"] + .as_str() + .unwrap_or("backend_error (E_NOTIMPL)") + .to_string() + } else { + "backend_error (velocity keys not enabled)".to_string() + }; return Err(reason); } @@ -432,12 +420,67 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { Ok(()) } -/// Probe the `isolation_session` backend. +/// Probe the released binary's live `network.proxy` support separately from +/// ordinary ProcessContainer support. Some builds accept the proxy JSON during +/// `--dry-run` but return `ERROR_INVALID_PARAMETER` from the live launcher. +fn probe_processcontainer_proxy(wxc: &PathBuf) -> Result<(), String> { + let (_tempdir, temp_path) = temp_fixture(); + let proxy_listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|error| format!("failed to reserve proxy probe port: {error}"))?; + let proxy_port = proxy_listener + .local_addr() + .map_err(|error| format!("failed to read proxy probe port: {error}"))? + .port(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "probe-pc-proxy", + "containment": "processcontainer", + "process": { + "commandLine": "C:\\Windows\\System32\\cmd.exe /c exit 0", + "cwd": temp_path, + "timeout": 30_000, + }, + "filesystem": { + "readwritePaths": [temp_path], + }, + "processContainer": { + "leastPrivilege": false, + }, + "network": { + "defaultPolicy": "block", + "proxy": { "localhost": proxy_port }, + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let output = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .map_err(|error| format!("wxc-exec proxy probe failed to spawn: {error}"))?; + + if output.status.success() { + return Ok(()); + } + + Err(format!( + "live network.proxy probe returned exit {}: stdout={} stderr={}", + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + )) +} + +/// Probe the isolation_session backend. /// /// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or /// `Err(reason)` when the backend is unavailable (caller prints SKIP). fn probe_isolation_session(wxc: &PathBuf) -> Result { - if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|value| value == "1") { + if std::env::var("OPENSHELL_MXC_MOCK_WXC") + .map(|v| v == "1") + .unwrap_or(false) + { return Err( "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" .to_string(), @@ -586,7 +629,7 @@ fn pc_oneshot_in_policy_write_succeeds() { "process": { "commandLine": format!("cmd /c echo hello > \"{target_str}\""), "cwd": tmpdir_str, - "timeout": 30, + "timeout": 30_000, }, "filesystem": { "readwritePaths": [tmpdir_str], @@ -594,6 +637,11 @@ fn pc_oneshot_in_policy_write_succeeds() { "processContainer": { "leastPrivilege": false, }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, }); let json = serde_json::to_string(&config).unwrap(); @@ -619,8 +667,160 @@ fn pc_oneshot_in_policy_write_succeeds() { ); } +/// Run an HTTPS request through the real driver and ProcessContainer. The +/// workload explicitly reads the injected bundle before curl uses it, proving +/// that the driver's internal TLS share is reachable from the AppContainer. +#[tokio::test] +#[ignore = "requires real wxc-exec and outbound HTTPS"] +async fn pc_https_egress_reads_injected_ca_bundle() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + if let Err(reason) = probe_processcontainer_proxy(&wxc) { + eprintln!("SKIP: processcontainer network.proxy not live: {reason}"); + return; + } + + let system_root = std::env::var("SYSTEMROOT").expect("SYSTEMROOT must be set on Windows"); + let cmd = PathBuf::from(&system_root).join("System32").join("cmd.exe"); + let curl = PathBuf::from(system_root).join("System32").join("curl.exe"); + if !curl.exists() { + eprintln!("SKIP: Windows curl.exe not found at {}", curl.display()); + return; + } + + let output_dir = tempfile::tempdir().expect("HTTPS output directory"); + let output_path = output_dir.path().join("example.html"); + let certificate_path = output_dir.path().join("peer-certificate.txt"); + let output_dir_string = output_dir.path().to_string_lossy().into_owned(); + let output_path_string = output_path.to_string_lossy().into_owned(); + let certificate_path_string = certificate_path.to_string_lossy().into_owned(); + let cmd_string = cmd.to_string_lossy().into_owned(); + let script = format!( + "type \"%CURL_CA_BUNDLE%\" 1>NUL && \ + \"{}\" --fail --silent --show-error --cacert \"%CURL_CA_BUNDLE%\" \ + https://example.com/ --output \"{output_path_string}\" \ + --write-out \"%{{certs}}\" 1>\"{certificate_path_string}\"", + curl.display() + ); + let command = vec![ + cmd_string.clone(), + "/d".to_string(), + "/c".to_string(), + script, + ]; + let serde_json::Value::Object(driver_config) = serde_json::json!({ + "command": command, + "cwd": output_dir_string, + }) else { + unreachable!(); + }; + + let policy = SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: vec![output_dir_string], + }), + network_policies: std::collections::HashMap::from([( + "https_example".to_string(), + NetworkPolicyRule { + name: "https-example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + ports: vec![443], + protocol: "rest".to_string(), + tls: "terminate".to_string(), + enforcement: "enforce".to_string(), + access: "read-only".to_string(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: cmd_string, + ..Default::default() + }], + }, + )]), + ..Default::default() + }; + let sandbox = DriverSandbox { + id: "pc-https-ca".to_string(), + name: "pc-https-ca".to_string(), + spec: Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some( + openshell_core::proto_struct::json_object_to_struct(driver_config) + .expect("driver config"), + ), + ..Default::default() + }), + policy: Some(policy), + ..Default::default() + }), + ..Default::default() + }; + + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let config = MxcComputeConfig { + wxc_exec_path: wxc.to_string_lossy().into_owned(), + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".to_string(), + ..Default::default() + }; + let backend = MxcComputeBackend::new(config); + backend + .create_sandbox(&sandbox) + .await + .expect("real HTTPS sandbox create accepted"); + + let mut terminal_condition = None; + for _ in 0..600 { + if let Some(observed) = backend.get_sandbox("pc-https-ca").await + && let Some(condition) = observed + .status + .and_then(|status| status.conditions.into_iter().find(|c| c.r#type == "Ready")) + && matches!( + condition.reason.as_str(), + "AgentCompleted" | "ExecFailed" | "ProvisionFailed" + ) + { + terminal_condition = Some(condition); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + let condition = terminal_condition.expect("HTTPS sandbox should reach a terminal condition"); + assert_eq!( + condition.reason, "AgentCompleted", + "HTTPS workload failed: {}", + condition.message + ); + assert!(output_path.exists(), "curl should write the HTTPS response"); + assert!( + std::fs::metadata(&output_path) + .expect("HTTPS response metadata") + .len() + > 0, + "HTTPS response should not be empty" + ); + let peer_certificate = + std::fs::read_to_string(certificate_path).expect("curl peer certificate output"); + assert!( + peer_certificate.contains("OpenShell Sandbox CA"), + "HTTPS response must use a certificate issued by the host proxy CA" + ); +} + /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. -/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write +/// This is the genuine OS default-deny proof — the AppContainer blocks the write /// without requiring any host ACL lockdown. The mock can only fake this. #[test] #[ignore = "requires real wxc-exec"] @@ -648,7 +848,7 @@ fn pc_oneshot_out_of_policy_write_denied() { "process": { "commandLine": format!("cmd /c echo denied > \"{denied_file_str}\""), "cwd": granted_str, - "timeout": 30, + "timeout": 30_000, }, "filesystem": { // Only the granted_dir is in policy — denied_dir is NOT granted. @@ -657,6 +857,11 @@ fn pc_oneshot_out_of_policy_write_denied() { "processContainer": { "leastPrivilege": false, }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, }); let json = serde_json::to_string(&config).unwrap(); @@ -685,7 +890,7 @@ fn pc_oneshot_out_of_policy_write_denied() { // ── Isolation session enforcement tests ────────────────────────────────────── -/// Full `isolation_session` round trip: provision → start → exec → stop → +/// Full isolation_session round trip: provision → start → exec → stop → /// deprovision. `deprovision` runs in a drop-guard even on panic so the /// single-session backend is never left orphaned. #[test] diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index edc5658c5..9961f4b31 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -31,7 +31,7 @@ ipnet = "2" miette = { workspace = true } prost-types = { workspace = true } rcgen = { workspace = true } -regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob"] } +regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob", "yaml"] } reqwest = { workspace = true } rustls = { workspace = true } rustls-native-certs = { workspace = true } @@ -42,6 +42,7 @@ serde_yml = { workspace = true } sha1 = "0.10" sha2 = { workspace = true } spiffe = { workspace = true } +tempfile = "3" thiserror = { workspace = true } tokio = { workspace = true } tokio-rustls = { workspace = true } @@ -56,7 +57,6 @@ bundled-ca-roots = ["dep:webpki-roots"] [dev-dependencies] openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } -tempfile = "3" tonic = { workspace = true } temp-env = "0.3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs new file mode 100644 index 000000000..631dc6329 --- /dev/null +++ b/crates/openshell-supervisor-network/src/host.rs @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Host-side proxy startup for compute drivers that do not run the Linux +//! in-sandbox supervisor. +//! +//! MXC uses this on Windows: MXC's `network.proxy` redirects sandbox egress to a +//! per-sandbox loopback listener in the gateway process, and this module starts +//! the existing OpenShell CONNECT proxy against the trimmed network-only +//! `SandboxPolicy`. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use miette::Result; +use openshell_core::activity::ActivitySender; +use openshell_core::denial::DenialEvent; +use openshell_core::policy::ProxyPolicy; +use openshell_core::proposals::AgentProposals; +use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; +use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_ocsf::{ + ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ctx::ctx as ocsf_ctx, ocsf_emit, +}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::l7::tls::{ + CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, + write_ca_files, +}; +use crate::opa::OpaEngine; +use crate::policy_local::PolicyLocalContext; +use crate::proxy::{ProxyHandle, ProxyIdentityMode}; + +/// Configuration for a host-side OpenShell CONNECT proxy. +pub struct HostProxyConfig { + /// Exact socket the compute driver will redirect sandbox egress to. + pub bind_addr: SocketAddr, + /// Network-only policy produced by the compute driver's policy split. + pub policy: ProtoSandboxPolicy, + /// Static process identity used when the platform cannot recover the + /// socket-owning sandbox process. Policy binaries must match this path for + /// L4/L7 allow rules to pass. + pub binary_path: PathBuf, + pub sandbox_id: Option, + pub sandbox_name: Option, + pub openshell_endpoint: Option, + pub provider_credentials: Option, + /// Shared feature state for the policy.local agent proposal surface. + pub agent_proposals: AgentProposals, + pub denial_tx: Option>, + pub activity_tx: Option, +} + +/// RAII handle for a host-side proxy. Dropping it aborts the proxy accept loop. +pub struct HostProxyHandle { + proxy: ProxyHandle, + ca_file_paths: Option<(PathBuf, PathBuf)>, + #[allow(dead_code)] + tls_dir: Option, + pub policy_local_ctx: Arc, +} + +impl HostProxyHandle { + #[must_use] + pub const fn http_addr(&self) -> Option { + self.proxy.http_addr() + } + + #[must_use] + pub fn ca_file_paths(&self) -> Option<(PathBuf, PathBuf)> { + self.ca_file_paths.clone() + } +} + +/// Start a host-side proxy for one sandbox. +/// +/// Linux supervisor mode should continue to use `run::run_networking`; this API +/// is for host-side compute-driver integrations such as Windows MXC. +pub async fn start_host_proxy(config: HostProxyConfig) -> Result { + if !config.bind_addr.ip().is_loopback() { + return Err(miette::miette!( + "host proxy bind address must be loopback-only: {}", + config.bind_addr + )); + } + if !config.policy.network_middlewares.is_empty() { + return Err(miette::miette!( + "host proxy cannot enforce network_middlewares without a middleware service registry; refusing to start with {} configured middleware(s)", + config.policy.network_middlewares.len() + )); + } + + let engine = Arc::new(OpaEngine::from_proto(&config.policy)?); + let (_workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); + let policy_local_ctx = Arc::new(PolicyLocalContext::new( + Some(config.policy.clone()), + config.openshell_endpoint.clone(), + config + .sandbox_name + .clone() + .or_else(|| config.sandbox_id.clone()), + config.agent_proposals, + workspace_rx, + )); + let (_ready_tx, ready_rx) = tokio::sync::watch::channel(true); + let proxy_policy = ProxyPolicy { + http_addr: Some(config.bind_addr), + }; + let upstream_proxy_args = crate::upstream_proxy::UpstreamProxyArgs::default(); + let (tls_state, ca_file_paths, tls_dir) = match tempfile::Builder::new() + .prefix("openshell-mxc-tls-") + .tempdir() + { + Ok(tls_dir) => match SandboxCa::generate() { + Ok(ca) => { + let system_ca_bundle = read_system_ca_bundle(); + match write_ca_files(&ca, tls_dir.path(), &system_ca_bundle) { + Ok(paths) => match build_upstream_client_config(&system_ca_bundle) { + Ok(upstream_config) => { + let cert_cache = CertCache::new(ca); + let state = Arc::new(ProxyTlsState::new(cert_cache, upstream_config)); + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "enabled") + .message( + "Host proxy TLS termination enabled: ephemeral CA generated" + ) + .build() + ); + (Some(state), Some(paths), Some(tls_dir)) + } + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to build host proxy upstream TLS config, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, Some(tls_dir)) + } + }, + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to write host proxy CA files, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, Some(tls_dir)) + } + } + } + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to generate host proxy ephemeral CA, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, Some(tls_dir)) + } + }, + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to create host proxy TLS trust directory, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, None) + } + }; + let proxy = ProxyHandle::start_with_bind_addr( + &proxy_policy, + Some(config.bind_addr), + engine, + Arc::new(ProxyIdentityMode::static_binary(config.binary_path)?), + tls_state, + config.provider_credentials, + Some(policy_local_ctx.clone()), + config.denial_tx, + config.activity_tx, + ready_rx, + &upstream_proxy_args, + ) + .await?; + + Ok(HostProxyHandle { + proxy, + ca_file_paths, + tls_dir, + policy_local_ctx, + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use openshell_core::proposals::AgentProposals; + use openshell_core::proto::{ + MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy as ProtoSandboxPolicy, + }; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpStream; + + use super::*; + + fn test_config(bind_addr: SocketAddr, binary_path: PathBuf) -> HostProxyConfig { + HostProxyConfig { + bind_addr, + policy: ProtoSandboxPolicy { + version: 1, + ..Default::default() + }, + binary_path, + sandbox_id: Some("sandbox-123".to_string()), + sandbox_name: Some("agent-box".to_string()), + openshell_endpoint: None, + provider_credentials: None, + agent_proposals: AgentProposals::new(true), + denial_tx: None, + activity_tx: None, + } + } + + #[tokio::test] + async fn rejects_non_loopback_bind_addr() { + let result = start_host_proxy(test_config( + ([192, 0, 2, 1], 0).into(), + PathBuf::from("missing-agent.exe"), + )) + .await; + + let Err(err) = result else { + panic!("host proxy should reject non-loopback bind addresses"); + }; + assert!( + err.to_string().contains("loopback-only"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn rejects_middleware_policy_without_registry() { + let mut config = test_config( + ([127, 0, 0, 1], 0).into(), + PathBuf::from("missing-agent.exe"), + ); + config.policy.network_middlewares.insert( + "redactor".into(), + NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: "openshell/regex".into(), + on_error: "fail_closed".into(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api.example.com".into()], + exclude: Vec::new(), + }), + ..Default::default() + }, + ); + + let Err(error) = start_host_proxy(config).await else { + panic!("host proxy must reject middleware without a registry"); + }; + assert!( + error + .to_string() + .contains("cannot enforce network_middlewares"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn starts_loopback_proxy_and_serves_policy_local() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + + let handle = start_host_proxy(test_config( + ([127, 0, 0, 1], 0).into(), + binary.path().to_path_buf(), + )) + .await + .unwrap(); + + let addr = handle.http_addr().expect("proxy should report bound addr"); + assert!(addr.ip().is_loopback()); + assert_ne!(addr.port(), 0); + let (ca_cert, bundle) = handle.ca_file_paths().expect("CA paths"); + assert!(ca_cert.exists(), "standalone CA cert should exist"); + assert!(bundle.exists(), "combined CA bundle should exist"); + assert!( + std::fs::read_to_string(ca_cert) + .unwrap() + .contains("BEGIN CERTIFICATE") + ); + assert!( + std::fs::read_to_string(bundle) + .unwrap() + .contains("BEGIN CERTIFICATE") + ); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client + .write_all( + b"GET http://policy.local/v1/policy/current HTTP/1.1\r\n\ + Host: policy.local\r\n\ + Connection: close\r\n\ + \r\n", + ) + .await + .unwrap(); + + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + + let response = String::from_utf8(response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 200 OK"), + "unexpected response: {response}" + ); + let (_, body) = response.split_once("\r\n\r\n").expect("response body"); + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(body["format"], "yaml"); + assert!( + body["policy_yaml"] + .as_str() + .unwrap_or_default() + .contains("version: 1"), + "unexpected policy payload: {body}" + ); + } +} diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index 5e89c3503..643db06f5 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -21,10 +21,8 @@ use tracing::debug; #[derive(Clone)] struct FileFingerprint { len: u64, - mtime_sec: i64, - mtime_nsec: i64, - ctime_sec: i64, - ctime_nsec: i64, + mtime: Option<(i64, i64)>, + ctime: Option<(i64, i64)>, #[cfg(unix)] dev: u64, #[cfg(unix)] @@ -33,12 +31,20 @@ struct FileFingerprint { impl FileFingerprint { fn from_metadata(metadata: &Metadata) -> Self { + #[cfg(unix)] + let (mtime, ctime) = ( + Some((metadata.mtime(), metadata.mtime_nsec())), + Some((metadata.ctime(), metadata.ctime_nsec())), + ); + #[cfg(not(unix))] + let (mtime, ctime) = ( + metadata.modified().ok().and_then(system_time_parts), + metadata.created().ok().and_then(system_time_parts), + ); Self { len: metadata.len(), - mtime_sec: metadata.mtime(), - mtime_nsec: metadata.mtime_nsec(), - ctime_sec: metadata.ctime(), - ctime_nsec: metadata.ctime_nsec(), + mtime, + ctime, #[cfg(unix)] dev: metadata.dev(), #[cfg(unix)] @@ -47,13 +53,24 @@ impl FileFingerprint { } } +#[cfg(not(unix))] +fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { + let duration = time.duration_since(std::time::UNIX_EPOCH).ok()?; + Some(( + duration.as_secs() as i64, + i64::from(duration.subsec_nanos()), + )) +} + impl PartialEq for FileFingerprint { fn eq(&self, other: &Self) -> bool { self.len == other.len - && self.mtime_sec == other.mtime_sec - && self.mtime_nsec == other.mtime_nsec - && self.ctime_sec == other.ctime_sec - && self.ctime_nsec == other.ctime_nsec + && self.mtime.is_some() + && other.mtime.is_some() + && self.mtime == other.mtime + && self.ctime.is_some() + && other.ctime.is_some() + && self.ctime == other.ctime && { #[cfg(unix)] { diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index e00a5bf48..b815e826c 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -8,6 +8,8 @@ //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. +#[cfg(target_os = "windows")] +pub mod host; pub mod identity; pub mod l7; pub mod opa; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 221038075..668972536 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7,6 +7,7 @@ pub(crate) mod destination; mod egress; mod relay; +#[cfg(target_os = "linux")] use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; @@ -33,7 +34,8 @@ use std::mem::size_of; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; +#[cfg(target_os = "linux")] +use std::sync::atomic::AtomicU32; use tokio::io::{ AsyncRead as TokioAsyncRead, AsyncReadExt, AsyncWrite as TokioAsyncWrite, AsyncWriteExt, }; @@ -90,10 +92,10 @@ fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { ocsf_emit!(finding); } -/// Hostnames injected by compute drivers as `/etc/hosts` aliases for the host +/// Hostnames injected by compute drivers as hosts-file aliases for the host /// machine. Traffic to these names is eligible for the trusted-gateway SSRF /// exemption when the resolved IP matches the driver-injected value read from -/// `/etc/hosts` at proxy startup. +/// the platform hosts file at proxy startup. const HOST_GATEWAY_ALIASES: &[&str] = &[ "host.openshell.internal", "host.containers.internal", @@ -136,6 +138,60 @@ pub struct ProxyHandle { exited_rx: Option>, } +#[derive(Clone)] +pub(crate) enum ProxyIdentityMode { + /// Linux supervisor mode: bind a CONNECT request to the process that owns + /// the redirected TCP socket via procfs. + #[cfg(target_os = "linux")] + Procfs { + identity_cache: Arc, + entrypoint_pid: Arc, + }, + /// Host-side mode for platforms where procfs socket ownership is + /// unavailable. MXC uses this on Windows: every connection redirected to + /// the per-sandbox listener is evaluated as the configured sandbox agent + /// identity. + #[cfg(any(not(target_os = "linux"), test))] + Static { + binary_path: PathBuf, + binary_sha256: String, + }, +} + +impl ProxyIdentityMode { + #[cfg(target_os = "linux")] + pub(crate) fn procfs( + identity_cache: Arc, + entrypoint_pid: Arc, + ) -> Self { + Self::Procfs { + identity_cache, + entrypoint_pid, + } + } + + #[cfg(any(not(target_os = "linux"), test))] + pub(crate) fn static_binary(path: impl Into) -> Result { + let binary_path = path.into(); + let binary_sha256 = crate::procfs::file_sha256(&binary_path)?; + Ok(Self::Static { + binary_path, + binary_sha256, + }) + } + + fn entrypoint_pid(&self) -> u32 { + match self { + #[cfg(target_os = "linux")] + Self::Procfs { entrypoint_pid, .. } => { + entrypoint_pid.load(std::sync::atomic::Ordering::Acquire) + } + #[cfg(any(not(target_os = "linux"), test))] + Self::Static { .. } => 0, + } + } +} + impl ProxyHandle { /// Start the proxy with OPA engine for policy evaluation. /// @@ -146,8 +202,7 @@ impl ProxyHandle { policy: &ProxyPolicy, bind_addr: Option, opa_engine: Arc, - identity_cache: Arc, - entrypoint_pid: Arc, + identity_mode: Arc, tls_state: Option>, provider_credentials: Option, policy_local_ctx: Option>, @@ -183,14 +238,14 @@ impl ProxyHandle { ocsf_emit!(event); } - // Detect the trusted host gateway IP from /etc/hosts before user code - // runs. This is read once at startup so later /etc/hosts modifications - // by sandbox workloads cannot influence the stored value. + // Detect the trusted host gateway IP from the platform hosts file + // before user code runs. This is read once at startup so later hosts + // file modifications by sandbox workloads cannot influence it. let trusted_host_gateway: Arc> = Arc::new(detect_trusted_host_gateway()); if let Some(ref ip) = *trusted_host_gateway { tracing::info!( %ip, - "Trusted host gateway detected from /etc/hosts; \ + "Trusted host gateway detected from platform hosts file; \ host-gateway aliases exempt from SSRF always-blocked check" ); } @@ -277,8 +332,7 @@ impl ProxyHandle { consecutive_unknown_errors = 0; set_tcp_nodelay_best_effort(&stream); let opa = opa_engine.clone(); - let cache = identity_cache.clone(); - let spid = entrypoint_pid.clone(); + let identity = identity_mode.clone(); let tls = tls_state.clone(); let policy_local = policy_local_ctx.clone(); let proposals = agent_proposals.clone(); @@ -300,8 +354,7 @@ impl ProxyHandle { if let Err(err) = handle_tcp_connection( stream, opa, - cache, - spid, + identity, tls, policy_local, proposals, @@ -523,7 +576,7 @@ async fn handle_transparent_tcp_connection( let cache = identity_cache.clone(); let pid = entrypoint_pid.clone(); let decision = tokio::task::spawn_blocking(move || { - authorize_egress_intent(connection, &engine, &cache, &pid, intent) + authorize_egress_intent_procfs(connection, &engine, &cache, &pid, intent) }) .await .map_err(|error| miette::miette!("identity resolution task panicked: {error}"))?; @@ -1583,8 +1636,7 @@ async fn deny_forward_destination( async fn handle_tcp_connection( mut client: TcpStream, opa_engine: Arc, - identity_cache: Arc, - entrypoint_pid: Arc, + identity_mode: Arc, tls_state: Option>, policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, @@ -1655,8 +1707,7 @@ async fn handle_tcp_connection( used, &mut client, opa_engine, - identity_cache, - entrypoint_pid, + identity_mode, policy_local_ctx, agent_proposals, trusted_host_gateway, @@ -1681,11 +1732,10 @@ async fn handle_tcp_connection( // Wrapped in spawn_blocking because identity resolution does heavy sync I/O: // /proc scanning + SHA256 hashing of binaries (e.g. node at 124MB). let opa_clone = opa_engine.clone(); - let cache_clone = identity_cache.clone(); - let pid_clone = entrypoint_pid.clone(); + let identity_clone = identity_mode.clone(); let intent = EgressIntent::connect(host_lc.clone(), port); let mut decision = tokio::task::spawn_blocking(move || { - authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) + authorize_egress_intent(connection, &opa_clone, &identity_clone, intent) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?; @@ -1803,7 +1853,7 @@ async fn handle_tcp_connection( let effective_tls_skip = decision.endpoint.tls_mode == crate::l7::TlsMode::Skip; let credential_guard = query_endpoint_credential_guard(&opa_engine, &decision, &host_lc, port)?; - let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); + let sandbox_entrypoint_pid = identity_mode.entrypoint_pid(); match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} @@ -2477,7 +2527,7 @@ fn resolve_process_identity( /// Evaluate OPA policy for a TCP connection with identity binding via /proc/net/tcp. #[cfg(target_os = "linux")] -fn authorize_egress_intent( +fn authorize_egress_intent_procfs( connection: crate::procfs::WorkloadProxyTcpConnection, engine: &OpaEngine, identity_cache: &BinaryIdentityCache, @@ -2650,33 +2700,71 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres } } -/// Non-Linux stub: OPA identity binding requires /proc. -#[cfg(not(target_os = "linux"))] fn authorize_egress_intent( - _connection: crate::procfs::WorkloadProxyTcpConnection, + connection: crate::procfs::WorkloadProxyTcpConnection, engine: &OpaEngine, - _identity_cache: &BinaryIdentityCache, - _entrypoint_pid: &AtomicU32, + identity_mode: &ProxyIdentityMode, intent: EgressIntent, ) -> EgressDecision { + #[cfg(not(target_os = "linux"))] + let _ = &connection; + if !crate::opa::network_binary_identity_required() { return evaluate_endpoint_only_opa(engine, intent); } - EgressDecision { - intent, - action: NetworkAction::Deny { - reason: "identity binding unavailable on this platform".into(), - }, - policy_generation: engine.current_generation(), - identity: ProcessIdentityEvidence::Unavailable( - IdentityUnavailableReason::UnsupportedPlatform, + match identity_mode { + #[cfg(target_os = "linux")] + ProxyIdentityMode::Procfs { + identity_cache, + entrypoint_pid, + } => authorize_egress_intent_procfs( + connection, + engine, + identity_cache, + entrypoint_pid, + intent, ), - endpoint: EndpointDecision::default(), - binary: None, - binary_pid: None, - ancestors: vec![], - cmdline_paths: vec![], + #[cfg(any(not(target_os = "linux"), test))] + ProxyIdentityMode::Static { + binary_path, + binary_sha256, + } => { + let input = crate::opa::NetworkInput { + host: intent.destination.host.clone(), + port: intent.destination.port, + binary_path: binary_path.clone(), + binary_sha256: binary_sha256.clone(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { + intent, + action: authorization.action.clone(), + policy_generation: authorization.generation, + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::from_authorization(&authorization), + binary: Some(binary_path.clone()), + binary_pid: None, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }, + Err(error) => EgressDecision { + intent, + action: NetworkAction::Deny { + reason: format!("policy evaluation error: {error}"), + }, + policy_generation: engine.current_generation(), + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::default(), + binary: Some(binary_path.clone()), + binary_pid: None, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }, + } + } } } @@ -2929,16 +3017,27 @@ fn is_cloud_metadata_ip(ip: IpAddr) -> bool { } } -/// Read the proxy's own `/etc/hosts` at startup and return the IP mapped to +/// Read the proxy's own platform hosts file at startup and return the IP mapped to /// `host.openshell.internal`, if present and safe. /// /// This is called once before user code runs, so the returned value is immune -/// to later `/etc/hosts` tampering by sandbox workloads. Returns `None` if no -/// entry exists, the entry cannot be parsed, or the mapped IP is a cloud -/// metadata address. -#[cfg(any(target_os = "linux", test))] +/// to later hosts-file tampering by sandbox workloads. Returns `None` if the +/// hosts file cannot be read, no entry exists, the entry cannot be parsed, or +/// the mapped IP is a cloud metadata address. +#[cfg(any(target_os = "linux", target_os = "windows", test))] pub(crate) fn detect_trusted_host_gateway() -> Option { - let contents = std::fs::read_to_string("/etc/hosts").ok()?; + let hosts_path = platform_hosts_path(); + let contents = match std::fs::read_to_string(&hosts_path) { + Ok(contents) => contents, + Err(error) => { + warn!( + path = %hosts_path.display(), + %error, + "failed to read platform hosts file; trusted-gateway SSRF exemption disabled" + ); + return None; + } + }; let ips = parse_hosts_file_for_host(&contents, "host.openshell.internal"); // Multiple distinct IPs for the alias is unexpected — compute drivers @@ -2950,7 +3049,7 @@ pub(crate) fn detect_trusted_host_gateway() -> Option { if ips.len() > 1 { warn!( ips = ?ips, - "host.openshell.internal has {} distinct IPs in /etc/hosts; \ + "host.openshell.internal has {} distinct IPs in the platform hosts file; \ expected exactly one. Using first entry. \ Connections resolving to any other IP will be rejected.", ips.len() @@ -2984,11 +3083,38 @@ pub(crate) fn detect_trusted_host_gateway() -> Option { Some(ip) } -#[cfg(not(any(target_os = "linux", test)))] +#[cfg(not(any(target_os = "linux", target_os = "windows", test)))] pub(crate) fn detect_trusted_host_gateway() -> Option { None } +#[cfg(target_os = "linux")] +fn platform_hosts_path() -> PathBuf { + PathBuf::from("/etc/hosts") +} + +#[cfg(target_os = "windows")] +fn platform_hosts_path() -> PathBuf { + windows_hosts_path_from_system_root(std::env::var("SystemRoot").ok().as_deref()) +} + +#[cfg(target_os = "windows")] +fn windows_hosts_path_from_system_root(system_root: Option<&str>) -> PathBuf { + let root = system_root + .filter(|value| !value.trim().is_empty()) + .unwrap_or(r"C:\Windows"); + PathBuf::from(root) + .join("System32") + .join("drivers") + .join("etc") + .join("hosts") +} + +#[cfg(all(test, not(any(target_os = "linux", target_os = "windows"))))] +fn platform_hosts_path() -> PathBuf { + PathBuf::from("/etc/hosts") +} + /// Resolve `host:port` and validate that every resolved address matches the /// trusted host gateway IP. /// @@ -3055,7 +3181,7 @@ fn resolve_ip_literal(host: &str, port: u16) -> Option> { .map(|ip| vec![SocketAddr::new(ip, port)]) } -#[cfg(any(target_os = "linux", test))] +#[cfg(any(target_os = "linux", target_os = "windows", test))] fn parse_hosts_file_for_host(contents: &str, host: &str) -> Vec { let lookup_host = normalize_host_lookup_key(host); let mut addrs = Vec::new(); @@ -4080,8 +4206,7 @@ async fn handle_forward_proxy( used: usize, client: &mut TcpStream, opa_engine: Arc, - identity_cache: Arc, - entrypoint_pid: Arc, + identity_mode: Arc, policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, trusted_host_gateway: Arc>, @@ -4187,11 +4312,10 @@ async fn handle_forward_proxy( let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); let opa_clone = opa_engine.clone(); - let cache_clone = identity_cache.clone(); - let pid_clone = entrypoint_pid.clone(); + let identity_clone = identity_mode.clone(); let intent = EgressIntent::forward_http(host_lc.clone(), port); let mut decision = tokio::task::spawn_blocking(move || { - authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) + authorize_egress_intent(connection, &opa_clone, &identity_clone, intent) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?; @@ -4282,7 +4406,7 @@ async fn handle_forward_proxy( action = ?decision.action, "Forward proxy L4 policy decision" ); - let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); + let sandbox_entrypoint_pid = identity_mode.entrypoint_pid(); let forward_generation_guard = match relay::pin_policy_generation( &opa_engine, decision.policy_generation, @@ -5665,6 +5789,9 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { mod tests { use super::*; use openshell_core::proposals::AgentProposals; + use openshell_core::proto::{ + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy as ProtoSandboxPolicy, + }; use std::collections::HashMap as TestHashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; @@ -5838,8 +5965,7 @@ network_policies: {} Box::pin(handle_tcp_connection( server, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new(ProxyIdentityMode::static_binary(std::env::current_exe().unwrap()).unwrap()), None, None, AgentProposals::default(), @@ -5980,6 +6106,15 @@ network_policies: response }); let (mut proxy_connection, _) = proxy_listener.accept().await.unwrap(); + #[cfg(target_os = "linux")] + let identity_mode = Arc::new(ProxyIdentityMode::procfs( + Arc::new(BinaryIdentityCache::new()), + Arc::new(AtomicU32::new(std::process::id())), + )); + #[cfg(not(target_os = "linux"))] + let identity_mode = Arc::new( + ProxyIdentityMode::static_binary(executable.clone()).expect("hash test executable"), + ); tokio::time::timeout( std::time::Duration::from_secs(30), @@ -5990,8 +6125,7 @@ network_policies: request.len(), &mut proxy_connection, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + identity_mode, None, AgentProposals::default(), Arc::new(None), @@ -6104,8 +6238,9 @@ network_policies: request.len(), &mut proxy_connection, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new( + ProxyIdentityMode::static_binary(std::env::current_exe().unwrap()).unwrap(), + ), None, AgentProposals::default(), Arc::new(None), @@ -6237,8 +6372,9 @@ network_policies: request.len(), &mut proxy_connection, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new( + ProxyIdentityMode::static_binary(std::env::current_exe().unwrap()).unwrap(), + ), None, AgentProposals::default(), Arc::new(None), @@ -6806,6 +6942,97 @@ network_policies: ); } + #[test] + fn static_binary_hashes_configured_file() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"abc").unwrap(); + + match ProxyIdentityMode::static_binary(tmp.path()).unwrap() { + ProxyIdentityMode::Static { + binary_path, + binary_sha256, + } => { + assert_eq!(binary_path, tmp.path()); + assert_eq!( + binary_sha256, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + #[cfg(target_os = "linux")] + ProxyIdentityMode::Procfs { .. } => panic!("expected static identity mode"), + } + } + + #[test] + fn static_identity_evaluate_opa_tcp_allows_and_denies_with_proto_policy() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"static-agent").unwrap(); + let identity = ProxyIdentityMode::static_binary(tmp.path()).unwrap(); + let binary_path = tmp.path().to_string_lossy().into_owned(); + let policy_name = "static_agent"; + let engine = OpaEngine::from_proto(&ProtoSandboxPolicy { + version: 1, + network_policies: std::collections::HashMap::from([( + policy_name.to_string(), + NetworkPolicyRule { + name: policy_name.to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.test".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { path: binary_path }], + }, + )]), + ..Default::default() + }) + .unwrap(); + let peer_addr: SocketAddr = ([127, 0, 0, 1], 49152).into(); + let connection = crate::procfs::WorkloadProxyTcpConnection::new( + peer_addr, + ([127, 0, 0, 1], 18080).into(), + ); + + let allowed = authorize_egress_intent( + connection, + &engine, + &identity, + EgressIntent::connect("api.example.test".to_string(), 443), + ); + match allowed.action { + NetworkAction::Allow { matched_policy } => { + assert_eq!(matched_policy.as_deref(), Some(policy_name)); + } + NetworkAction::Deny { reason } => panic!("expected allow, got deny: {reason}"), + } + assert_eq!(allowed.binary.as_deref(), Some(tmp.path())); + assert_eq!(allowed.binary_pid, None); + assert!(allowed.ancestors.is_empty()); + assert!(allowed.cmdline_paths.is_empty()); + + let denied = authorize_egress_intent( + connection, + &engine, + &identity, + EgressIntent::connect("blocked.example.test".to_string(), 443), + ); + match denied.action { + NetworkAction::Allow { matched_policy } => { + panic!("expected deny, got allow from policy {matched_policy:?}"); + } + NetworkAction::Deny { reason } => { + assert!( + reason.contains("endpoint blocked.example.test:443 is not allowed"), + "unexpected deny reason: {reason}" + ); + } + } + assert_eq!(denied.binary.as_deref(), Some(tmp.path())); + assert_eq!(denied.binary_pid, None); + assert!(denied.ancestors.is_empty()); + assert!(denied.cmdline_paths.is_empty()); + } + #[test] fn tunnel_protocol_prefix_detection_waits_for_partial_supported_prefixes() { assert!(could_be_supported_tunnel_protocol_prefix(&[0x16])); @@ -10835,8 +11062,7 @@ network_policies: }); let (server, _peer) = listener.accept().await.unwrap(); - let entrypoint_pid = Arc::new(AtomicU32::new(std::process::id())); - let cache = Arc::new(BinaryIdentityCache::new()); + let identity_mode = Arc::new(ProxyIdentityMode::static_binary(exe).unwrap()); let (denial_tx, mut denial_rx) = mpsc::unbounded_channel(); let completed = tokio::time::timeout( @@ -10844,8 +11070,7 @@ network_policies: Box::pin(handle_tcp_connection( server, engine, - cache, - entrypoint_pid, + identity_mode, None, // tls_state — ephemeral CA unavailable None, // policy_local_ctx AgentProposals::default(), // agent_proposals @@ -10881,15 +11106,15 @@ network_policies: const POLICY_REGO: &str = include_str!("../data/sandbox-policy.rego"); let exe = std::env::current_exe().expect("current_exe"); + let exe_yaml = serde_json::to_string(&exe.to_string_lossy()).expect("serialize exe path"); let data = format!( r#"network_policies: test_allow: name: test_allow endpoints: {endpoint_yaml} binaries: - - {{ path: "{exe}" }} + - {{ path: {exe_yaml} }} "#, - exe = exe.display(), ); let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, &data).expect("load policy")); @@ -10910,8 +11135,7 @@ network_policies: Box::pin(handle_tcp_connection( server, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new(ProxyIdentityMode::static_binary(exe).unwrap()), None, None, AgentProposals::default(), diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index badff5c42..c0e78484a 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -381,28 +381,27 @@ network_policies: #[cfg(not(target_os = "linux"))] #[test] -fn identity_required_mode_is_explicitly_unsupported_off_linux() { +fn static_identity_is_supported_off_linux() { let engine = OpaEngine::from_strings( include_str!("../../../data/sandbox-policy.rego"), "network_policies: {}\n", ) .unwrap(); + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + let identity = ProxyIdentityMode::static_binary(binary.path()).unwrap(); let decision = authorize_egress_intent( crate::procfs::WorkloadProxyTcpConnection::new( "127.0.0.1:41000".parse().unwrap(), "127.0.0.1:3000".parse().unwrap(), ), &engine, - &BinaryIdentityCache::new(), - &AtomicU32::new(1), + &identity, EgressIntent::connect("target.example".to_string(), 443), ); assert!(matches!(decision.action, NetworkAction::Deny { .. })); - assert_eq!( - decision.identity, - ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::UnsupportedPlatform) - ); + assert_eq!(decision.identity, ProcessIdentityEvidence::Available); } #[test] @@ -541,8 +540,12 @@ network_policies: Box::pin(handle_tcp_connection( stream, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(0)), + Arc::new( + ProxyIdentityMode::static_binary( + std::env::current_exe().unwrap(), + ) + .unwrap(), + ), None, None, AgentProposals::default(), diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index af9ffb840..b11d9aa0d 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -29,6 +29,7 @@ use openshell_core::denial::DenialEvent; use openshell_core::proposals::AgentProposals; use tokio::sync::mpsc::UnboundedSender; +#[cfg(target_os = "linux")] use crate::identity::BinaryIdentityCache; use crate::l7::tls::{ CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, @@ -36,7 +37,7 @@ use crate::l7::tls::{ }; use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; -use crate::proxy::ProxyHandle; +use crate::proxy::{ProxyHandle, ProxyIdentityMode}; #[cfg(target_os = "linux")] pub struct TransparentRuntimeSetup { @@ -161,6 +162,13 @@ pub struct Networking { _transparent_tcp: Option, } +#[cfg(not(target_os = "linux"))] +fn current_exe_static_identity_path() -> Result { + std::env::current_exe().map_err(|e| { + miette::miette!("failed to resolve supervisor executable for static proxy identity: {e}") + }) +} + /// Set up the networking stack: ephemeral CA + TLS state, proxy server, /// and the SSH-side proxy URL / netns FD. /// @@ -308,8 +316,9 @@ pub async fn run_networking( let _ = engine_ready_tx.send(true); } - // Identity cache for SHA256 TOFU when OPA is active. Only consumed by - // the proxy, so it's owned here. + // Linux procfs identity mode uses a SHA256 TOFU cache. Windows host mode + // uses an explicit static sandbox identity instead. + #[cfg(target_os = "linux")] let identity_cache = opa_engine.map(|_| Arc::new(BinaryIdentityCache::new())); // Generate ephemeral CA and TLS state for HTTPS L7 inspection. @@ -410,10 +419,6 @@ pub async fn run_networking( miette::miette!("Proxy mode requires an OPA engine (--rego-policy and --rego-data)") })?; - let cache = identity_cache.clone().ok_or_else(|| { - miette::miette!("Proxy mode requires an identity cache (OPA engine must be configured)") - })?; - // If the orchestrator gave us a proxy bind IP (the host-side veth IP // from the workload's netns on Linux), use it so only traffic // originating inside the namespace can reach the proxy. Otherwise the @@ -424,12 +429,25 @@ pub async fn run_networking( SocketAddr::new(ip, port) }); + #[cfg(target_os = "linux")] + let identity_mode = { + let cache = identity_cache.clone().ok_or_else(|| { + miette::miette!( + "Proxy mode requires an identity cache (OPA engine must be configured)" + ) + })?; + ProxyIdentityMode::procfs(cache, entrypoint_pid.clone()) + }; + #[cfg(target_os = "windows")] + let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; + #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] + let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; + let proxy_handle = ProxyHandle::start_with_bind_addr( proxy_policy, bind_addr, engine, - cache, - entrypoint_pid.clone(), + Arc::new(identity_mode), tls_state, Some(provider_credentials.clone()), Some(policy_local_ctx.clone()), diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index f9a3f0032..7c8e043e1 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -673,6 +673,10 @@ backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] +# Pattern-C governed egress. The address is a loopback seed; the driver +# allocates a unique ephemeral port for each sandbox. +egress_proxy = false +egress_proxy_addr = "" debug = false etw_audit = true ``` @@ -682,6 +686,19 @@ administrator or belong to the Windows Performance Log Users group. Workload commands and working directories remain sandbox-scoped and must be supplied in the `mxc` driver configuration when creating a sandbox. +Set `egress_proxy = true` with a loopback seed such as +`egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern-C split. +MXC redirects sandbox traffic to a sandbox-unique port on the configured +loopback IP, where the host CONNECT proxy enforces the trimmed network policy. +The driver exposes the proxy's public CA bundle through a sandbox-unique +internal filesystem grant so HTTPS clients can read the injected trust path; +the CA private key remains in gateway memory. +Governed egress requires `process_container` until MXC supports `network.proxy` +for isolation sessions. +MXC rejects policies containing `network_middlewares` before launch because +this host-proxy path does not yet receive the gateway's built-in and remote +middleware registry. + The driver records executable identity in process audit events and omits raw command arguments because they can contain credentials or personal data. See [OCSF JSON Export](/observability/ocsf-json-export) for durable Windows audit diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 63384e5cf..6d8a188ef 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -50,14 +50,15 @@ if (-not [int]::TryParse($BuildJobsValue, [ref] $WindowsBuildJobs) -or $WindowsB } $WindowsCargoMutex = [System.Threading.Mutex]::new($false, "Local\OpenShellWindowsMsvcCargo") -$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-sandbox --exclude openshell-supervisor-network --exclude openshell-supervisor-process --exclude openshell-vfio" +$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-sandbox --exclude openshell-supervisor-process --exclude openshell-vfio" $WindowsClippyPackageExcludes = $UnsupportedDriverPackageExcludes $WindowsClippyLintArgs = "-D warnings -A dead-code -A unused-imports -A clippy::unused-async" -$PrebuiltZ3WorkspaceFeatures = "--features openshell-prover/prebuilt-z3" -$PrebuiltZ3ServerFeatures = "--features openshell-server/prebuilt-z3,openshell-prover/prebuilt-z3" -$PrebuiltZ3Version = "4.16.0" -$Z3WorkspaceFeatures = $PrebuiltZ3WorkspaceFeatures -$Z3ServerFeatures = $PrebuiltZ3ServerFeatures +$BundledZ3WorkspaceFeatures = "--features openshell-prover/bundled-z3" +$BundledZ3ServerFeatures = "--features openshell-server/bundled-z3,openshell-prover/bundled-z3" +$BundledZ3GatewayFeatures = "--features openshell-gateway/bundled-z3" +$Z3WorkspaceFeatures = $BundledZ3WorkspaceFeatures +$Z3ServerFeatures = $BundledZ3ServerFeatures +$Z3GatewayFeatures = $BundledZ3GatewayFeatures function Get-VsInstallRoots { $programFiles = @( @@ -353,12 +354,11 @@ function Resolve-Z3HeaderPath([string] $HeaderPath) { function Configure-Z3 { if ([string]::IsNullOrWhiteSpace($env:Z3_LIBRARY_PATH_OVERRIDE)) { - Write-Host "==> Z3: prebuilt release" - $env:Z3_SYS_Z3_VERSION = $PrebuiltZ3Version - Write-Host " Z3_SYS_Z3_VERSION=$env:Z3_SYS_Z3_VERSION" + Write-Host "==> Z3: bundled" return [pscustomobject]@{ - WorkspaceFeatures = $PrebuiltZ3WorkspaceFeatures - ServerFeatures = $PrebuiltZ3ServerFeatures + WorkspaceFeatures = $BundledZ3WorkspaceFeatures + ServerFeatures = $BundledZ3ServerFeatures + GatewayFeatures = $BundledZ3GatewayFeatures } } @@ -386,6 +386,7 @@ function Configure-Z3 { return [pscustomobject]@{ WorkspaceFeatures = "" ServerFeatures = "" + GatewayFeatures = "" } } @@ -512,7 +513,7 @@ function Invoke-Lint([string] $RustTarget) { function Invoke-Build([string] $RustTarget) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell $Z3WorkspaceFeatures" ` + -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell $Z3GatewayFeatures" ` -LogName "build-$RustTarget-release.log" } @@ -610,6 +611,7 @@ if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsu $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures + $Z3GatewayFeatures = $z3Features.GatewayFeatures $env:LIBCLANG_PATH = Resolve-LibclangPath Add-PathEntry $env:LIBCLANG_PATH Write-Host "==> LIBCLANG_PATH=$env:LIBCLANG_PATH" From fd3fd9cf7476c6a618ad9b5ce143936e3ebb245e Mon Sep 17 00:00:00 2001 From: Shiju Date: Tue, 15 Sep 2026 13:46:38 +0000 Subject: [PATCH 10/10] feat(sandbox): explain failed calls to external tool servers (#3207) Show configured tool server addresses and their last observed connection results together in sandbox status. Keep sandbox lifecycle readiness separate so an external connection failure does not mark the sandbox unready. Expose direct endpoint records through the CLI and SDKs, with plain-language failure explanations and gateway acceptance times. Keep observation tracking, runtime reporting, and gateway validation in dedicated endpoint status modules. Preserve bounded reporting, request attribution, retry ordering, and configuration and supervisor authority checks. Clear obsolete observations while retaining the configured addresses, and document the distinction between an observed HTTP response, current availability, and tool success. Signed-off-by: Shiju --- Cargo.lock | 2 + architecture/gateway.md | 21 +- architecture/sandbox.md | 20 + crates/openshell-cli/src/run.rs | 347 +++- .../tests/ensure_providers_integration.rs | 9 + .../openshell-cli/tests/mtls_integration.rs | 9 + .../tests/provider_commands_integration.rs | 9 + .../sandbox_create_lifecycle_integration.rs | 18 +- .../sandbox_name_fallback_integration.rs | 9 + crates/openshell-core/Cargo.toml | 1 + crates/openshell-core/src/endpoint_status.rs | 1184 ++++++++++++ crates/openshell-core/src/grpc_client.rs | 63 +- crates/openshell-core/src/lib.rs | 2 + crates/openshell-core/src/mcp.rs | 13 +- crates/openshell-core/src/policy_identity.rs | 177 ++ .../openshell-sandbox/src/endpoint_status.rs | 1194 ++++++++++++ crates/openshell-sandbox/src/lib.rs | 126 +- crates/openshell-sdk/tests/client_mock.rs | 7 + crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/compute/mod.rs | 41 + crates/openshell-server/src/grpc/mod.rs | 27 +- crates/openshell-server/src/grpc/policy.rs | 300 +-- .../src/grpc/policy/endpoint_status.rs | 758 ++++++++ .../src/grpc/policy/endpoint_status_tests.rs | 1231 ++++++++++++ crates/openshell-server/src/grpc/provider.rs | 4 + crates/openshell-server/src/grpc/sandbox.rs | 151 +- crates/openshell-server/src/lib.rs | 8 + .../openshell-server/src/provider_refresh.rs | 9 + crates/openshell-server/src/storage_proto.rs | 43 +- .../src/supervisor_session.rs | 217 ++- crates/openshell-server/tests/common/mod.rs | 9 + .../tests/supervisor_relay_integration.rs | 9 + .../src/l7/mod.rs | 383 ++++ .../src/l7/relay.rs | 680 ++++++- .../src/l7/rest.rs | 530 +++++- .../openshell-supervisor-network/src/opa.rs | 128 ++ .../openshell-supervisor-network/src/proxy.rs | 1001 +++++++++- .../src/proxy/destination.rs | 53 +- .../src/proxy/relay.rs | 17 +- .../src/proxy/tests/compatibility.rs | 5 + .../openshell-supervisor-network/src/run.rs | 3 + .../openshell-supervisor-process/src/run.rs | 6 +- .../src/supervisor_session.rs | 27 +- docs/sandboxes/manage-sandboxes.mdx | 39 +- examples/governance-interceptor/Cargo.lock | 1 + .../Cargo.lock | 72 + proto/openshell.proto | 90 +- sdk/go/docs/src/api/sandboxes.md | 16 + sdk/go/openshell/v1/fake/fake_test.go | 38 + sdk/go/openshell/v1/fake/sandbox.go | 6 + .../v1/internal/converter/coverage_test.go | 23 +- .../v1/internal/converter/sandbox.go | 33 + .../v1/internal/converter/sandbox_test.go | 68 + sdk/go/openshell/v1/sandbox.go | 18 + sdk/go/openshell/v1/types/sandbox.go | 44 + sdk/go/proto/openshellv1/openshell.pb.go | 1648 +++++++++++------ sdk/go/proto/openshellv1/openshell_grpc.pb.go | 40 + sdk/typescript/src/raw.test.ts | 74 + skills/debug-openshell-cluster/SKILL.md | 1 + 59 files changed, 10045 insertions(+), 1018 deletions(-) create mode 100644 crates/openshell-core/src/endpoint_status.rs create mode 100644 crates/openshell-core/src/policy_identity.rs create mode 100644 crates/openshell-sandbox/src/endpoint_status.rs create mode 100644 crates/openshell-server/src/grpc/policy/endpoint_status.rs create mode 100644 crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs create mode 100644 sdk/typescript/src/raw.test.ts diff --git a/Cargo.lock b/Cargo.lock index 4584ff07c..716cb10eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4049,6 +4049,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "sha2 0.10.9", "tar", "tempfile", "thiserror 2.0.18", @@ -4504,6 +4505,7 @@ dependencies = [ "axum", "base64", "bytes", + "chrono", "clap", "futures", "futures-util", diff --git a/architecture/gateway.md b/architecture/gateway.md index 3881618db..1f3e712a6 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -334,16 +334,10 @@ The storage schema is intentionally narrow: ### Protobuf API and storage boundaries -Public RPC contracts and durable protobuf formats have separate ownership. The -`openshell.v1.OpenShell` service currently has 74 RPCs. Their request and -response roots, streaming flags, and transitive message closure come from the -public descriptor set generated by `openshell-core`; a fingerprint test in -`openshell-server` requires this inventory to be reviewed whenever it changes. -Compute-driver, credential-driver, gateway-interceptor, and -supervisor-middleware services are compiled contracts for internal extension -boundaries, not public gateway RPCs. The current public inventory has 74 -methods, 278 messages, and 12 enums -(`8ac68c71d93e6a5e56406b8df1882ee40c6066270969e03eb99803f0e6396fc1`). +Public RPC contracts and durable protobuf formats have separate ownership. The `openshell.v1.OpenShell` service's request and response roots, streaming flags, and transitive message closure come from the public descriptor set generated by `openshell-core`. The `public_and_durable_schema_inventories_are_complete` test in `openshell-server` owns the counts and fingerprints and requires this inventory to be reviewed whenever it changes. Compute-driver, credential-driver, gateway-interceptor, and supervisor-middleware services are compiled contracts for internal extension boundaries. + +`ReportEndpointStatus` is a sandbox-authenticated public gateway RPC. Its request, response, and `EndpointObservation` messages belong only to the public closure. `EndpointStatus` and `EndpointResult` also belong to the durable closure because `Sandbox.status.endpoint_statuses` persists them. The repeated status field uses a new wire tag; stored sandboxes without it decode with an empty endpoint list and retain their lifecycle fields. A fixed payload encoded with the earlier sandbox schema verifies that no database rewrite is required. + The removed `NetworkBinary.harness` field remains reserved by number and name, so protobuf implementations cannot reuse its wire slot or source identifier. The durable-policy compatibility decoder reads the former boolean before Prost @@ -363,12 +357,7 @@ Go, Python, and TypeScript client generation inputs do not advertise them. | Public messages used directly as encoded storage roots | `Sandbox`, `SandboxWorkloadTemplate`, `Provider`, `Workspace`, `WorkspaceMember`, `SshSession`, `ServiceEndpoint` | The generated public type is also the persisted payload. `SshSession` is not in the current public RPC message closure. | | Embedded encoded root | `SandboxPolicy` | Stored in policy rows and inside the JSON settings envelope. | -The 12 encoded durable roots above have a closure of 81 messages and eight -enums (`369b36511c2e38b9df9621704a00123516c7538d8ee89a499158d7de5cee1882`). -Its intersection with the public RPC closure contains 71 messages and eight -enums (`05add438ba041defc98d791038ae593d3f09352677cae43f2276d494205ce415`). -The descriptor-derived test owns these full inventories; the tables here record -the reviewed roots and classifications. +The descriptor-derived test inventories the complete message and enum closure of the encoded durable roots and its intersection with the public RPC closure. The tables here record the reviewed roots and classifications. | Dual-purpose encoded root | Current decision | |---|---| diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 5b6245c89..53c26a56b 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -361,6 +361,26 @@ auth file without the acknowledgement, and the acknowledgement without an auth file, so credentials are never sent in cleartext without an explicit operator decision. +## Tool server connection status + +A sandbox can be `Ready` while a call to an external tool server fails. For configured endpoints that use MCP over HTTP, OpenShell records the last observed network result beside the endpoint's address. Users can identify the server and distinguish policy denial, unavailable credentials, TLS or network failure, and an upstream HTTP rejection without combining client and supervisor logs. These observations do not affect sandbox lifecycle readiness. + +```mermaid +flowchart LR + Traffic[Calls to configured tool servers] --> Observer[Observe network result] + Observer --> Reporter[Background reporter] + Reporter --> Gateway[Validate and store results] + Gateway --> Status[Endpoint address, last result, report time] +``` + +The gateway exposes one record per configured endpoint in `Sandbox.status.endpoint_statuses`. Each record contains its address, an opaque identifier, a typed result, and the time the gateway accepted that result. The address remains available when evidence resets to `NoObservedExchange`. Ordinary sandbox conditions continue to describe lifecycle and platform state. + +Network observers send only the endpoint identifier and a fixed result classification, without credentials, payloads, or raw upstream errors. Requests capture observation authority before selecting policy or credentials, then bind the selected policy hash and provider revision to that capture. Observation handles also identify the installed endpoint inventory and supervisor authority, so a concurrent update cannot attribute an old request to a new configuration, and reinstalling the same configuration cannot revive an obsolete request. The sandbox reporter coalesces observations and retries an immutable snapshot through `ReportEndpointStatus`. Bounded delivery can drop observations, so endpoint status is not a complete request history. + +The gateway validates the reporting supervisor's session, configuration revisions, and report sequence before atomically storing results. Global policy writes share the report's synchronization boundary. An effective policy change resets all endpoint evidence; repeated acknowledgements and metadata-only policy revisions preserve it. A provider environment change resets evidence for endpoints that depend on those credentials. Supervisor disconnection or replacement and gateway restart also invalidate observation authority. Identical report retries leave timestamps unchanged. After an inventory reset, still-valid pending evidence can be accepted again if an acknowledgement was lost, advancing the report time without another exchange. + +These are passive observations with no expiry. `HttpResponseReceived` means the server returned a final HTTP status below 400, including a protocol upgrade; that response can still contain an MCP error. Informational responses alone do not establish success. MCP protocol-version and request-body policy rejections produce `PolicyDenied`. A failure before the HTTP path is known updates status only when the host and port identify one distinct endpoint. Ambiguous failures remain in structured events and logs. Results combine callers and effective ports for an endpoint. Consumers that need current tool availability must verify an actual operation. The sandbox management guide explains the public fields and results. + ## Credentials Provider credentials are stored at the gateway and fetched by the supervisor at diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index a89e3c30b..243e10d1b 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -46,15 +46,15 @@ use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, BeginRootfsTarStagingRequest, ClearDraftChunksRequest, CreateSandboxRequest, CreateSandboxTemplateRequest, CreateSshSessionRequest, DeleteSandboxRequest, DeleteSandboxTemplateRequest, - DeleteServiceRequest, ExecSandboxRequest, ExposeServiceRequest, GetCurrentUserRequest, - GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, + DeleteServiceRequest, EndpointResult, EndpointStatus, ExecSandboxRequest, ExposeServiceRequest, + GetCurrentUserRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, GetSandboxTemplateRequest, GetServiceRequest, GpuResourceRequirements, ListSandboxPoliciesRequest, ListSandboxTemplatesRequest, ListSandboxesRequest, ListServicesRequest, PolicySource, PolicyStatus, RejectDraftChunkRequest, - ResourceRequirements, RevokeSshSessionRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxResources, SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplate, - SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, + ResourceRequirements, RevokeSshSessionRequest, Sandbox, SandboxCondition, SandboxPhase, + SandboxPolicy, SandboxResources, SandboxServiceLevel, SandboxSpec, SandboxStartup, + SandboxTemplate, SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, ServiceEndpointResponse, SettingScope, StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, WatchSandboxRequest, exec_sandbox_event, tcp_forward_init, @@ -1589,6 +1589,36 @@ pub async fn sandbox_get( ); } + if let Some(status) = &sandbox.status + && !status.conditions.is_empty() + { + println!(" {}", "Conditions:".dimmed()); + for condition in &status.conditions { + for (index, line) in sandbox_condition_display_lines(condition) + .iter() + .enumerate() + { + let prefix = if index == 0 { "- " } else { " " }; + println!(" {prefix}{line}"); + } + } + } + + if let Some(status) = &sandbox.status + && !status.endpoint_statuses.is_empty() + { + println!(" {}", "Tool server connections:".dimmed()); + for endpoint in &status.endpoint_statuses { + for (index, line) in endpoint_status_display_lines(endpoint).iter().enumerate() { + let prefix = if index == 0 { "- " } else { " " }; + println!(" {prefix}{line}"); + } + } + println!( + " Results come from observed MCP over HTTP traffic. They do not check current availability or tool-call success." + ); + } + let policy_from_global = config.policy_source == PolicySource::Global as i32; println!( " {} {}", @@ -2392,6 +2422,29 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { "resource_version": provenance.resource_version, }) }); + let conditions = sandbox.status.as_ref().map_or_else(Vec::new, |status| { + status + .conditions + .iter() + .map(sandbox_condition_to_json) + .collect::>() + }); + let endpoint_statuses = sandbox.status.as_ref().map_or_else(Vec::new, |status| { + status + .endpoint_statuses + .iter() + .map(|endpoint| { + serde_json::json!({ + "endpoint_id": endpoint.endpoint_id, + "host": endpoint.host, + "ports": endpoint.ports, + "path": endpoint.path, + "last_result": endpoint_result_name(endpoint.last_result()), + "last_reported_at": endpoint.last_reported_at, + }) + }) + .collect::>() + }); serde_json::json!({ "id": sandbox.object_id(), "name": sandbox.object_name(), @@ -2403,10 +2456,93 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { "phase": phase_name(sandbox.phase()), "current_policy_version": sandbox.current_policy_version(), "exit_code": sandbox.status.as_ref().and_then(|status| status.exit_code), + "conditions": conditions, + "endpoint_statuses": endpoint_statuses, "created_from_workload_template": created_from_workload_template, }) } +fn sandbox_condition_to_json(condition: &SandboxCondition) -> serde_json::Value { + serde_json::json!({ + "type": condition.r#type, + "status": condition.status, + "reason": condition.reason, + "message": condition.message, + "last_transition_time": condition.last_transition_time, + }) +} + +fn sandbox_condition_display_lines(condition: &SandboxCondition) -> Vec { + let reason = if condition.reason.is_empty() { + String::new() + } else { + format!(" ({})", condition.reason) + }; + let message = if condition.message.is_empty() { + String::new() + } else { + format!(" - {}", condition.message) + }; + let mut lines = vec![format!( + "{}: {}{reason}{message}", + condition.r#type, condition.status + )]; + if !condition.last_transition_time.is_empty() { + lines.push(format!( + "Last transition: {}", + condition.last_transition_time + )); + } + lines +} + +// These names are part of the CLI JSON/YAML contract, independent of the +// generated Rust enum's Debug output and protobuf's prefixed wire names. +fn endpoint_result_name(result: EndpointResult) -> &'static str { + match result { + EndpointResult::Unspecified => "Unspecified", + EndpointResult::NoObservedExchange => "NoObservedExchange", + EndpointResult::HttpResponseReceived => "HttpResponseReceived", + EndpointResult::PolicyDenied => "PolicyDenied", + EndpointResult::CredentialUnavailable => "CredentialUnavailable", + EndpointResult::TlsFailed => "TlsFailed", + EndpointResult::TransportFailed => "TransportFailed", + EndpointResult::UpstreamRejected => "UpstreamRejected", + } +} + +fn endpoint_status_display_lines(endpoint: &EndpointStatus) -> Vec { + let ports = endpoint + .ports + .iter() + .map(u32::to_string) + .collect::>() + .join(", "); + let result = match endpoint.last_result() { + EndpointResult::Unspecified => "No result provided.", + EndpointResult::NoObservedExchange => "No exchange observed.", + EndpointResult::HttpResponseReceived => "Server returned an HTTP response below 400.", + EndpointResult::PolicyDenied => "Blocked by OpenShell policy.", + EndpointResult::CredentialUnavailable => { + "Required OpenShell-managed credential was unavailable." + } + EndpointResult::TlsFailed => "TLS connection failed.", + EndpointResult::TransportFailed => "Connection failed before an HTTP response arrived.", + EndpointResult::UpstreamRejected => "Server rejected the request (HTTP 400 or higher).", + }; + // The gateway supplies acceptance time, which can follow the actual + // exchange. An empty timestamp means there is no accepted observation. + let reported_at = non_empty_or(&endpoint.last_reported_at, "no report yet"); + vec![ + format!( + "{} (ports: {ports}; path: {})", + endpoint.host, endpoint.path + ), + format!("Last result: {result}"), + format!("Reported at (gateway acceptance): {reported_at}"), + ] +} + fn sandbox_detail_to_json( sandbox: &Sandbox, config: &GetSandboxConfigResponse, @@ -5883,12 +6019,12 @@ mod tests { PROGRESS_STEP_STARTING_SANDBOX, }; use openshell_core::proto::{ - GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, - ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy, - SandboxPolicyRevision, SandboxResources, SandboxStatus, SandboxWorkloadConfig, - SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, - ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, WorkspaceRole, - datamodel::v1::ObjectMeta, + EndpointResult, EndpointStatus, GetSandboxConfigResponse, GpuResourceRequirements, + PolicySource, PolicyStatus, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, + SandboxPolicy, SandboxPolicyRevision, SandboxResources, SandboxStatus, + SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, + SandboxWorkloadTemplateSpec, ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, + WorkspaceRole, datamodel::v1::ObjectMeta, }; #[test] @@ -7144,6 +7280,195 @@ mod tests { assert!(json["policy"].is_null()); } + #[test] + fn sandbox_detail_keeps_failed_endpoint_separate_from_ready_conditions_in_json_and_yaml() { + let sandbox = Sandbox { + status: Some(SandboxStatus { + phase: SandboxPhase::Ready as i32, + endpoint_statuses: vec![EndpointStatus { + endpoint_id: "endpoint:example".to_string(), + host: "tools.example.test".to_string(), + ports: vec![443, 8443], + path: "/mcp".to_string(), + last_result: EndpointResult::TransportFailed as i32, + last_reported_at: "2026-09-05T10:01:00Z".to_string(), + }], + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: "DependenciesReady".to_string(), + message: "Supervisor session connected".to_string(), + last_transition_time: "2026-09-05T10:00:00Z".to_string(), + }], + ..Default::default() + }), + ..Default::default() + }; + + let json = super::sandbox_detail_to_json( + &sandbox, + &GetSandboxConfigResponse { + policy_source: PolicySource::Sandbox as i32, + ..Default::default() + }, + ) + .unwrap(); + + // Both formats preserve the complete endpoint record and independent + // lifecycle condition without requiring a join. + let yaml = serde_yml::to_string(&json).unwrap(); + let from_yaml: serde_json::Value = serde_yml::from_str(&yaml).unwrap(); + for output in [json, from_yaml] { + assert_eq!(output["phase"], "Ready"); + assert_eq!( + output["conditions"], + serde_json::json!([{ + "type": "Ready", + "status": "True", + "reason": "DependenciesReady", + "message": "Supervisor session connected", + "last_transition_time": "2026-09-05T10:00:00Z", + }]) + ); + assert_eq!( + output["endpoint_statuses"], + serde_json::json!([{ + "endpoint_id": "endpoint:example", + "host": "tools.example.test", + "ports": [443, 8443], + "path": "/mcp", + "last_result": "TransportFailed", + "last_reported_at": "2026-09-05T10:01:00Z", + }]) + ); + } + } + + #[test] + fn endpoint_status_display_explains_failure_and_report_time() { + let endpoint = EndpointStatus { + endpoint_id: "endpoint:example".to_string(), + host: "tools.example.test".to_string(), + ports: vec![443, 8443], + path: "/mcp".to_string(), + last_result: EndpointResult::TransportFailed as i32, + last_reported_at: "2026-09-05T11:01:00Z".to_string(), + }; + + assert_eq!( + super::endpoint_status_display_lines(&endpoint), + vec![ + "tools.example.test (ports: 443, 8443; path: /mcp)", + "Last result: Connection failed before an HTTP response arrived.", + "Reported at (gateway acceptance): 2026-09-05T11:01:00Z", + ] + ); + } + + #[test] + fn endpoint_status_preserves_address_without_an_observation() { + let endpoint = EndpointStatus { + endpoint_id: "endpoint:reset".to_string(), + host: "tools.example.test".to_string(), + ports: vec![443], + path: "/**".to_string(), + last_result: EndpointResult::NoObservedExchange as i32, + last_reported_at: String::new(), + }; + + assert_eq!( + super::endpoint_status_display_lines(&endpoint), + vec![ + "tools.example.test (ports: 443; path: /**)", + "Last result: No exchange observed.", + "Reported at (gateway acceptance): no report yet", + ] + ); + let sandbox = Sandbox { + status: Some(SandboxStatus { + endpoint_statuses: vec![endpoint], + ..Default::default() + }), + ..Default::default() + }; + let output = super::sandbox_to_json(&sandbox); + assert_eq!( + output["endpoint_statuses"], + serde_json::json!([{ + "endpoint_id": "endpoint:reset", + "host": "tools.example.test", + "ports": [443], + "path": "/**", + "last_result": "NoObservedExchange", + "last_reported_at": "", + }]) + ); + assert_eq!(output["conditions"], serde_json::json!([])); + } + + #[test] + fn endpoint_status_http_response_does_not_claim_a_successful_tool_call() { + let endpoint = EndpointStatus { + host: "tools.example.test".to_string(), + ports: vec![443], + path: "/mcp".to_string(), + last_result: EndpointResult::HttpResponseReceived as i32, + last_reported_at: "2026-09-05T11:01:00Z".to_string(), + ..Default::default() + }; + assert_eq!( + super::endpoint_status_display_lines(&endpoint)[1], + "Last result: Server returned an HTTP response below 400." + ); + } + + #[test] + fn endpoint_status_structured_output_uses_stable_result_names() { + for (result, name) in [ + (EndpointResult::Unspecified, "Unspecified"), + (EndpointResult::NoObservedExchange, "NoObservedExchange"), + (EndpointResult::HttpResponseReceived, "HttpResponseReceived"), + (EndpointResult::PolicyDenied, "PolicyDenied"), + ( + EndpointResult::CredentialUnavailable, + "CredentialUnavailable", + ), + (EndpointResult::TlsFailed, "TlsFailed"), + (EndpointResult::TransportFailed, "TransportFailed"), + (EndpointResult::UpstreamRejected, "UpstreamRejected"), + ] { + let sandbox = Sandbox { + status: Some(SandboxStatus { + endpoint_statuses: vec![EndpointStatus { + last_result: result as i32, + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + super::sandbox_to_json(&sandbox)["endpoint_statuses"][0]["last_result"], + name + ); + } + } + + #[test] + fn sandbox_condition_display_leaves_lifecycle_conditions_unchanged() { + let ordinary = SandboxCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: "DependenciesReady".to_string(), + message: "Supervisor session connected".to_string(), + last_transition_time: String::new(), + }; + assert_eq!( + super::sandbox_condition_display_lines(&ordinary), + vec!["Ready: True (DependenciesReady) - Supervisor session connected".to_string()] + ); + } + fn log_line( level: &str, target: &str, diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7b17ee7a3..0f69287d9 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -79,6 +79,15 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ReportEndpointStatusResponse {}, + )) + } + async fn begin_rootfs_tar_staging( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 29de1fe6f..f916d704b 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -32,6 +32,15 @@ struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ReportEndpointStatusResponse {}, + )) + } + async fn begin_rootfs_tar_staging( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 798927566..b7431bfc6 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -104,6 +104,15 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ReportEndpointStatusResponse {}, + )) + } + async fn begin_rootfs_tar_staging( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 5c24fc669..6434f66f4 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -23,11 +23,12 @@ use openshell_core::proto::{ GpuResourceRequirements, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxTemplatesRequest, ListSandboxTemplatesResponse, ListSandboxesRequest, - ListSandboxesResponse, PlatformEvent, Provider, ProviderResponse, RevokeSshSessionRequest, - RevokeSshSessionResponse, Sandbox, SandboxCondition, SandboxLogLine, SandboxPhase, - SandboxResponse, SandboxStatus, SandboxStreamEvent, SandboxTemplateResponse, - SandboxWorkloadTemplate, ServiceStatus, SettingValue, SupervisorMessage, UpdateProviderRequest, - WatchSandboxRequest, sandbox_stream_event, + ListSandboxesResponse, PlatformEvent, Provider, ProviderResponse, ReportEndpointStatusRequest, + ReportEndpointStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox, + SandboxCondition, SandboxLogLine, SandboxPhase, SandboxResponse, SandboxStatus, + SandboxStreamEvent, SandboxTemplateResponse, SandboxWorkloadTemplate, ServiceStatus, + SettingValue, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, + sandbox_stream_event, }; use std::collections::HashMap; use std::fs; @@ -833,6 +834,13 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + async fn report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn get_sandbox_logs( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index fdb0ebbd0..d04050159 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -47,6 +47,15 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ReportEndpointStatusResponse {}, + )) + } + async fn begin_rootfs_tar_staging( &self, _request: tonic::Request, diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index ff2cc638f..aa58f0470 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -24,6 +24,7 @@ thiserror = { workspace = true } miette = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } tracing = { workspace = true } url = { workspace = true } ipnet = "2" diff --git a/crates/openshell-core/src/endpoint_status.rs b/crates/openshell-core/src/endpoint_status.rs new file mode 100644 index 000000000..1da64e7bd --- /dev/null +++ b/crates/openshell-core/src/endpoint_status.rs @@ -0,0 +1,1184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Last observed network results for configured external tool endpoints. +//! +//! MCP-over-HTTP enforcement currently supplies the observations. Results are +//! passive evidence and do not determine sandbox readiness or present health. +//! +//! The types in this module deliberately cannot carry request URLs, headers, +//! bodies, tool arguments, credentials, or upstream error text. Network +//! enforcement reports only the configured endpoint identifier and a typed result. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::sync::Arc; + +use sha2::{Digest, Sha256}; +use tokio::sync::{mpsc, watch}; + +/// Maximum number of pending endpoint observations between network enforcement +/// and the sandbox orchestrator. +/// +/// Network callers use a non-blocking send and drop observations when this +/// bound is reached, so status reporting cannot delay proxied traffic. +pub const ENDPOINT_STATUS_CHANNEL_CAPACITY: usize = 64; + +/// Derive the stable, non-secret identifier reported for a configured tool server endpoint. +/// +/// The digest covers only the endpoint's lowercase host, effective sorted port +/// set, and canonical path. Every value is length-prefixed and the digest is +/// domain-separated, so concatenation cannot make distinct endpoints collide. +/// The modern `ports` field takes precedence over the legacy singular `port`. +#[must_use] +pub fn endpoint_id(endpoint: &crate::proto::NetworkEndpoint) -> String { + initial_endpoint_status(endpoint).endpoint_id +} + +/// Describe a configured tool server endpoint using the canonical identity and an unobserved result. +/// +/// Only configured host, port, and path selectors are exposed. Observed request +/// URLs, query strings, caller identities, and credentials never enter this value. +#[must_use] +pub fn initial_endpoint_status( + endpoint: &crate::proto::NetworkEndpoint, +) -> crate::proto::EndpointStatus { + const DOMAIN: &[u8] = b"openshell:endpoint:v1"; + + let host = endpoint.host.to_ascii_lowercase(); + let path = match endpoint.path.as_str() { + "" | "**" | "/**" => "/**", + path => path, + }; + let mut ports = if endpoint.ports.is_empty() { + (endpoint.port != 0) + .then_some(endpoint.port) + .into_iter() + .collect() + } else { + endpoint.ports.clone() + }; + ports.sort_unstable(); + ports.dedup(); + + let mut digest = Sha256::new(); + digest.update(DOMAIN); + hash_identity_value(&mut digest, host.as_bytes()); + hash_identity_value(&mut digest, path.as_bytes()); + let port_count = u64::try_from(ports.len()).unwrap_or(u64::MAX); + hash_identity_value(&mut digest, &port_count.to_be_bytes()); + for port in &ports { + hash_identity_value(&mut digest, &port.to_be_bytes()); + } + + let mut endpoint_id = String::with_capacity("endpoint:v1:".len() + 64); + endpoint_id.push_str("endpoint:v1:"); + for byte in digest.finalize() { + // Writing to a String is infallible; ignore fmt's Result without + // weakening the identity derivation contract with a panic path. + let _ = write!(endpoint_id, "{byte:02x}"); + } + crate::proto::EndpointStatus { + endpoint_id, + host, + ports, + path: path.to_string(), + last_result: crate::proto::EndpointResult::NoObservedExchange.into(), + last_reported_at: String::new(), + } +} + +fn hash_identity_value(digest: &mut Sha256, value: &[u8]) { + let value_len = u64::try_from(value.len()).unwrap_or(u64::MAX); + digest.update(value_len.to_be_bytes()); + digest.update(value); +} + +/// A version of the policy and provider environment installed in a sandbox. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EndpointConfigVersion { + /// Hash of the installed sandbox policy. + pub policy_hash: String, + /// Revision of the installed provider environment. + pub provider_env_revision: u64, +} + +/// Opaque identity for one inventory installation or observation authority. +/// +/// Identity follows the allocation, not configuration values or a counter. +/// A retained handle keeps its allocation alive, so its identity cannot recur. +#[derive(Clone, Debug)] +pub struct EndpointObservationGeneration(Arc<()>); + +impl EndpointObservationGeneration { + fn new() -> Self { + Self(Arc::new(())) + } + + fn matches(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +#[derive(Clone, Debug)] +struct InstalledInventory { + config_version: EndpointConfigVersion, + generation: EndpointObservationGeneration, +} + +#[derive(Debug)] +struct ObservationState { + inventory: Option, + authority_generation: EndpointObservationGeneration, +} + +/// One configured MCP endpoint in the current endpoint inventory. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EndpointInventoryEntry { + /// Stable `OpenShell`-derived identifier validated against gateway inventory. + pub endpoint_id: String, + /// Whether this endpoint depends on provider-managed credentials. + pub uses_provider_credentials: bool, +} + +/// A redacted network result observed for a configured tool endpoint. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum EndpointResult { + /// No MCP exchange has been observed since the endpoint became current. + #[default] + NoObservedExchange, + /// The last observed upstream HTTP response had a status below 400. + /// This does not establish MCP protocol success or current availability. + HttpResponseReceived, + /// `OpenShell` policy denied the MCP exchange. + PolicyDenied, + /// A required provider credential was unavailable. + CredentialUnavailable, + /// TLS establishment or verification failed. + TlsFailed, + /// The transport could not reach or communicate with the upstream. + TransportFailed, + /// The upstream rejected an otherwise completed MCP exchange. + UpstreamRejected, +} + +/// A request-scoped handle bound to an inventory and supervisor authority. +/// +/// Its fields are private so callers cannot attach arbitrary request material. +/// Capture the handle before starting an upstream exchange and submit it only +/// after the exchange reaches a terminal result. +#[derive(Clone, Debug)] +pub struct EndpointObservationHandle { + config_version: EndpointConfigVersion, + endpoint_id: String, + inventory_generation: EndpointObservationGeneration, + authority_generation: EndpointObservationGeneration, +} + +/// Observation authority captured before a request selects policy or credentials. +/// +/// Selection can cross asynchronous work and a configuration installation. The +/// captured identities let the eventual endpoint binding reject that mixed state, +/// including installations whose policy and provider values recur. +#[derive(Clone, Debug)] +pub struct EndpointObservationContext { + inventory: InstalledInventory, + authority_generation: EndpointObservationGeneration, +} + +/// Commands consumed in FIFO order by the sandbox endpoint-status tracker. +#[derive(Debug)] +pub enum EndpointStatusCommand { + /// Replace the complete configured endpoint inventory for an installed configuration version. + Reset { + /// Policy and provider version represented by this inventory. + config_version: EndpointConfigVersion, + /// Complete set of configured tool server endpoints. + endpoints: Vec, + /// Unique identity minted when this reset is enqueued. + inventory_generation: EndpointObservationGeneration, + }, + /// Apply one redacted runtime result to an endpoint in the captured configuration version. + Observe { + /// Configuration captured when the request started. + config_version: EndpointConfigVersion, + /// Stable configured endpoint identifier. + endpoint_id: String, + /// Typed, non-sensitive terminal result. + result: EndpointResult, + /// Inventory installation captured when the request started. + inventory_generation: EndpointObservationGeneration, + /// Supervisor observation authority captured when the request started. + authority_generation: EndpointObservationGeneration, + }, +} + +/// Receiving half of the bounded tool server endpoint-status channel. +/// +/// Construct its tracker before consuming commands so supervisor replacement +/// can invalidate handles even while the first inventory reset is queued. +#[derive(Debug)] +pub struct EndpointStatusReceiver { + commands: mpsc::Receiver, + current: watch::Sender, +} + +impl EndpointStatusReceiver { + /// Create the FIFO tracker sharing this channel's observation authority. + #[must_use] + pub fn tracker(&self) -> EndpointStatusTracker { + EndpointStatusTracker { + config_version: None, + inventory_generation: None, + current: self.current.clone(), + endpoints: BTreeMap::new(), + } + } + + /// Receive the next command, or `None` once all senders have stopped. + pub async fn recv(&mut self) -> Option { + self.commands.recv().await + } + + /// Receive a queued command without waiting. + /// + /// # Errors + /// + /// Returns an error when the queue is empty or all senders have stopped. + pub fn try_recv(&mut self) -> Result { + self.commands.try_recv() + } +} + +/// Non-blocking observation sender shared with network enforcement. +/// +/// Inventory resets reserve bounded channel capacity and publish their configuration version +/// before new requests can capture it. Runtime observations never wait for +/// capacity and therefore cannot add backpressure to network traffic. +#[derive(Clone, Debug)] +pub struct EndpointObservationSender { + commands: mpsc::Sender, + current: watch::Sender, +} + +impl EndpointObservationSender { + /// Capture the installed observation authority before selecting request state. + /// + /// Returns `None` until the first complete endpoint inventory is installed. + #[must_use] + pub fn capture(&self) -> Option { + let current = self.current.borrow(); + Some(EndpointObservationContext { + inventory: current.inventory.clone()?, + authority_generation: current.authority_generation.clone(), + }) + } + + /// Bind a selected endpoint to the authority captured at request start. + /// + /// The selected policy hash and any request-scoped provider revision must + /// match that authority. A replaced inventory or supervisor also rejects the + /// binding, even when their configuration values recur. Requests without a + /// provider snapshot pass `None`; their policy selection is still checked. + #[must_use] + pub fn begin_captured( + &self, + context: &EndpointObservationContext, + endpoint_id: String, + policy_hash: &str, + provider_env_revision: Option, + ) -> Option { + let config = &context.inventory.config_version; + if config.policy_hash != policy_hash + || provider_env_revision + .is_some_and(|revision| revision != config.provider_env_revision) + { + // Runtime activation and inventory publication are separate. Reject + // observations during that gap instead of assigning another policy's result. + return None; + } + let current = self.current.borrow(); + let inventory = current.inventory.as_ref()?; + if !inventory.generation.matches(&context.inventory.generation) + || !current + .authority_generation + .matches(&context.authority_generation) + { + return None; + } + Some(EndpointObservationHandle { + config_version: config.clone(), + endpoint_id, + inventory_generation: context.inventory.generation.clone(), + authority_generation: context.authority_generation.clone(), + }) + } + + /// Enqueue a complete inventory reset and make its configuration version available to new + /// request observations. + /// + /// # Errors + /// + /// Returns the reset command when the sandbox tracker has stopped. + pub async fn reset( + &self, + config_version: EndpointConfigVersion, + endpoints: Vec, + ) -> Result<(), mpsc::error::SendError> { + let inventory_generation = EndpointObservationGeneration::new(); + let permit = self.commands.reserve().await.map_err(|_| { + mpsc::error::SendError(EndpointStatusCommand::Reset { + config_version: config_version.clone(), + endpoints: endpoints.clone(), + inventory_generation: inventory_generation.clone(), + }) + })?; + // One short write lock orders FIFO insertion and publication across + // cloned senders. Capacity is reserved before locking, so no await + // holds the state lock and no new handle precedes its inventory. + self.current.send_modify(|current| { + permit.send(EndpointStatusCommand::Reset { + config_version: config_version.clone(), + endpoints, + inventory_generation: inventory_generation.clone(), + }); + current.inventory = Some(InstalledInventory { + config_version, + generation: inventory_generation, + }); + }); + Ok(()) + } + + /// Capture the current inventory and supervisor authority at request start. + /// + /// Returns `None` until the first complete endpoint inventory is installed. + #[must_use] + pub fn begin(&self, endpoint_id: String) -> Option { + let current = self.current.borrow(); + let inventory = current.inventory.as_ref()?; + Some(EndpointObservationHandle { + config_version: inventory.config_version.clone(), + endpoint_id, + inventory_generation: inventory.generation.clone(), + authority_generation: current.authority_generation.clone(), + }) + } + + /// Try to enqueue a terminal observation without waiting for capacity. + /// + /// Returns `false` for `NoObservedExchange`, if the bounded channel is + /// full, or if its receiver has stopped. Callers must treat a rejected + /// update as status loss, never as a network failure. + #[must_use] + pub fn try_observe( + &self, + observation: EndpointObservationHandle, + result: EndpointResult, + ) -> bool { + if result == EndpointResult::NoObservedExchange { + return false; + } + self.commands + .try_send(EndpointStatusCommand::Observe { + config_version: observation.config_version, + endpoint_id: observation.endpoint_id, + result, + inventory_generation: observation.inventory_generation, + authority_generation: observation.authority_generation, + }) + .is_ok() + } +} + +/// Create the bounded channel used for tool server endpoint inventory and observations. +#[must_use] +pub fn endpoint_status_channel() -> (EndpointObservationSender, EndpointStatusReceiver) { + let (commands, receiver) = mpsc::channel(ENDPOINT_STATUS_CHANNEL_CAPACITY); + let (current, _) = watch::channel(ObservationState { + inventory: None, + authority_generation: EndpointObservationGeneration::new(), + }); + ( + EndpointObservationSender { + commands, + current: current.clone(), + }, + EndpointStatusReceiver { + commands: receiver, + current, + }, + ) +} + +/// One endpoint result in a complete status snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EndpointObservation { + /// Stable `OpenShell`-derived identifier validated against gateway inventory. + pub endpoint_id: String, + /// Latest result observed for the endpoint in the current configuration version. + pub result: EndpointResult, +} + +/// Complete tool server endpoint status for one installed policy and provider revision. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EndpointStatusSnapshot { + /// Policy and provider version represented by this snapshot. + pub config_version: EndpointConfigVersion, + /// Complete, identifier-sorted set of configured endpoint statuses. + pub endpoints: Vec, + /// Endpoints that carried at least one new result in this report batch. + /// + /// The gateway uses this set to advance observation timestamps only for + /// endpoints actually observed since the previous accepted report. It + /// validates every value against `endpoints` and the current policy. + pub observed_endpoint_ids: Vec, + /// Active supervisor session authorized to report this snapshot. + /// + /// The endpoint reporter fills this field after the gateway accepts the + /// `ConnectSupervisor` stream. Trackers leave it empty. + pub supervisor_session_id: String, + /// Monotonic sequence within one authenticated supervisor session. + /// + /// Each frozen snapshot reserves a sequence, including snapshots superseded + /// by an inventory reset. Gaps are valid. A retry preserves its complete body + /// and sequence so a lost response cannot advance observation timestamps. + pub report_sequence: u64, +} + +/// FIFO state machine that turns inventory and observations into snapshots. +#[derive(Debug)] +pub struct EndpointStatusTracker { + config_version: Option, + inventory_generation: Option, + current: watch::Sender, + endpoints: BTreeMap, +} + +impl EndpointStatusTracker { + /// Apply one FIFO command. + /// + /// Returns `true` when the command must publish a fresh snapshot. Repeated + /// results still return `true` so the gateway can advance observation + /// freshness. Observations for an obsolete inventory, supervisor authority, + /// or unknown endpoint identifier are ignored. Reinstalling equal configuration values + /// cannot make an in-flight exchange from an older installation current. + pub fn apply(&mut self, command: EndpointStatusCommand) -> bool { + match command { + EndpointStatusCommand::Reset { + config_version, + endpoints, + inventory_generation, + } => { + self.reset(config_version, endpoints); + // A queued reset installs only its inventory identity. It + // cannot restore authority invalidated by a session change. + self.inventory_generation = Some(inventory_generation); + true + } + EndpointStatusCommand::Observe { + config_version, + endpoint_id, + result, + inventory_generation, + authority_generation, + } => { + if self.config_version.as_ref() != Some(&config_version) + || !self + .inventory_generation + .as_ref() + .is_some_and(|current| current.matches(&inventory_generation)) + || !self + .current + .borrow() + .authority_generation + .matches(&authority_generation) + { + // Stale evidence must not change results or freshness. + return false; + } + let Some(endpoint) = self.endpoints.get_mut(&endpoint_id) else { + return false; + }; + // Repeated results are material observations: the gateway + // advances endpoint freshness even when the classification is + // unchanged. + *endpoint = result; + true + } + } + } + + /// Return the complete current snapshot, or `None` before the first reset. + #[must_use] + pub fn snapshot(&self) -> Option { + let config_version = self.config_version.clone()?; + let endpoints = self + .endpoints + .iter() + .map(|(endpoint_id, endpoint)| EndpointObservation { + endpoint_id: endpoint_id.clone(), + result: *endpoint, + }) + .collect(); + Some(EndpointStatusSnapshot { + config_version, + endpoints, + observed_endpoint_ids: Vec::new(), + supervisor_session_id: String::new(), + report_sequence: 0, + }) + } + + /// Forget results learned by a previous authenticated supervisor session. + /// + /// A replacement session is a new observation authority. Retaining its + /// predecessor's results would let a reconnect restore stale evidence + /// immediately after the gateway resets the endpoint statuses. + pub fn clear_observations(&mut self) -> bool { + // Rotate even before the first reset is consumed: its sender may + // already have issued handles. Inventory resets never replace this + // authority, so pending resets cannot revive pre-replacement handles. + self.current.send_modify(|current| { + current.authority_generation = EndpointObservationGeneration::new(); + }); + let Some(_config_version) = self.config_version.as_ref() else { + return false; + }; + for endpoint in self.endpoints.values_mut() { + *endpoint = EndpointResult::NoObservedExchange; + } + true + } + + fn reset( + &mut self, + config_version: EndpointConfigVersion, + inventory: Vec, + ) { + let policy_changed = self + .config_version + .as_ref() + .is_none_or(|current| current.policy_hash != config_version.policy_hash); + let provider_changed = self.config_version.as_ref().is_some_and(|current| { + current.provider_env_revision != config_version.provider_env_revision + }); + let previous = std::mem::take(&mut self.endpoints); + + self.endpoints = inventory + .into_iter() + .map(|endpoint| { + // A policy change invalidates every observation. A provider + // revision invalidates only endpoints whose runtime behavior can + // depend on provider-managed credentials. + let preserve = + !policy_changed && (!provider_changed || !endpoint.uses_provider_credentials); + let result = if preserve { + previous + .get(&endpoint.endpoint_id) + .copied() + .unwrap_or(EndpointResult::NoObservedExchange) + } else { + EndpointResult::NoObservedExchange + }; + (endpoint.endpoint_id, result) + }) + .collect(); + self.config_version = Some(config_version); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_version(policy_hash: &str, provider_env_revision: u64) -> EndpointConfigVersion { + EndpointConfigVersion { + policy_hash: policy_hash.to_string(), + provider_env_revision, + } + } + + fn inventory() -> Vec { + vec![ + EndpointInventoryEntry { + endpoint_id: "endpoint:credentialed".to_string(), + uses_provider_credentials: true, + }, + EndpointInventoryEntry { + endpoint_id: "endpoint:public".to_string(), + uses_provider_credentials: false, + }, + ] + } + + fn endpoint( + host: &str, + port: u32, + ports: Vec, + path: &str, + ) -> crate::proto::NetworkEndpoint { + crate::proto::NetworkEndpoint { + host: host.to_string(), + port, + ports, + path: path.to_string(), + protocol: "mcp".to_string(), + ..Default::default() + } + } + + #[test] + fn endpoint_identity_is_stable_across_equivalent_endpoint_spelling() { + let legacy = endpoint("MCP.EXAMPLE.COM", 443, Vec::new(), ""); + let modern = endpoint("mcp.example.com", 0, vec![443, 443], "/**"); + + let identifier = endpoint_id(&legacy); + assert_eq!(identifier, endpoint_id(&modern)); + assert_eq!( + identifier, + "endpoint:v1:82a2b0fc1e462408e15e5e9ad55b851e9e688cdf091da695ba177bb0403916af" + ); + } + + #[test] + fn endpoint_identity_normalizes_effective_port_set() { + let unsorted = endpoint("mcp.example.com", 8443, vec![8443, 443, 443], "**"); + let sorted = endpoint("mcp.example.com", 0, vec![443, 8443], "/**"); + + assert_eq!( + endpoint_id(&unsorted), + endpoint_id(&sorted), + "the repeated ports field takes precedence and is a set" + ); + } + + #[test] + fn endpoint_identity_distinguishes_scoped_paths() { + let root = endpoint("mcp.example.com", 443, Vec::new(), "/**"); + let scoped = endpoint("mcp.example.com", 443, Vec::new(), "/mcp"); + + assert_ne!(endpoint_id(&root), endpoint_id(&scoped)); + } + + #[test] + fn initial_status_contains_canonical_address_and_unobserved_result() { + let configured = endpoint("MCP.EXAMPLE.COM", 1234, vec![8443, 443, 443], "**"); + let descriptor = initial_endpoint_status(&configured); + assert_eq!(descriptor.host, "mcp.example.com"); + assert_eq!(descriptor.ports, vec![443, 8443]); + assert_eq!(descriptor.path, "/**"); + assert_eq!( + descriptor.last_result, + crate::proto::EndpointResult::NoObservedExchange as i32, + ); + assert!(descriptor.last_reported_at.is_empty()); + + let reconstructed = endpoint( + &descriptor.host, + 0, + descriptor.ports.clone(), + &descriptor.path, + ); + assert_eq!(descriptor.endpoint_id, endpoint_id(&reconstructed)); + assert_eq!(descriptor, initial_endpoint_status(&reconstructed)); + assert_eq!( + initial_endpoint_status(&endpoint("MCP.EXAMPLE.COM", 443, Vec::new(), "")), + initial_endpoint_status(&endpoint("mcp.example.com", 0, vec![443], "/**")), + ); + } + + #[test] + fn initial_status_preserves_scoped_paths_without_inventing_ports() { + let descriptor = initial_endpoint_status(&endpoint("MCP.EXAMPLE.COM", 0, vec![], "/mcp")); + assert_eq!(descriptor.host, "mcp.example.com"); + assert!(descriptor.ports.is_empty()); + assert_eq!(descriptor.path, "/mcp"); + assert_ne!( + descriptor.endpoint_id, + endpoint_id(&endpoint("mcp.example.com", 443, vec![], "/mcp")), + ); + } + + fn observe( + tracker: &EndpointStatusTracker, + config_version: EndpointConfigVersion, + endpoint_id: &str, + result: EndpointResult, + ) -> EndpointStatusCommand { + EndpointStatusCommand::Observe { + inventory_generation: tracker + .inventory_generation + .clone() + .expect("inventory installed"), + authority_generation: tracker.current.borrow().authority_generation.clone(), + config_version, + endpoint_id: endpoint_id.to_string(), + result, + } + } + + #[test] + fn policy_change_resets_every_endpoint() { + let mut tracker = endpoint_status_channel().1.tracker(); + tracker.apply(EndpointStatusCommand::Reset { + inventory_generation: EndpointObservationGeneration::new(), + config_version: config_version("policy-a", 1), + endpoints: inventory(), + }); + tracker.apply(observe( + &tracker, + config_version("policy-a", 1), + "endpoint:credentialed", + EndpointResult::HttpResponseReceived, + )); + tracker.apply(observe( + &tracker, + config_version("policy-a", 1), + "endpoint:public", + EndpointResult::HttpResponseReceived, + )); + + tracker.apply(EndpointStatusCommand::Reset { + inventory_generation: EndpointObservationGeneration::new(), + config_version: config_version("policy-b", 1), + endpoints: inventory(), + }); + + let snapshot = tracker.snapshot().expect("reset creates a snapshot"); + assert!( + snapshot + .endpoints + .iter() + .all(|endpoint| endpoint.result == EndpointResult::NoObservedExchange) + ); + } + + #[test] + fn reset_rebuilds_inventory_and_preserves_matching_current_endpoints() { + let mut tracker = endpoint_status_channel().1.tracker(); + tracker.apply(EndpointStatusCommand::Reset { + inventory_generation: EndpointObservationGeneration::new(), + config_version: config_version("policy-a", 1), + endpoints: inventory(), + }); + tracker.apply(observe( + &tracker, + config_version("policy-a", 1), + "endpoint:public", + EndpointResult::HttpResponseReceived, + )); + + tracker.apply(EndpointStatusCommand::Reset { + inventory_generation: EndpointObservationGeneration::new(), + config_version: config_version("policy-a", 1), + endpoints: vec![ + EndpointInventoryEntry { + endpoint_id: "endpoint:new".to_string(), + uses_provider_credentials: false, + }, + EndpointInventoryEntry { + endpoint_id: "endpoint:public".to_string(), + uses_provider_credentials: false, + }, + ], + }); + + assert_eq!( + tracker + .snapshot() + .expect("reset creates a snapshot") + .endpoints, + vec![ + EndpointObservation { + endpoint_id: "endpoint:new".to_string(), + result: EndpointResult::NoObservedExchange, + }, + EndpointObservation { + endpoint_id: "endpoint:public".to_string(), + result: EndpointResult::HttpResponseReceived, + }, + ] + ); + } + + #[test] + fn provider_change_preserves_only_uncredentialed_endpoints() { + let mut tracker = endpoint_status_channel().1.tracker(); + tracker.apply(EndpointStatusCommand::Reset { + inventory_generation: EndpointObservationGeneration::new(), + config_version: config_version("policy-a", 1), + endpoints: inventory(), + }); + for endpoint_id in ["endpoint:credentialed", "endpoint:public"] { + tracker.apply(observe( + &tracker, + config_version("policy-a", 1), + endpoint_id, + EndpointResult::HttpResponseReceived, + )); + } + + tracker.apply(EndpointStatusCommand::Reset { + inventory_generation: EndpointObservationGeneration::new(), + config_version: config_version("policy-a", 2), + endpoints: inventory(), + }); + + let snapshot = tracker.snapshot().expect("reset creates a snapshot"); + assert_eq!( + snapshot.endpoints, + vec![ + EndpointObservation { + endpoint_id: "endpoint:credentialed".to_string(), + result: EndpointResult::NoObservedExchange, + }, + EndpointObservation { + endpoint_id: "endpoint:public".to_string(), + result: EndpointResult::HttpResponseReceived, + }, + ] + ); + } + + #[test] + fn stale_observation_cannot_update_new_configuration() { + let mut tracker = endpoint_status_channel().1.tracker(); + tracker.apply(EndpointStatusCommand::Reset { + inventory_generation: EndpointObservationGeneration::new(), + config_version: config_version("policy-a", 1), + endpoints: inventory(), + }); + tracker.apply(EndpointStatusCommand::Reset { + inventory_generation: EndpointObservationGeneration::new(), + config_version: config_version("policy-a", 2), + endpoints: inventory(), + }); + + assert!(!tracker.apply(observe( + &tracker, + config_version("policy-a", 1), + "endpoint:credentialed", + EndpointResult::HttpResponseReceived, + ))); + assert_eq!( + tracker + .snapshot() + .expect("reset creates a snapshot") + .endpoints[0] + .result, + EndpointResult::NoObservedExchange + ); + } + + #[test] + fn repeated_outcome_remains_a_reportable_observation() { + let mut tracker = endpoint_status_channel().1.tracker(); + tracker.apply(EndpointStatusCommand::Reset { + inventory_generation: EndpointObservationGeneration::new(), + config_version: config_version("policy-a", 1), + endpoints: inventory(), + }); + let first = tracker.apply(observe( + &tracker, + config_version("policy-a", 1), + "endpoint:public", + EndpointResult::HttpResponseReceived, + )); + let second = tracker.apply(observe( + &tracker, + config_version("policy-a", 1), + "endpoint:public", + EndpointResult::HttpResponseReceived, + )); + + assert!(first); + assert!(second); + } + + #[tokio::test] + async fn handles_remain_stale_after_supervisor_replacement() { + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + sender + .reset(config_version("policy-a", 1), inventory()) + .await + .unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + let stale = sender.begin("endpoint:public".to_string()).unwrap(); + assert!(sender.try_observe(stale.clone(), EndpointResult::HttpResponseReceived)); + assert!(tracker.apply(receiver.recv().await.unwrap())); + + for _ in 0..2 { + assert!(tracker.clear_observations()); + assert!(sender.try_observe(stale.clone(), EndpointResult::HttpResponseReceived)); + assert!(!tracker.apply(receiver.recv().await.unwrap())); + assert!( + tracker + .snapshot() + .unwrap() + .endpoints + .iter() + .all(|endpoint| { endpoint.result == EndpointResult::NoObservedExchange }) + ); + + let fresh = sender.begin("endpoint:public".to_string()).unwrap(); + assert!(sender.try_observe(fresh, EndpointResult::PolicyDenied)); + assert!(tracker.apply(receiver.recv().await.unwrap())); + assert_eq!( + tracker.snapshot().unwrap().endpoints[1].result, + EndpointResult::PolicyDenied + ); + } + } + + #[tokio::test] + async fn handles_remain_stale_when_inventory_values_recur() { + // Exercise policy cycles, provider cycles, and same-value reinstallations. + for intermediate in [ + config_version("policy-b", 1), + config_version("policy-a", 2), + config_version("policy-a", 1), + ] { + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + let original = config_version("policy-a", 1); + sender.reset(original.clone(), inventory()).await.unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + let stale = sender.begin("endpoint:public".to_string()).unwrap(); + + sender.reset(intermediate, inventory()).await.unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + sender.reset(original, inventory()).await.unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + + assert!(sender.try_observe(stale, EndpointResult::HttpResponseReceived)); + assert!(!tracker.apply(receiver.recv().await.unwrap())); + assert!( + tracker + .snapshot() + .unwrap() + .endpoints + .iter() + .all(|endpoint| { endpoint.result == EndpointResult::NoObservedExchange }) + ); + let fresh = sender.begin("endpoint:public".to_string()).unwrap(); + assert!(sender.try_observe(fresh, EndpointResult::PolicyDenied)); + assert!(tracker.apply(receiver.recv().await.unwrap())); + assert_eq!( + tracker.snapshot().unwrap().endpoints[1].result, + EndpointResult::PolicyDenied + ); + } + } + + #[tokio::test] + async fn captured_context_rejects_mixed_policy_and_provider_selection() { + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + sender + .reset(config_version("policy-a", 1), inventory()) + .await + .unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + let context = sender.capture().unwrap(); + assert!( + sender + .begin_captured(&context, "endpoint:public".into(), "policy-b", Some(1)) + .is_none() + ); + assert!( + sender + .begin_captured(&context, "endpoint:public".into(), "policy-a", Some(2)) + .is_none() + ); + let current = sender + .begin_captured(&context, "endpoint:public".into(), "policy-a", Some(1)) + .unwrap(); + assert!(sender.try_observe(current, EndpointResult::PolicyDenied)); + assert!(tracker.apply(receiver.recv().await.unwrap())); + assert_eq!( + tracker.snapshot().unwrap().endpoints[1].result, + EndpointResult::PolicyDenied + ); + } + + #[tokio::test] + async fn captured_context_stays_retired_across_policy_provider_and_session_cycles() { + for intermediate in [ + config_version("policy-b", 1), + config_version("policy-a", 2), + config_version("policy-a", 1), + ] { + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + sender + .reset(config_version("policy-a", 1), inventory()) + .await + .unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + let stale = sender.capture().unwrap(); + sender.reset(intermediate, inventory()).await.unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + sender + .reset(config_version("policy-a", 1), inventory()) + .await + .unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + assert!( + sender + .begin_captured(&stale, "endpoint:public".into(), "policy-a", Some(1)) + .is_none() + ); + let replaced = sender.capture().unwrap(); + assert!(tracker.clear_observations()); + assert!( + sender + .begin_captured(&replaced, "endpoint:public".into(), "policy-a", Some(1)) + .is_none() + ); + let fresh = sender.capture().unwrap(); + let current = sender + .begin_captured(&fresh, "endpoint:public".into(), "policy-a", Some(1)) + .unwrap(); + assert!(sender.try_observe(current, EndpointResult::PolicyDenied)); + assert!(tracker.apply(receiver.recv().await.unwrap())); + } + } + + #[tokio::test] + async fn queued_inventory_cannot_restore_replaced_authority() { + for already_installed in [false, true] { + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + if already_installed { + sender + .reset(config_version("policy-a", 1), inventory()) + .await + .unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + } + sender + .reset(config_version("policy-b", 1), inventory()) + .await + .unwrap(); + let stale = sender.begin("endpoint:public".to_string()).unwrap(); + + assert_eq!(tracker.clear_observations(), already_installed); + let fresh = sender.begin("endpoint:public".to_string()).unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + assert!(sender.try_observe(stale, EndpointResult::HttpResponseReceived)); + assert!(!tracker.apply(receiver.recv().await.unwrap())); + assert!( + tracker + .snapshot() + .unwrap() + .endpoints + .iter() + .all(|endpoint| { endpoint.result == EndpointResult::NoObservedExchange }) + ); + assert!(sender.try_observe(fresh, EndpointResult::PolicyDenied)); + assert!(tracker.apply(receiver.recv().await.unwrap())); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_resets_publish_in_fifo_order() { + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let mut resets = Vec::new(); + for policy in ["policy-a", "policy-b"] { + let sender = sender.clone(); + let barrier = barrier.clone(); + resets.push(tokio::spawn(async move { + barrier.wait().await; + sender + .reset(config_version(policy, 1), inventory()) + .await + .unwrap(); + })); + } + for reset in resets { + reset.await.unwrap(); + } + for _ in 0..2 { + assert!(tracker.apply(receiver.recv().await.unwrap())); + } + + let fresh = sender.begin("endpoint:public".to_string()).unwrap(); + assert_eq!( + tracker.snapshot().unwrap().config_version, + fresh.config_version + ); + assert!(sender.try_observe(fresh, EndpointResult::HttpResponseReceived)); + assert!(tracker.apply(receiver.recv().await.unwrap())); + } + + #[tokio::test] + async fn full_channel_drops_observations_without_publishing_a_cancelled_reset() { + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + sender + .reset(config_version("policy-a", 1), inventory()) + .await + .unwrap(); + assert!(tracker.apply(receiver.recv().await.unwrap())); + let observation = sender.begin("endpoint:public".to_string()).unwrap(); + for _ in 0..ENDPOINT_STATUS_CHANNEL_CAPACITY { + assert!(sender.try_observe(observation.clone(), EndpointResult::HttpResponseReceived)); + } + assert!(!sender.try_observe(observation.clone(), EndpointResult::HttpResponseReceived)); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + sender.reset(config_version("policy-b", 1), inventory()), + ) + .await + .is_err() + ); + let current = sender.begin("endpoint:public".to_string()).unwrap(); + assert!( + current + .inventory_generation + .matches(&observation.inventory_generation) + ); + assert!(tracker.apply(receiver.recv().await.unwrap())); + drop(receiver); + assert!(!sender.try_observe(current, EndpointResult::HttpResponseReceived)); + assert!( + sender + .reset(config_version("policy-b", 1), inventory()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn sender_captures_configuration_without_sensitive_payload_fields() { + let (sender, mut receiver) = endpoint_status_channel(); + assert!(sender.begin("endpoint:public".to_string()).is_none()); + sender + .reset(config_version("policy-a", 1), inventory()) + .await + .expect("receiver remains active"); + let observation = sender + .begin("endpoint:public".to_string()) + .expect("reset publishes the config_version"); + assert!(!sender.try_observe(observation.clone(), EndpointResult::NoObservedExchange)); + assert!(sender.try_observe(observation, EndpointResult::HttpResponseReceived)); + + assert!(matches!( + receiver.recv().await, + Some(EndpointStatusCommand::Reset { .. }) + )); + assert!(matches!( + receiver.recv().await, + Some(EndpointStatusCommand::Observe { + endpoint_id, + result: EndpointResult::HttpResponseReceived, + .. + }) if endpoint_id == "endpoint:public" + )); + } +} diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 38b91e850..4fed53753 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -22,13 +22,15 @@ use std::collections::HashMap; use std::sync::{Arc, OnceLock, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use crate::endpoint_status::{EndpointResult, EndpointStatusSnapshot}; use crate::proto::{ - DenialSummary, ExchangeProviderSubjectTokenRequest, GetDraftPolicyRequest, - GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, IssueSandboxTokenRequest, - NetworkActivitySummary, PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest, - ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, UpdateConfigRequest, open_shell_client::OpenShellClient, - workspace_selector, + DenialSummary, EndpointObservation as ProtoEndpointObservation, + EndpointResult as ProtoEndpointResult, ExchangeProviderSubjectTokenRequest, + GetDraftPolicyRequest, GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, + IssueSandboxTokenRequest, NetworkActivitySummary, PolicyChunk, PolicySource, PolicyStatus, + RefreshSandboxTokenRequest, ReportEndpointStatusRequest, ReportPolicyStatusRequest, + SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, + UpdateConfigRequest, open_shell_client::OpenShellClient, workspace_selector, }; use crate::sandbox_env; use miette::{IntoDiagnostic, Result, WrapErr}; @@ -1215,4 +1217,53 @@ impl CachedOpenShellClient { Ok(()) } + + /// Report the latest network results for all configured tool endpoints. + /// + /// The shared snapshot contains only endpoint identifiers, typed results, + /// and the endpoints observed in the current batch, so this RPC cannot + /// accidentally attach request or credential material to public status. + pub async fn report_endpoint_status( + &self, + sandbox_id: &str, + snapshot: &EndpointStatusSnapshot, + ) -> Result<()> { + let observations = snapshot + .endpoints + .iter() + .map(|endpoint| ProtoEndpointObservation { + endpoint_id: endpoint.endpoint_id.clone(), + result: match endpoint.result { + EndpointResult::NoObservedExchange => ProtoEndpointResult::NoObservedExchange, + EndpointResult::HttpResponseReceived => { + ProtoEndpointResult::HttpResponseReceived + } + EndpointResult::PolicyDenied => ProtoEndpointResult::PolicyDenied, + EndpointResult::CredentialUnavailable => { + ProtoEndpointResult::CredentialUnavailable + } + EndpointResult::TlsFailed => ProtoEndpointResult::TlsFailed, + EndpointResult::TransportFailed => ProtoEndpointResult::TransportFailed, + EndpointResult::UpstreamRejected => ProtoEndpointResult::UpstreamRejected, + } + .into(), + }) + .collect(); + + self.client + .clone() + .report_endpoint_status(ReportEndpointStatusRequest { + sandbox_id: sandbox_id.to_string(), + policy_hash: snapshot.config_version.policy_hash.clone(), + provider_env_revision: snapshot.config_version.provider_env_revision, + observations, + observed_endpoint_ids: snapshot.observed_endpoint_ids.clone(), + supervisor_session_id: snapshot.supervisor_session_id.clone(), + report_sequence: snapshot.report_sequence, + }) + .await + .into_diagnostic()?; + + Ok(()) + } } diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 10e200816..b24443179 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -18,6 +18,7 @@ pub mod driver_mounts; pub mod driver_utils; pub mod dynamic_string_allowlist; pub mod endpoint_path; +pub mod endpoint_status; pub mod error; #[cfg(unix)] pub mod external_driver_socket; @@ -37,6 +38,7 @@ pub mod net; pub mod oauth; pub mod paths; pub mod policy; +pub mod policy_identity; pub mod progress; pub mod proposals; pub mod proto; diff --git a/crates/openshell-core/src/mcp.rs b/crates/openshell-core/src/mcp.rs index 883ca119a..ede63320a 100644 --- a/crates/openshell-core/src/mcp.rs +++ b/crates/openshell-core/src/mcp.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! OpenShell-owned MCP protocol revisions and immutable batch-shape metadata. +//! `OpenShell`-owned MCP protocol revisions and immutable batch-shape metadata. use std::collections::BTreeSet; use std::fmt; @@ -14,6 +14,15 @@ use crate::proto::{McpOptions, ProviderProfile}; /// The bound is distinct from the separately enforced request-body byte limit. pub const MAX_MCP_LEGACY_BATCH_MESSAGES: usize = 64; +/// Return whether a policy protocol name denotes MCP. +/// +/// Protocol names are case-insensitive throughout policy validation and +/// execution, so every MCP-specific projection must use the same predicate. +#[must_use] +pub fn is_mcp_protocol(protocol: &str) -> bool { + protocol.trim().eq_ignore_ascii_case("mcp") +} + /// Stable MCP protocol revisions accepted by `OpenShell` policy configuration. /// /// Variant order is the canonical semantic order used when normalizing policy @@ -46,7 +55,7 @@ pub const DEFAULT_MCP_PROTOCOL_VERSION: McpProtocolVersion = McpProtocolVersion: /// original evidence. MCP-shaped data on non-MCP endpoints is also untouched. pub fn normalize_provider_profile_mcp_fields(profile: &mut ProviderProfile) { for endpoint in &mut profile.endpoints { - if !endpoint.protocol.eq_ignore_ascii_case("mcp") { + if !is_mcp_protocol(&endpoint.protocol) { continue; } diff --git a/crates/openshell-core/src/policy_identity.rs b/crates/openshell-core/src/policy_identity.rs new file mode 100644 index 000000000..738b79682 --- /dev/null +++ b/crates/openshell-core/src/policy_identity.rs @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical policy encoding and deterministic policy hashes. + +use crate::proto::{ + NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy as ProtoSandboxPolicy, +}; +use prost::Message; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; + +#[allow(clippy::cast_possible_truncation)] +const fn canonical_size(value: usize) -> [u8; 8] { + // Every supported Rust pointer width fits in u64, so encoding a collection + // length or index needs no fallible conversion. + (value as u64).to_le_bytes() +} + +fn append_canonical_bytes(out: &mut Vec, value: &[u8]) { + out.extend_from_slice(&canonical_size(value.len())); + out.extend_from_slice(value); +} + +fn append_canonical_message(out: &mut Vec, value: &M) { + append_canonical_bytes(out, &value.encode_to_vec()); +} + +fn append_sorted_message_map( + out: &mut Vec, + label: &[u8], + values: &HashMap, +) { + append_canonical_bytes(out, label); + let mut entries = values.iter().collect::>(); + entries.sort_by_key(|(key, _)| key.as_str()); + out.extend_from_slice(&canonical_size(entries.len())); + for (key, value) in entries { + append_canonical_bytes(out, key.as_bytes()); + append_canonical_message(out, value); + } +} + +/// Encode a policy rule without depending on randomized protobuf map order. +pub fn canonical_rule_bytes(rule: &NetworkPolicyRule) -> Vec { + let mut map_free = rule.clone(); + for endpoint in &mut map_free.endpoints { + endpoint.graphql_persisted_queries.clear(); + for rule in &mut endpoint.rules { + if let Some(allow) = &mut rule.allow { + allow.query.clear(); + allow.params.clear(); + } + } + for deny in &mut endpoint.deny_rules { + deny.query.clear(); + deny.params.clear(); + } + } + + let mut out = Vec::new(); + append_canonical_message(&mut out, &map_free); + for (endpoint_index, endpoint) in rule.endpoints.iter().enumerate() { + out.extend_from_slice(&canonical_size(endpoint_index)); + append_sorted_message_map( + &mut out, + b"graphql_persisted_queries", + &endpoint.graphql_persisted_queries, + ); + for (rule_index, rule) in endpoint.rules.iter().enumerate() { + out.extend_from_slice(&canonical_size(rule_index)); + if let Some(allow) = &rule.allow { + append_sorted_message_map(&mut out, b"allow_query", &allow.query); + append_sorted_message_map(&mut out, b"allow_params", &allow.params); + } + } + for (rule_index, deny) in endpoint.deny_rules.iter().enumerate() { + out.extend_from_slice(&canonical_size(rule_index)); + append_sorted_message_map(&mut out, b"deny_query", &deny.query); + append_sorted_message_map(&mut out, b"deny_params", &deny.params); + } + } + out +} + +fn canonical_struct_bytes(value: &prost_types::Struct) -> Vec { + let mut out = Vec::new(); + let mut fields = value.fields.iter().collect::>(); + fields.sort_by_key(|(key, _)| key.as_str()); + out.extend_from_slice(&canonical_size(fields.len())); + for (key, value) in fields { + append_canonical_bytes(&mut out, key.as_bytes()); + append_canonical_bytes(&mut out, &canonical_value_bytes(value)); + } + out +} + +fn canonical_value_bytes(value: &prost_types::Value) -> Vec { + use prost_types::value::Kind; + + let mut out = Vec::new(); + match &value.kind { + None => out.push(0), + Some(Kind::NullValue(value)) => { + out.push(1); + out.extend_from_slice(&value.to_le_bytes()); + } + Some(Kind::NumberValue(value)) => { + out.push(2); + out.extend_from_slice(&value.to_bits().to_le_bytes()); + } + Some(Kind::StringValue(value)) => { + out.push(3); + append_canonical_bytes(&mut out, value.as_bytes()); + } + Some(Kind::BoolValue(value)) => { + out.push(4); + out.push(u8::from(*value)); + } + Some(Kind::StructValue(value)) => { + out.push(5); + append_canonical_bytes(&mut out, &canonical_struct_bytes(value)); + } + Some(Kind::ListValue(value)) => { + out.push(6); + out.extend_from_slice(&canonical_size(value.values.len())); + for item in &value.values { + append_canonical_bytes(&mut out, &canonical_value_bytes(item)); + } + } + } + out +} + +fn canonical_middleware_bytes(middleware: &NetworkMiddlewareConfig) -> Vec { + let mut map_free = middleware.clone(); + map_free.config = None; + let mut out = Vec::new(); + append_canonical_message(&mut out, &map_free); + if let Some(config) = &middleware.config { + append_canonical_bytes(&mut out, &canonical_struct_bytes(config)); + } + out +} + +fn canonical_policy_bytes(policy: &ProtoSandboxPolicy) -> Vec { + let mut map_free = policy.clone(); + map_free.network_policies.clear(); + map_free.network_middlewares.clear(); + let mut out = Vec::new(); + append_canonical_message(&mut out, &map_free); + + let mut policy_entries = policy.network_policies.iter().collect::>(); + policy_entries.sort_by_key(|(key, _)| key.as_str()); + append_canonical_bytes(&mut out, b"network_policies"); + out.extend_from_slice(&canonical_size(policy_entries.len())); + for (key, rule) in policy_entries { + append_canonical_bytes(&mut out, key.as_bytes()); + append_canonical_bytes(&mut out, &canonical_rule_bytes(rule)); + } + + let mut middleware_entries = policy.network_middlewares.iter().collect::>(); + middleware_entries.sort_by_key(|(key, _)| key.as_str()); + append_canonical_bytes(&mut out, b"network_middlewares"); + out.extend_from_slice(&canonical_size(middleware_entries.len())); + for (key, middleware) in middleware_entries { + append_canonical_bytes(&mut out, key.as_bytes()); + append_canonical_bytes(&mut out, &canonical_middleware_bytes(middleware)); + } + out +} + +/// Compute a deterministic SHA-256 hash of a `SandboxPolicy`, recursively +/// sorting every protobuf map while preserving repeated-field order. +pub fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { + format!("{:x}", Sha256::digest(canonical_policy_bytes(policy))) +} diff --git a/crates/openshell-sandbox/src/endpoint_status.rs b/crates/openshell-sandbox/src/endpoint_status.rs new file mode 100644 index 000000000..c598c94e8 --- /dev/null +++ b/crates/openshell-sandbox/src/endpoint_status.rs @@ -0,0 +1,1194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Report the last observed network result for configured external tool endpoints. +//! +//! Only MCP-over-HTTP traffic currently supplies observations. Reporting is +//! passive, bounded, and independent of policy enforcement and sandbox readiness. + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::Duration; + +use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, EndpointObservationSender, + EndpointStatusCommand, EndpointStatusReceiver, EndpointStatusTracker, endpoint_id, +}; +use openshell_core::mcp::is_mcp_protocol; +use openshell_core::proto::SandboxPolicy; +use tokio::time::timeout; +use tracing::warn; + +use crate::{PolicyGatewayClient, is_retryable_error}; + +/// Deliver complete tool server endpoint snapshots independently from network traffic. +/// +/// The tracker consumes one FIFO and rejects observations from an earlier +/// inventory or supervisor session. Retries retain an immutable snapshot until +/// an inventory reset supersedes it; each frozen snapshot reserves a distinct +/// session sequence so cancellation cannot reuse an ambiguously accepted body. +pub async fn run_reporter( + client: C, + sandbox_id: String, + mut commands: EndpointStatusReceiver, + mut supervisor_session_id: tokio::sync::watch::Receiver>, +) { + const REPORT_TIMEOUT: Duration = Duration::from_secs(10); + const OBSERVATION_COALESCE_WINDOW: Duration = Duration::from_millis(250); + + let mut tracker = commands.tracker(); + // Adopting the current value also acknowledges its watch notification. + // A value queued before startup must not later cancel the initial report. + let mut active_session = supervisor_session_id.borrow_and_update().clone(); + // Startup adopts the latest session, so handles captured before the + // reporter started cannot establish that session's observation freshness. + tracker.clear_observations(); + let mut next_sequence = 1_u64; + let mut dirty = false; + let mut observed_endpoint_ids = BTreeSet::new(); + let mut coalesce_until = None; + let mut commands_open = true; + let mut session_watch_open = true; + + 'outbox: loop { + // Endpoint state can accumulate while disconnected, but it cannot leave + // the sandbox until an authenticated supervisor session is active. + while !dirty || active_session.is_none() { + if !commands_open && !session_watch_open { + return; + } + tokio::select! { + command = commands.recv(), if commands_open => match command { + Some(command) => { + let inventory_reset = matches!( + &command, + EndpointStatusCommand::Reset { .. } + ); + let changed = apply_command( + &mut tracker, + &mut observed_endpoint_ids, + command, + ); + dirty |= changed; + if changed { + coalesce_until = if inventory_reset { + None + } else { + Some(tokio::time::Instant::now() + OBSERVATION_COALESCE_WINDOW) + }; + } + } + None => commands_open = false, + }, + changed = supervisor_session_id.changed(), if session_watch_open => { + if changed.is_err() { + session_watch_open = false; + } else if apply_session_change( + &mut tracker, + &mut observed_endpoint_ids, + &mut active_session, + &mut next_sequence, + supervisor_session_id.borrow_and_update().clone(), + &mut dirty, + ) { + coalesce_until = None; + } + } + } + } + + if let Some(flush_at) = coalesce_until.take() { + loop { + tokio::select! { + () = tokio::time::sleep_until(flush_at) => break, + command = commands.recv(), if commands_open => match command { + Some(command) => { + dirty |= apply_command( + &mut tracker, + &mut observed_endpoint_ids, + command, + ); + } + None => commands_open = false, + }, + changed = supervisor_session_id.changed(), if session_watch_open => { + if changed.is_err() { + session_watch_open = false; + } else if apply_session_change( + &mut tracker, + &mut observed_endpoint_ids, + &mut active_session, + &mut next_sequence, + supervisor_session_id.borrow_and_update().clone(), + &mut dirty, + ) { + continue 'outbox; + } + } + } + } + } + + // Freeze one immutable report after draining commands already queued. + // Observations update the next report, never an in-flight retry. An + // inventory reset retires that report and reserves a higher sequence. + while let Ok(command) = commands.try_recv() { + dirty |= apply_command(&mut tracker, &mut observed_endpoint_ids, command); + } + let Some(session_id) = active_session.clone() else { + continue; + }; + let Some(mut snapshot) = tracker.snapshot() else { + dirty = false; + continue; + }; + snapshot.observed_endpoint_ids = observed_endpoint_ids.iter().cloned().collect(); + snapshot.supervisor_session_id = session_id; + snapshot.report_sequence = next_sequence; + // A cancelled RPC may already have committed. Reserve the sequence + // before sending so a reset cannot reuse it with a different body, + // even when configuration values return to an earlier configuration. + let Some(following_sequence) = next_sequence.checked_add(1) else { + warn!("Endpoint status sequence exhausted for the active session"); + return; + }; + next_sequence = following_sequence; + observed_endpoint_ids.clear(); + dirty = false; + + let mut attempt = 1_u32; + 'retry: loop { + let mut report = Box::pin(timeout( + REPORT_TIMEOUT, + client.report_endpoint_status(&sandbox_id, &snapshot), + )); + let result = loop { + tokio::select! { + result = &mut report => break result, + command = commands.recv(), if commands_open => match command { + Some(command) => { + let inventory_reset = matches!( + &command, + EndpointStatusCommand::Reset { .. } + ); + if inventory_reset { + // A cancelled batch may not have reached the + // gateway. Retain its real observations only + // where the reset preserves their results. + observed_endpoint_ids.extend(snapshot.observed_endpoint_ids.iter().cloned()); + } + let changed = apply_command( + &mut tracker, + &mut observed_endpoint_ids, + command, + ); + dirty |= changed; + // An accepted reset supersedes this installation, + // including same-value reinstalls. Retrying its old + // snapshot could block current observations forever. + if changed && inventory_reset { + continue 'outbox; + } + } + None => commands_open = false, + }, + changed = supervisor_session_id.changed(), if session_watch_open => { + if changed.is_err() { + session_watch_open = false; + } else if apply_session_change( + &mut tracker, + &mut observed_endpoint_ids, + &mut active_session, + &mut next_sequence, + supervisor_session_id.borrow_and_update().clone(), + &mut dirty, + ) { + continue 'outbox; + } + } + } + }; + + match result { + Ok(Ok(())) => continue 'outbox, + Ok(Err(error)) if !is_retryable_error(&error) => { + warn!(%error, "Discarding terminal endpoint status snapshot"); + continue 'outbox; + } + Ok(Err(error)) => { + warn!(%error, attempt, "Endpoint status report failed transiently; retaining immutable snapshot"); + } + Err(error) => { + warn!(%error, attempt, "Endpoint status report timed out; retaining immutable snapshot"); + } + } + + let retry_at = tokio::time::Instant::now() + + Duration::from_secs(1_u64 << attempt.saturating_sub(1).min(5)); + loop { + tokio::select! { + () = tokio::time::sleep_until(retry_at) => { + attempt = attempt.saturating_add(1); + continue 'retry; + } + command = commands.recv(), if commands_open => match command { + Some(command) => { + let inventory_reset = matches!( + &command, + EndpointStatusCommand::Reset { .. } + ); + if inventory_reset { + // A cancelled batch may not have reached the + // gateway. Retain its real observations only + // where the reset preserves their results. + observed_endpoint_ids.extend(snapshot.observed_endpoint_ids.iter().cloned()); + } + let changed = apply_command( + &mut tracker, + &mut observed_endpoint_ids, + command, + ); + dirty |= changed; + // An accepted reset supersedes this installation, + // including same-value reinstalls. Retrying its old + // snapshot could block current observations forever. + if changed && inventory_reset { + continue 'outbox; + } + } + None => commands_open = false, + }, + changed = supervisor_session_id.changed(), if session_watch_open => { + if changed.is_err() { + session_watch_open = false; + } else if apply_session_change( + &mut tracker, + &mut observed_endpoint_ids, + &mut active_session, + &mut next_sequence, + supervisor_session_id.borrow_and_update().clone(), + &mut dirty, + ) { + continue 'outbox; + } + } + } + } + } + } +} + +fn apply_session_change( + tracker: &mut EndpointStatusTracker, + observed_endpoint_ids: &mut BTreeSet, + active_session: &mut Option, + next_sequence: &mut u64, + replacement: Option, + dirty: &mut bool, +) -> bool { + if *active_session == replacement { + // Watch notifications can repeat the same session. Only a different + // authority may cancel delivery or reset the report sequence. + return false; + } + *active_session = replacement; + *next_sequence = 1; + observed_endpoint_ids.clear(); + // Results belong to the session that observed them. Clearing on both + // disconnect and replacement prevents a new stream from replaying them. + *dirty |= tracker.clear_observations(); + true +} + +fn apply_command( + tracker: &mut EndpointStatusTracker, + observed_endpoint_ids: &mut BTreeSet, + command: EndpointStatusCommand, +) -> bool { + let retained_endpoint_ids = match &command { + EndpointStatusCommand::Reset { + config_version, + endpoints, + .. + } => { + let previous = tracker.snapshot(); + // Match the tracker's reset rule: provider changes retain evidence + // only for endpoints without provider credentials. Policy changes + // and endpoint removal retire the corresponding observations. + Some( + endpoints + .iter() + .filter(|endpoint| { + previous.as_ref().is_some_and(|snapshot| { + snapshot.config_version.policy_hash == config_version.policy_hash + && (snapshot.config_version.provider_env_revision + == config_version.provider_env_revision + || !endpoint.uses_provider_credentials) + }) + }) + .map(|endpoint| endpoint.endpoint_id.clone()) + .collect::>(), + ) + } + EndpointStatusCommand::Observe { .. } => None, + }; + let observed_endpoint_id = match &command { + EndpointStatusCommand::Observe { endpoint_id, .. } => Some(endpoint_id.clone()), + EndpointStatusCommand::Reset { .. } => None, + }; + if !tracker.apply(command) { + return false; + } + if let Some(retained_endpoint_ids) = retained_endpoint_ids { + // Surviving results still need their pending observation markers if + // an earlier report was superseded before its acknowledgement arrived. + observed_endpoint_ids.retain(|endpoint_id| retained_endpoint_ids.contains(endpoint_id)); + } else if let Some(endpoint_id) = observed_endpoint_id { + observed_endpoint_ids.insert(endpoint_id); + } + true +} + +/// Collect the MCP endpoints whose HTTP exchanges provide status observations. +pub fn mcp_endpoint_inventory(policy: &SandboxPolicy) -> Vec { + let mut endpoints = BTreeMap::new(); + for endpoint in policy + .network_policies + .values() + .flat_map(|rule| &rule.endpoints) + .filter(|endpoint| is_mcp_protocol(&endpoint.protocol)) + { + endpoints + .entry(endpoint_id(endpoint)) + .and_modify(|credentialed| *credentialed |= endpoint.provider_credentialed) + .or_insert(endpoint.provider_credentialed); + } + endpoints + .into_iter() + .map( + |(endpoint_id, uses_provider_credentials)| EndpointInventoryEntry { + endpoint_id, + uses_provider_credentials, + }, + ) + .collect() +} + +/// Reset observations after the policy and provider environment are installed. +pub async fn reset( + sender: Option<&EndpointObservationSender>, + policy: Option<&SandboxPolicy>, + policy_hash: &str, + provider_env_revision: u64, +) { + let (Some(sender), Some(policy)) = (sender, policy) else { + // Local policy overrides and disabled networking have no gateway + // endpoint inventory to report against. + return; + }; + if policy_hash.is_empty() { + // Until a gateway policy is installed, observations cannot be bound + // to an authoritative configuration version. + return; + } + let config_version = EndpointConfigVersion { + policy_hash: policy_hash.to_string(), + provider_env_revision, + }; + if let Err(error) = sender + .reset(config_version, mcp_endpoint_inventory(policy)) + .await + { + warn!(%error, "Endpoint status tracker unavailable during policy installation"); + } +} + +#[cfg(test)] +#[allow( + clippy::similar_names, + reason = "Test fixtures compare successive report identities." +)] +mod tests { + use super::*; + use miette::Result; + use openshell_core::endpoint_status::{ + EndpointResult, EndpointStatusSnapshot, endpoint_status_channel, + }; + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::sync::mpsc::UnboundedSender; + + #[derive(Clone)] + struct EndpointStatusGateway { + reports: UnboundedSender, + } + + #[tonic::async_trait] + impl PolicyGatewayClient for EndpointStatusGateway { + async fn poll_settings( + &self, + _sandbox_id: &str, + ) -> Result { + Err(miette::miette!( + "endpoint-status test does not poll settings" + )) + } + + async fn report_policy_status( + &self, + _sandbox_id: &str, + _version: u32, + _loaded: bool, + _error: &str, + ) -> Result<()> { + Ok(()) + } + + async fn report_endpoint_status( + &self, + _sandbox_id: &str, + snapshot: &EndpointStatusSnapshot, + ) -> Result<()> { + self.reports + .send(snapshot.clone()) + .map_err(|_| miette::miette!("endpoint-status report receiver closed")) + } + + fn workspace(&self) -> String { + String::new() + } + } + + #[derive(Clone)] + struct RetryEndpointStatusGateway { + attempts: Arc, + reports: UnboundedSender, + } + + #[tonic::async_trait] + impl PolicyGatewayClient for RetryEndpointStatusGateway { + async fn poll_settings( + &self, + _sandbox_id: &str, + ) -> Result { + Err(miette::miette!( + "endpoint-status test does not poll settings" + )) + } + + async fn report_policy_status( + &self, + _sandbox_id: &str, + _version: u32, + _loaded: bool, + _error: &str, + ) -> Result<()> { + Ok(()) + } + + async fn report_endpoint_status( + &self, + _sandbox_id: &str, + snapshot: &EndpointStatusSnapshot, + ) -> Result<()> { + self.reports + .send(snapshot.clone()) + .map_err(|_| miette::miette!("endpoint-status report receiver closed"))?; + if self.attempts.fetch_add(1, Ordering::SeqCst) == 0 { + Err(miette::miette!("simulated connection loss after commit")) + } else { + Ok(()) + } + } + + fn workspace(&self) -> String { + String::new() + } + } + + #[tokio::test] + async fn reporter_coalesces_observation_bursts() { + let (sender, receiver) = endpoint_status_channel(); + let (reports_tx, mut reports_rx) = tokio::sync::mpsc::unbounded_channel(); + let (session_tx, session_rx) = + tokio::sync::watch::channel(Some("session-test".to_string())); + let reporter = tokio::spawn(run_reporter( + EndpointStatusGateway { + reports: reports_tx, + }, + "sandbox-test".to_string(), + receiver, + session_rx, + )); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy-a".to_string(), + provider_env_revision: 1, + }, + vec![ + EndpointInventoryEntry { + endpoint_id: "endpoint:public".to_string(), + uses_provider_credentials: false, + }, + EndpointInventoryEntry { + endpoint_id: "endpoint:unused".to_string(), + uses_provider_credentials: false, + }, + ], + ) + .await + .expect("reporter remains active"); + let initial = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("initial inventory report timed out") + .expect("reporter remains active"); + assert_eq!( + initial.endpoints[0].result, + EndpointResult::NoObservedExchange + ); + assert!(initial.observed_endpoint_ids.is_empty()); + assert_eq!(initial.supervisor_session_id, "session-test"); + assert_eq!(initial.report_sequence, 1); + + for result in [ + EndpointResult::HttpResponseReceived, + EndpointResult::PolicyDenied, + EndpointResult::HttpResponseReceived, + ] { + let observation = sender + .begin("endpoint:public".to_string()) + .expect("reset published a configuration version"); + assert!(sender.try_observe(observation, result)); + } + + let coalesced = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("coalesced observation report timed out") + .expect("reporter remains active"); + assert_eq!( + coalesced + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == "endpoint:public") + .expect("public endpoint remains in the full snapshot") + .result, + EndpointResult::HttpResponseReceived + ); + assert_eq!( + coalesced.observed_endpoint_ids, + vec!["endpoint:public".to_string()] + ); + assert_eq!(coalesced.report_sequence, 2); + assert!( + timeout(Duration::from_millis(100), reports_rx.recv()) + .await + .is_err(), + "one observation burst must produce one control-plane write" + ); + + let stale_observation = sender + .begin("endpoint:unused".to_string()) + .expect("reset published a configuration version"); + session_tx.send_replace(Some("session-replacement".to_string())); + let replacement = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("replacement-session report timed out") + .expect("reporter remains active"); + assert_eq!(replacement.supervisor_session_id, "session-replacement"); + assert_eq!(replacement.report_sequence, 1); + assert!(replacement.observed_endpoint_ids.is_empty()); + assert!( + replacement + .endpoints + .iter() + .all(|endpoint| endpoint.result == EndpointResult::NoObservedExchange) + ); + + assert!(sender.try_observe(stale_observation, EndpointResult::HttpResponseReceived)); + // Wait beyond the coalescing window: a stale exchange must not produce + // a report that advances the replacement session's observation times. + assert!( + timeout(Duration::from_millis(500), reports_rx.recv()) + .await + .is_err(), + "an exchange started in the previous session must not be reported" + ); + + let fresh_observation = sender + .begin("endpoint:public".to_string()) + .expect("the replacement session retains the inventory"); + assert!(sender.try_observe(fresh_observation, EndpointResult::PolicyDenied)); + let fresh = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("fresh replacement-session observation timed out") + .expect("reporter remains active"); + assert_eq!(fresh.supervisor_session_id, "session-replacement"); + assert_eq!(fresh.report_sequence, 2); + assert_eq!( + fresh.observed_endpoint_ids, + vec!["endpoint:public".to_string()] + ); + assert_eq!(fresh.endpoints[0].endpoint_id, "endpoint:public"); + assert_eq!(fresh.endpoints[0].result, EndpointResult::PolicyDenied); + assert_eq!(fresh.endpoints[1].endpoint_id, "endpoint:unused"); + assert_eq!( + fresh.endpoints[1].result, + EndpointResult::NoObservedExchange + ); + reporter.abort(); + } + + #[tokio::test] + async fn reporter_invalidates_handles_queued_before_startup() { + let (sender, receiver) = endpoint_status_channel(); + let (reports_tx, mut reports_rx) = tokio::sync::mpsc::unbounded_channel(); + let (session_tx, session_rx) = tokio::sync::watch::channel(Some("session-a".to_string())); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy-a".to_string(), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: "endpoint:public".to_string(), + uses_provider_credentials: false, + }], + ) + .await + .expect("the receiver remains active"); + let stale_observation = sender + .begin("endpoint:public".to_string()) + .expect("the queued reset published a configuration version"); + session_tx.send_replace(Some("session-b".to_string())); + assert!(sender.try_observe(stale_observation, EndpointResult::HttpResponseReceived)); + + // Both commands precede reporter startup. Adopting the latest watch + // value must invalidate the earlier handle before draining the FIFO. + let reporter = tokio::spawn(run_reporter( + EndpointStatusGateway { + reports: reports_tx, + }, + "sandbox-test".to_string(), + receiver, + session_rx, + )); + let initial = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("initial inventory report timed out") + .expect("reporter remains active"); + assert_eq!(initial.supervisor_session_id, "session-b"); + assert_eq!(initial.report_sequence, 1); + assert_eq!( + initial.endpoints[0].result, + EndpointResult::NoObservedExchange + ); + assert!(initial.observed_endpoint_ids.is_empty()); + + let fresh_observation = sender + .begin("endpoint:public".to_string()) + .expect("the reporter published its observation authority"); + assert!(sender.try_observe(fresh_observation, EndpointResult::PolicyDenied)); + let fresh = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("fresh observation report timed out") + .expect("reporter remains active"); + assert_eq!(fresh.supervisor_session_id, "session-b"); + assert_eq!(fresh.report_sequence, 2); + assert_eq!(fresh.endpoints[0].result, EndpointResult::PolicyDenied); + assert_eq!( + fresh.observed_endpoint_ids, + vec!["endpoint:public".to_string()] + ); + reporter.abort(); + } + + #[tokio::test] + async fn session_change_invalidates_handles_before_pending_reset() { + let (sender, mut commands) = endpoint_status_channel(); + let mut tracker = commands.tracker(); + let config_version = EndpointConfigVersion { + policy_hash: "policy-a".to_string(), + provider_env_revision: 1, + }; + sender + .reset( + config_version.clone(), + vec![EndpointInventoryEntry { + endpoint_id: "endpoint:public".to_string(), + uses_provider_credentials: false, + }], + ) + .await + .expect("tracker remains active"); + let stale_observation = sender + .begin("endpoint:public".to_string()) + .expect("the queued reset published a configuration version"); + + let mut observed_endpoint_ids = BTreeSet::new(); + let mut active_session = Some("session-test".to_string()); + let mut next_sequence = 9; + let mut dirty = false; + // A session can change before the first inventory is consumed. Its + // pending reset must not restore the previous observation authority. + apply_session_change( + &mut tracker, + &mut observed_endpoint_ids, + &mut active_session, + &mut next_sequence, + Some("session-replacement".to_string()), + &mut dirty, + ); + assert_eq!(active_session.as_deref(), Some("session-replacement")); + assert_eq!(next_sequence, 1); + assert!(!dirty); + assert!(tracker.snapshot().is_none()); + + let fresh_observation = sender + .begin("endpoint:public".to_string()) + .expect("new requests capture the replacement authority"); + assert!(apply_command( + &mut tracker, + &mut observed_endpoint_ids, + commands.try_recv().expect("the first reset is pending"), + )); + + assert!(sender.try_observe(stale_observation, EndpointResult::HttpResponseReceived)); + assert!(!apply_command( + &mut tracker, + &mut observed_endpoint_ids, + commands + .try_recv() + .expect("the stale observation is queued"), + )); + assert!(observed_endpoint_ids.is_empty()); + assert_eq!( + tracker + .snapshot() + .expect("the inventory is installed") + .endpoints[0] + .result, + EndpointResult::NoObservedExchange + ); + + assert!(sender.try_observe(fresh_observation, EndpointResult::PolicyDenied)); + assert!(apply_command( + &mut tracker, + &mut observed_endpoint_ids, + commands + .try_recv() + .expect("the fresh observation is queued"), + )); + assert_eq!( + observed_endpoint_ids, + BTreeSet::from(["endpoint:public".to_string()]) + ); + assert_eq!( + tracker + .snapshot() + .expect("the inventory is installed") + .endpoints[0] + .result, + EndpointResult::PolicyDenied + ); + } + + #[tokio::test] + async fn reporter_retries_an_immutable_sequence() { + let (sender, receiver) = endpoint_status_channel(); + let (reports_tx, mut reports_rx) = tokio::sync::mpsc::unbounded_channel(); + let (_session_tx, session_rx) = + tokio::sync::watch::channel(Some("session-test".to_string())); + let reporter = tokio::spawn(run_reporter( + RetryEndpointStatusGateway { + attempts: Arc::new(AtomicUsize::new(0)), + reports: reports_tx, + }, + "sandbox-test".to_string(), + receiver, + session_rx, + )); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy-a".to_string(), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: "endpoint:public".to_string(), + uses_provider_credentials: false, + }], + ) + .await + .expect("reporter remains active"); + let first_attempt = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("first report timed out") + .expect("reporter remains active"); + let observation = sender + .begin("endpoint:public".to_string()) + .expect("reset published a configuration version"); + assert!(sender.try_observe(observation, EndpointResult::HttpResponseReceived)); + + let retry = timeout(Duration::from_secs(2), reports_rx.recv()) + .await + .expect("retry timed out") + .expect("reporter remains active"); + assert_eq!(retry, first_attempt, "retry must preserve batch identity"); + + let next = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("deferred observation report timed out") + .expect("reporter remains active"); + assert_eq!(next.report_sequence, 2); + assert_eq!( + next.observed_endpoint_ids, + vec!["endpoint:public".to_string()] + ); + assert_eq!( + next.endpoints[0].result, + EndpointResult::HttpResponseReceived + ); + reporter.abort(); + } + + struct ControlledEndpointStatusAttempt { + snapshot: EndpointStatusSnapshot, + complete: tokio::sync::oneshot::Sender>, + } + + #[derive(Clone)] + struct ControlledEndpointStatusGateway { + reports: UnboundedSender, + } + + #[tonic::async_trait] + impl PolicyGatewayClient for ControlledEndpointStatusGateway { + async fn poll_settings( + &self, + _sandbox_id: &str, + ) -> Result { + Err(miette::miette!( + "endpoint-status test does not poll settings" + )) + } + + async fn report_policy_status( + &self, + _sandbox_id: &str, + _version: u32, + _loaded: bool, + _error: &str, + ) -> Result<()> { + Ok(()) + } + + async fn report_endpoint_status( + &self, + _sandbox_id: &str, + snapshot: &EndpointStatusSnapshot, + ) -> Result<()> { + let (complete, result) = tokio::sync::oneshot::channel(); + self.reports + .send(ControlledEndpointStatusAttempt { + snapshot: snapshot.clone(), + complete, + }) + .map_err(|_| miette::miette!("endpoint-status report receiver closed"))?; + result + .await + .map_err(|_| miette::miette!("endpoint-status response cancelled"))? + } + + fn workspace(&self) -> String { + String::new() + } + } + + async fn assert_reset_supersedes_pending_snapshot(during_backoff: bool) { + let (sender, receiver) = endpoint_status_channel(); + let (reports_tx, mut reports_rx) = tokio::sync::mpsc::unbounded_channel(); + let (_session_tx, session_rx) = + tokio::sync::watch::channel(Some("session-test".to_string())); + let reporter = tokio::spawn(run_reporter( + ControlledEndpointStatusGateway { + reports: reports_tx, + }, + "sandbox-test".to_string(), + receiver, + session_rx, + )); + let initial_config_version = EndpointConfigVersion { + policy_hash: "policy-a".to_string(), + provider_env_revision: 1, + }; + let inventory = vec![ + EndpointInventoryEntry { + endpoint_id: "endpoint:public".to_string(), + uses_provider_credentials: false, + }, + EndpointInventoryEntry { + endpoint_id: "endpoint:secure".to_string(), + uses_provider_credentials: true, + }, + ]; + sender + .reset(initial_config_version.clone(), inventory.clone()) + .await + .expect("reporter remains active"); + let first = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("initial report timed out") + .expect("reporter remains active"); + assert_eq!(first.snapshot.report_sequence, 1); + first + .complete + .send(Ok(())) + .expect("initial call remains active"); + for endpoint_id in ["endpoint:public", "endpoint:secure"] { + let observation = sender + .begin(endpoint_id.to_string()) + .expect("inventory installed"); + assert!(sender.try_observe(observation, EndpointResult::HttpResponseReceived)); + } + let pending = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("observation report timed out") + .expect("reporter remains active"); + assert_eq!(pending.snapshot.report_sequence, 2); + assert_eq!( + pending.snapshot.observed_endpoint_ids, + vec!["endpoint:public", "endpoint:secure"] + ); + let mut response = Some(pending.complete); + + // Exercise provider replacement, return to the original values, + // same-value reinstallation, and a policy change while unresolved + // public observations must either survive or be invalidated together + // with the results they justify. + for (offset, config_version) in [ + EndpointConfigVersion { + provider_env_revision: 2, + ..initial_config_version.clone() + }, + initial_config_version.clone(), + initial_config_version, + EndpointConfigVersion { + policy_hash: "policy-b".to_string(), + provider_env_revision: 1, + }, + ] + .into_iter() + .enumerate() + { + let stale = sender + .begin("endpoint:secure".to_string()) + .expect("inventory remains installed"); + if during_backoff { + response + .take() + .expect("pending response") + .send(Err(miette::miette!( + "simulated transient configuration mismatch" + ))) + .expect("pending call remains active"); + // On the current-thread test runtime this lets the reporter + // consume the response and enter its one-second retry backoff. + tokio::task::yield_now().await; + } + sender + .reset(config_version.clone(), inventory.clone()) + .await + .expect("reporter remains active"); + let fresh = sender + .begin("endpoint:secure".to_string()) + .expect("replacement inventory installed"); + assert!(sender.try_observe(fresh, EndpointResult::CredentialUnavailable)); + assert!(sender.try_observe(stale, EndpointResult::HttpResponseReceived)); + + let next = timeout(Duration::from_millis(500), reports_rx.recv()) + .await + .expect("reset must bypass the obsolete call or backoff") + .expect("reporter remains active"); + assert_eq!(next.snapshot.config_version, config_version); + assert_eq!( + next.snapshot.report_sequence, + u64::try_from(offset).expect("small test offset") + 3 + ); + assert_eq!(next.snapshot.endpoints.len(), 2); + assert_eq!(next.snapshot.endpoints[0].endpoint_id, "endpoint:public"); + assert_eq!(next.snapshot.endpoints[1].endpoint_id, "endpoint:secure"); + assert_eq!( + next.snapshot.endpoints[1].result, + EndpointResult::CredentialUnavailable + ); + if config_version.policy_hash == "policy-a" { + assert_eq!( + next.snapshot.observed_endpoint_ids, + vec!["endpoint:public", "endpoint:secure"] + ); + assert_eq!( + next.snapshot.endpoints[0].result, + EndpointResult::HttpResponseReceived + ); + } else { + assert_eq!(next.snapshot.observed_endpoint_ids, vec!["endpoint:secure"]); + assert_eq!( + next.snapshot.endpoints[0].result, + EndpointResult::NoObservedExchange + ); + } + if let Some(previous_response) = response.take() { + assert!( + previous_response.send(Ok(())).is_err(), + "reset must cancel the obsolete RPC future" + ); + } + response = Some(next.complete); + } + response + .expect("final pending response") + .send(Ok(())) + .expect("current report remains active"); + reporter.abort(); + } + + #[tokio::test] + async fn reporter_supersedes_in_flight_snapshot_on_inventory_reset() { + assert_reset_supersedes_pending_snapshot(false).await; + } + + #[tokio::test] + async fn reporter_supersedes_backoff_snapshot_on_inventory_reset() { + assert_reset_supersedes_pending_snapshot(true).await; + } + + #[tokio::test] + async fn reporter_retains_delivery_on_same_session_notifications() { + let (sender, receiver) = endpoint_status_channel(); + let (reports_tx, mut reports_rx) = tokio::sync::mpsc::unbounded_channel(); + let (session_tx, session_rx) = + tokio::sync::watch::channel(Some("session-test".to_string())); + let reporter = tokio::spawn(run_reporter( + ControlledEndpointStatusGateway { + reports: reports_tx, + }, + "sandbox-test".to_string(), + receiver, + session_rx, + )); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy-a".to_string(), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: "endpoint:public".to_string(), + uses_provider_credentials: false, + }], + ) + .await + .expect("reporter remains active"); + let first = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("initial report timed out") + .expect("reporter remains active"); + + // Repeat the current value while the RPC is pending. The watch emits + // a notification, but there is no new authority to cancel this batch. + session_tx.send_replace(Some("session-test".to_string())); + tokio::task::yield_now().await; + assert!( + !first.complete.is_closed(), + "same-session notification must not cancel the pending call" + ); + first + .complete + .send(Err(miette::miette!( + "simulated connection loss after commit" + ))) + .expect("pending report remains active"); + tokio::task::yield_now().await; + + // Repeat during backoff with newer evidence already queued. Delivery + // must still retry the identical body before publishing that evidence. + let observation = sender + .begin("endpoint:public".to_string()) + .expect("inventory remains installed"); + assert!(sender.try_observe(observation, EndpointResult::HttpResponseReceived)); + session_tx.send_replace(Some("session-test".to_string())); + let retry = timeout(Duration::from_secs(2), reports_rx.recv()) + .await + .expect("unchanged-session retry timed out") + .expect("reporter remains active"); + assert_eq!( + retry.snapshot, first.snapshot, + "a repeated session value must preserve retry identity" + ); + retry.complete.send(Ok(())).expect("retry remains active"); + + let observed = timeout(Duration::from_secs(1), reports_rx.recv()) + .await + .expect("queued observation timed out") + .expect("reporter remains active"); + assert_eq!( + observed.snapshot.report_sequence, + first.snapshot.report_sequence + 1 + ); + assert_eq!( + observed.snapshot.observed_endpoint_ids, + vec!["endpoint:public"] + ); + assert_eq!( + observed.snapshot.endpoints[0].result, + EndpointResult::HttpResponseReceived + ); + observed + .complete + .send(Ok(())) + .expect("observation remains active"); + reporter.abort(); + } + + #[test] + fn mcp_endpoint_inventory_is_deduplicated_and_protocol_scoped() { + let endpoint = NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + protocol: "MCP".to_string(), + ..Default::default() + }; + let policy = SandboxPolicy { + network_policies: std::collections::HashMap::from([( + "tool-servers".to_string(), + NetworkPolicyRule { + endpoints: vec![ + endpoint.clone(), + NetworkEndpoint { + host: "API.EXAMPLE.COM".to_string(), + protocol: "mcp".to_string(), + provider_credentialed: true, + ..endpoint.clone() + }, + NetworkEndpoint { + host: "ordinary-http.example.com".to_string(), + protocol: "rest".to_string(), + ..endpoint.clone() + }, + ], + ..Default::default() + }, + )]), + ..Default::default() + }; + + assert_eq!( + mcp_endpoint_inventory(&policy), + vec![EndpointInventoryEntry { + endpoint_id: endpoint_id(&endpoint), + uses_provider_credentials: true, + }] + ); + } +} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index b5b9358ac..40e31538e 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -17,6 +17,7 @@ compile_error!( mod activity_aggregator; mod denial_aggregator; +mod endpoint_status; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod google_cloud_metadata; mod mechanistic_mapper; @@ -69,6 +70,10 @@ use openshell_ocsf::{ pub(crate) use openshell_ocsf::ctx::ctx as ocsf_ctx; use openshell_core::denial::DenialEvent; +use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointObservationSender, EndpointStatusReceiver, + EndpointStatusSnapshot, endpoint_status_channel, +}; use openshell_core::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPolicy}; use openshell_core::proposals::AgentProposals; use openshell_core::provider_credentials::ProviderCredentialState; @@ -78,7 +83,7 @@ use openshell_supervisor_process::process::ProcessEnforcementMode; pub use openshell_supervisor_process::process::{ProcessHandle, ProcessStatus}; use openshell_supervisor_process::skills; use tokio::sync::mpsc::UnboundedSender; -#[cfg(any(test, target_os = "linux"))] +#[cfg(any(target_os = "linux", test))] use tokio::time::timeout; const SIDECAR_NETWORK_ENFORCEMENT_MODE: &str = "sidecar-nftables"; @@ -522,6 +527,46 @@ pub async fn run_sandbox( #[cfg(not(target_os = "linux"))] drop(bypass_activity_tx); + // Endpoint observations use a bounded channel and never add backpressure + // to proxied traffic. Queue the reset only after the corresponding policy + // and provider state have both been installed during startup. + let (endpoint_observation_tx, endpoint_status_rx) = if sandbox_id.is_some() && network_enabled { + let (sender, receiver) = endpoint_status_channel(); + (Some(sender), Some(receiver)) + } else { + (None, None) + }; + // Endpoint observations are authorized by the currently accepted supervisor + // stream, not merely by the sandbox's long-lived bearer credential. + let (supervisor_session_updates, supervisor_session_id) = + tokio::sync::watch::channel::>(None); + if let ( + Some(sender), + Some(proto), + LoadedPolicyOrigin::Gateway { + revision: Some(revision), + .. + }, + ) = ( + endpoint_observation_tx.as_ref(), + retained_proto.as_ref(), + &loaded_policy_origin, + ) { + let config_version = EndpointConfigVersion { + policy_hash: revision.policy_hash.clone(), + provider_env_revision: provider_credentials.snapshot().revision, + }; + if let Err(error) = sender + .reset( + config_version, + endpoint_status::mcp_endpoint_inventory(proto), + ) + .await + { + warn!(%error, "Endpoint status tracker unavailable during startup"); + } + } + // Workspace watch: the policy poll loop learns the workspace from // GetSandboxConfig and broadcasts it. Flush tasks and the policy.local // API read the current value so proposals target the correct workspace. @@ -549,6 +594,7 @@ pub async fn run_sandbox( openshell_endpoint_for_proxy.as_deref(), denial_tx, activity_tx, + endpoint_observation_tx.clone(), agent_proposals.clone(), workspace_rx.clone(), &upstream_proxy_args, @@ -629,6 +675,7 @@ pub async fn run_sandbox( sandbox_id: sandbox_id.clone(), trusted_ssh_socket_path: std::path::PathBuf::from(trusted_ssh_socket_path), control_publisher: sidecar_control_publisher.clone(), + supervisor_session_updates: supervisor_session_updates.clone(), }, ); } @@ -747,6 +794,10 @@ pub async fn run_sandbox( let poll_pid = entrypoint_pid.clone(); let poll_provider_credentials = provider_credentials.clone(); let poll_policy_local = networking.as_ref().map(|n| n.policy_local_ctx.clone()); + let poll_endpoint_policy = loaded_policy_origin + .allows_gateway_policy_reload() + .then(|| retained_proto.clone()) + .flatten(); let poll_interval_secs: u64 = std::env::var("OPENSHELL_POLICY_POLL_INTERVAL_SECS") .ok() .and_then(|v| v.parse().ok()) @@ -773,6 +824,10 @@ pub async fn run_sandbox( capable: transparent_tcp_capable, substrate_ready: transparent_tcp_substrate_ready, }, + endpoint_observation_tx, + endpoint_status_rx, + endpoint_policy: poll_endpoint_policy, + supervisor_session_id, }; tokio::spawn(async move { @@ -968,6 +1023,7 @@ pub async fn run_sandbox( entrypoint_pid, entrypoint_started_tx, sidecar_exit_tx, + Some(supervisor_session_updates), provider_credentials, main_env, ca_file_paths, @@ -1327,6 +1383,7 @@ struct SidecarEntrypointHandler { sandbox_id: Option, trusted_ssh_socket_path: std::path::PathBuf, control_publisher: Option, + supervisor_session_updates: tokio::sync::watch::Sender>, } #[cfg(target_os = "linux")] @@ -1343,6 +1400,7 @@ fn spawn_sidecar_entrypoint_handler( sandbox_id, trusted_ssh_socket_path, control_publisher, + supervisor_session_updates, } = handler; let mut session_started = false; let mut session_task: Option> = None; @@ -1453,7 +1511,10 @@ fn spawn_sidecar_entrypoint_handler( None, Some(supervisor_pid), Arc::clone(&terminating), - started.instance_id.clone(), + openshell_supervisor_process::supervisor_session::SessionRuntimeContext { + instance_id: started.instance_id.clone(), + session_id_updates: Some(supervisor_session_updates.clone()), + }, )); session_started = true; info!("sidecar supervisor session task spawned"); @@ -3139,6 +3200,14 @@ trait PolicyGatewayClient: Clone + Send + Sync + 'static { error: &str, ) -> Result<()>; + async fn report_endpoint_status( + &self, + _sandbox_id: &str, + _snapshot: &EndpointStatusSnapshot, + ) -> Result<()> { + Ok(()) + } + async fn refresh_installed_extension_credentials(&self) -> Result<()> { Ok(()) } @@ -3173,6 +3242,14 @@ impl PolicyGatewayClient for openshell_core::grpc_client::CachedOpenShellClient .await } + async fn report_endpoint_status( + &self, + sandbox_id: &str, + snapshot: &EndpointStatusSnapshot, + ) -> Result<()> { + self.report_endpoint_status(sandbox_id, snapshot).await + } + async fn refresh_installed_extension_credentials(&self) -> Result<()> { self.refresh_installed_extension_credentials().await } @@ -3352,6 +3429,14 @@ struct PolicyPollLoopContext { middleware_connector: MiddlewareConnector, /// Immutable driver capability and startup substrate state. transparent_tcp: TransparentTcpReloadState, + /// Producer shared with network enforcement and policy installation. + endpoint_observation_tx: Option, + /// Single FIFO consumed by the endpoint status reporter. + endpoint_status_rx: Option, + /// Gateway policy currently installed in OPA, absent for local overrides. + endpoint_policy: Option, + /// Session accepted by `ConnectSupervisor`; `None` suspends endpoint reports. + supervisor_session_id: tokio::sync::watch::Receiver>, } type MiddlewareConnector = Arc< @@ -3740,7 +3825,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } async fn run_policy_poll_loop_with_client( - ctx: PolicyPollLoopContext, + mut ctx: PolicyPollLoopContext, client: C, ) -> Result<()> { use openshell_core::proto::PolicySource; @@ -3752,6 +3837,14 @@ async fn run_policy_poll_loop_with_client( ctx.sandbox_id.clone(), status_receiver, )); + if let Some(endpoint_status_receiver) = ctx.endpoint_status_rx.take() { + tokio::spawn(endpoint_status::run_reporter( + client.clone(), + ctx.sandbox_id.clone(), + endpoint_status_receiver, + ctx.supervisor_session_id.clone(), + )); + } let mut current_config_revision: u64 = 0; let mut current_provider_env_revision: u64 = ctx.provider_credentials.snapshot().revision; @@ -3768,6 +3861,7 @@ async fn run_policy_poll_loop_with_client( let mut last_failed_runtime_revision: Option = None; let mut rejected_policy_generation: Option = None; let mut has_last_valid_policy = ctx.loaded_policy_origin.has_last_valid_policy(); + let mut current_endpoint_policy = ctx.endpoint_policy.clone(); // A first poll that does not match the policy already loaded into OPA must // pass through the normal reconciliation path immediately. It must never @@ -3795,6 +3889,14 @@ async fn run_policy_poll_loop_with_client( current_config_revision = candidate.config_revision; current_policy_version = candidate.version; current_policy_hash.clone_from(&candidate.policy_hash); + current_endpoint_policy.clone_from(&result.policy); + endpoint_status::reset( + ctx.endpoint_observation_tx.as_ref(), + current_endpoint_policy.as_ref(), + ¤t_policy_hash, + ctx.provider_credentials.snapshot().revision, + ) + .await; current_middleware_services = result.supervisor_middleware_services; current_extension_authentication_enabled = result.extension_authentication_enabled; @@ -3924,6 +4026,7 @@ async fn run_policy_poll_loop_with_client( &result, ); let mut policy_runtime_reconciled = false; + let mut provider_runtime_changed = false; // A local policy override is not coupled to the gateway policy // snapshot, so its service registry can still be reconciled alone. @@ -4019,6 +4122,7 @@ async fn run_policy_poll_loop_with_client( env_result.static_credential_bindings, env_result.non_secret_environment_keys, ); + provider_runtime_changed = true; if let Err(error) = install_result { ocsf_emit!( ConfigStateChangeBuilder::new(ocsf_ctx()) @@ -4057,6 +4161,7 @@ async fn run_policy_poll_loop_with_client( Err(e) => { ctx.provider_credentials .revoke_static_provider_environment(result.provider_env_revision); + provider_runtime_changed = true; warn!( error = %e, provider_env_revision = result.provider_env_revision, @@ -4189,6 +4294,7 @@ async fn run_policy_poll_loop_with_client( } current_policy_hash.clone_from(&result.policy_hash); + current_endpoint_policy.clone_from(&result.policy); current_middleware_services.clone_from(&result.supervisor_middleware_services); current_extension_authentication_enabled = result.extension_authentication_enabled; @@ -4298,6 +4404,16 @@ async fn run_policy_poll_loop_with_client( current_policy_version = version; } + if policy_runtime_reconciled || provider_runtime_changed { + endpoint_status::reset( + ctx.endpoint_observation_tx.as_ref(), + current_endpoint_policy.as_ref(), + ¤t_policy_hash, + ctx.provider_credentials.snapshot().revision, + ) + .await; + } + // Apply OCSF JSON toggle from the `ocsf_json_enabled` setting. apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings); @@ -5095,6 +5211,10 @@ network_policies: extension_authentication_enabled: false, middleware_connector, transparent_tcp: TransparentTcpReloadState::default(), + endpoint_observation_tx: None, + endpoint_status_rx: None, + endpoint_policy: None, + supervisor_session_id: tokio::sync::watch::channel(None).1, } } diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 549f715d9..6202f0bc2 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -157,6 +157,13 @@ fn workload_template_proto(name: &str, workspace: &str) -> proto::SandboxWorkloa #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(proto::ReportEndpointStatusResponse {})) + } + async fn begin_rootfs_tar_staging( &self, _request: tonic::Request, diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 505c37d50..1df7bb582 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -65,6 +65,7 @@ clap = { workspace = true } miette = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } # Logging tracing = { workspace = true } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 70ffb5fe5..fb44a4984 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -4253,6 +4253,7 @@ fn public_status_from_driver( current_policy_version, main_process_instance_id: String::new(), exit_code: None, + endpoint_statuses: Vec::new(), } } @@ -4262,6 +4263,13 @@ fn apply_driver_snapshot( session_connected: bool, driver_reports_runtime_readiness: bool, ) { + // Endpoint results belong to the gateway. A driver reports infrastructure + // and runtime state, so a full driver snapshot must preserve these records. + let endpoint_statuses = sandbox + .status + .as_ref() + .map(|status| status.endpoint_statuses.clone()) + .unwrap_or_default(); let old_phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); let sandbox_name = &incoming.name; @@ -4338,6 +4346,7 @@ fn apply_driver_snapshot( if let Some(status) = status.as_mut() { status.phase = phase as i32; + status.endpoint_statuses = endpoint_statuses; } if let Some(status) = status.as_mut() @@ -6396,6 +6405,38 @@ mod tests { } } + #[test] + fn driver_snapshot_preserves_endpoint_failure_and_ready_phase() { + let mut sandbox = sandbox_record("sandbox-id", "sandbox-name", SandboxPhase::Ready); + let endpoint = openshell_core::proto::EndpointStatus { + endpoint_id: "endpoint:v1:gateway".to_string(), + host: "api.example.com".to_string(), + ports: vec![443], + path: "/mcp".to_string(), + last_result: openshell_core::proto::EndpointResult::TransportFailed as i32, + last_reported_at: "2026-09-05T01:01:00.000Z".to_string(), + }; + sandbox.status = Some(SandboxStatus { + sandbox_name: "sandbox-name".to_string(), + phase: SandboxPhase::Ready as i32, + endpoint_statuses: vec![endpoint.clone()], + ..Default::default() + }); + let incoming = ready_driver_sandbox("sandbox-id", "sandbox-name"); + + apply_driver_snapshot(&mut sandbox, &incoming, true, true); + + assert_eq!(sandbox.phase(), SandboxPhase::Ready as i32); + let status = sandbox.status.expect("driver status applied"); + assert_eq!(status.endpoint_statuses, vec![endpoint]); + assert!( + status + .conditions + .iter() + .any(|condition| { condition.r#type == "Ready" && condition.status == "True" }) + ); + } + fn sandbox_watch_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { WatchSandboxesEvent { payload: Some(watch_sandboxes_event::Payload::Sandbox( diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 95bd6eaa3..d2665ee6b 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -48,16 +48,16 @@ use openshell_core::proto::{ ListWorkspacesResponse, MemoryResourceCapabilities, ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, - RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, ReportMainProcessExitRequest, - ReportMainProcessExitResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, - ResourceCapabilities, RevokeSshSessionRequest, RevokeSshSessionResponse, - RotateProviderCredentialRequest, RotateProviderCredentialResponse, SandboxResponse, - SandboxTemplateResponse, ServiceEndpointResponse, ServiceStatus, StartSandboxRequest, - StopSandboxRequest, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, - SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, - UpdateConfigRequest, UpdateConfigResponse, UpdateProviderProfilesRequest, - UpdateProviderProfilesResponse, UpdateProviderRequest, WatchSandboxRequest, - open_shell_server::OpenShell, + RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, ReportEndpointStatusRequest, + ReportEndpointStatusResponse, ReportMainProcessExitRequest, ReportMainProcessExitResponse, + ReportPolicyStatusRequest, ReportPolicyStatusResponse, ResourceCapabilities, + RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, + RotateProviderCredentialResponse, SandboxResponse, SandboxTemplateResponse, + ServiceEndpointResponse, ServiceStatus, StartSandboxRequest, StopSandboxRequest, + SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, + UndoDraftChunkRequest, UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, + UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, + WatchSandboxRequest, open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -592,6 +592,13 @@ impl OpenShell for OpenShellService { policy::handle_report_policy_status(&self.state, request).await } + async fn report_endpoint_status( + &self, + request: Request, + ) -> Result, Status> { + policy::handle_report_endpoint_status(&self.state, request).await + } + // --- Sandbox logs --- async fn get_sandbox_logs( diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 936211b26..8abdfd36d 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -10,6 +10,14 @@ #![allow(clippy::cast_precision_loss)] // f64->f32 for confidence scores #![allow(clippy::items_after_statements)] // DB_PORTS const inside function +mod endpoint_status; + +pub(super) use endpoint_status::handle_report_endpoint_status; +pub use endpoint_status::{ + invalidate_endpoint_status_on_startup, reset_endpoint_status_for_supervisor_session, + retry_endpoint_status_after_supervisor_disconnect, +}; + use crate::ServerState; use crate::auth::principal::Principal; use crate::auth::workspace_authz::{ @@ -29,6 +37,7 @@ use crate::storage_proto::StoredProviderCredentialRefreshState; #[cfg(test)] use crate::storage_proto::StoredProviderProfile; use openshell_core::net::{is_always_blocked_ip, is_internal_ip}; +use openshell_core::policy_identity::{canonical_rule_bytes, deterministic_policy_hash}; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; use openshell_core::proto::{ @@ -56,7 +65,7 @@ use openshell_core::telemetry::{ LifecycleOperation, LifecycleResource, PolicyDecisionOperation, TelemetryOutcome, }; use openshell_core::{ - VERSION, + GetResourceVersion, VERSION, endpoint_path::EndpointPathPattern, host_pattern::{host_matches, host_patterns_overlap}, settings::{self, SettingValueKind}, @@ -2962,6 +2971,7 @@ async fn profile_provider_policy_layers_with_catalog( pub(super) struct CredentialedEndpointScope { host: String, ports: Vec, + path: String, } #[derive(Debug, Default)] @@ -3018,6 +3028,7 @@ async fn provider_policy_context_with_catalog( let scope = CredentialedEndpointScope { host: endpoint.host.to_ascii_lowercase(), ports: endpoint_ports(endpoint), + path: canonical_endpoint_path(&endpoint.path), }; if !credentialed_scopes.contains(&scope) { credentialed_scopes.push(scope); @@ -3061,6 +3072,7 @@ fn extend_credentialed_scopes_from_policy_bindings( let scope = CredentialedEndpointScope { host: binding.host.to_ascii_lowercase(), ports: vec![binding.port], + path: canonical_endpoint_path(&binding.path), }; if !scopes.contains(&scope) { scopes.push(scope); @@ -3077,9 +3089,17 @@ fn endpoint_matches_credentialed_scope( return false; } let endpoint_ports = endpoint_ports(endpoint); - endpoint_ports.is_empty() + let port_matches = endpoint_ports.is_empty() || scope.ports.is_empty() - || endpoint_ports.iter().any(|port| scope.ports.contains(port)) + || endpoint_ports.iter().any(|port| scope.ports.contains(port)); + port_matches && path_pattern_covers(&scope.path, &canonical_endpoint_path(&endpoint.path)) +} + +fn canonical_endpoint_path(path: &str) -> String { + match path.trim() { + "" | "**" | "/**" => "/**".to_string(), + path => path.to_string(), + } } pub(super) fn clear_provider_credentialed_markers(policy: &mut ProtoSandboxPolicy) { @@ -3454,6 +3474,10 @@ async fn handle_update_config_inner( let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); + // Global policy determines the report's effective configuration. + // Serialize its writes after validation so a report cannot commit + // evidence derived from the policy this update has replaced. + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let latest = state .store .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) @@ -3551,6 +3575,13 @@ async fn handle_update_config_inner( validate_registered_setting_key(key)?; } + // Deleting global policy changes the report's effective configuration. + // Keep settings -> sandbox lock order for all global policy mutations. + let _sandbox_sync_guard = if key == POLICY_SETTING_KEY && req.delete_setting { + Some(state.compute.sandbox_sync_guard().await) + } else { + None + }; let mut global_settings = load_global_settings(state.store.as_ref()).await?; let provider_composition_was_enabled = provider_policy_composition_enabled_in(&global_settings)?; @@ -4252,18 +4283,42 @@ pub(super) async fn handle_report_policy_status( .supersede_older_policies(&req.sandbox_id, version) .await; - // Update current_policy_version using CAS - // TODO: Accept expected_version from UpdateConfigRequest for proper client-driven CAS let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let version_to_set = req.version; - state + let sandbox = state .store - .update_message_cas::(&req.sandbox_id, 0, |sandbox| { - sandbox.set_current_policy_version(version_to_set); - }) + .get_message::(&req.sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let endpoint_reset = endpoint_status::endpoint_status_reset_for_loaded_policy( + state.as_ref(), + &sandbox, + version, + ) + .await?; + let expected_resource_version = sandbox.get_resource_version(); + let version_to_set = req.version; + let updated = state + .store + .update_message_cas::( + &req.sandbox_id, + expected_resource_version, + |sandbox| { + sandbox.set_current_policy_version(version_to_set); + if let Some(endpoints) = &endpoint_reset { + endpoint_status::reconcile_endpoint_statuses( + sandbox, + endpoints, + &HashSet::new(), + "", + ); + } + }, + ) .await .map_err(|e| super::persistence_error_to_status(e, "update current_policy_version"))?; + state.sandbox_index.update_from_sandbox(&updated); state.sandbox_watch_bus.notify(&req.sandbox_id); } @@ -5785,203 +5840,6 @@ pub(super) async fn handle_get_draft_history( // Policy helper functions // --------------------------------------------------------------------------- -fn append_canonical_bytes(out: &mut Vec, value: &[u8]) { - out.extend_from_slice( - &u64::try_from(value.len()) - .expect("canonical value length fits in u64") - .to_le_bytes(), - ); - out.extend_from_slice(value); -} - -fn append_canonical_message(out: &mut Vec, value: &M) { - append_canonical_bytes(out, &value.encode_to_vec()); -} - -fn append_sorted_message_map( - out: &mut Vec, - label: &[u8], - values: &HashMap, -) { - append_canonical_bytes(out, label); - let mut entries = values.iter().collect::>(); - entries.sort_by_key(|(key, _)| key.as_str()); - out.extend_from_slice( - &u64::try_from(entries.len()) - .expect("canonical map length fits in u64") - .to_le_bytes(), - ); - for (key, value) in entries { - append_canonical_bytes(out, key.as_bytes()); - append_canonical_message(out, value); - } -} - -/// Encode a policy rule without depending on randomized protobuf map order. -fn canonical_rule_bytes(rule: &NetworkPolicyRule) -> Vec { - let mut map_free = rule.clone(); - for endpoint in &mut map_free.endpoints { - endpoint.graphql_persisted_queries.clear(); - for rule in &mut endpoint.rules { - if let Some(allow) = &mut rule.allow { - allow.query.clear(); - allow.params.clear(); - } - } - for deny in &mut endpoint.deny_rules { - deny.query.clear(); - deny.params.clear(); - } - } - - let mut out = Vec::new(); - append_canonical_message(&mut out, &map_free); - for (endpoint_index, endpoint) in rule.endpoints.iter().enumerate() { - out.extend_from_slice( - &u64::try_from(endpoint_index) - .expect("endpoint index fits in u64") - .to_le_bytes(), - ); - append_sorted_message_map( - &mut out, - b"graphql_persisted_queries", - &endpoint.graphql_persisted_queries, - ); - for (rule_index, rule) in endpoint.rules.iter().enumerate() { - out.extend_from_slice( - &u64::try_from(rule_index) - .expect("rule index fits in u64") - .to_le_bytes(), - ); - if let Some(allow) = &rule.allow { - append_sorted_message_map(&mut out, b"allow_query", &allow.query); - append_sorted_message_map(&mut out, b"allow_params", &allow.params); - } - } - for (rule_index, deny) in endpoint.deny_rules.iter().enumerate() { - out.extend_from_slice( - &u64::try_from(rule_index) - .expect("deny-rule index fits in u64") - .to_le_bytes(), - ); - append_sorted_message_map(&mut out, b"deny_query", &deny.query); - append_sorted_message_map(&mut out, b"deny_params", &deny.params); - } - } - out -} - -fn canonical_struct_bytes(value: &prost_types::Struct) -> Vec { - let mut out = Vec::new(); - let mut fields = value.fields.iter().collect::>(); - fields.sort_by_key(|(key, _)| key.as_str()); - out.extend_from_slice( - &u64::try_from(fields.len()) - .expect("struct field count fits in u64") - .to_le_bytes(), - ); - for (key, value) in fields { - append_canonical_bytes(&mut out, key.as_bytes()); - append_canonical_bytes(&mut out, &canonical_value_bytes(value)); - } - out -} - -fn canonical_value_bytes(value: &prost_types::Value) -> Vec { - use prost_types::value::Kind; - - let mut out = Vec::new(); - match &value.kind { - None => out.push(0), - Some(Kind::NullValue(value)) => { - out.push(1); - out.extend_from_slice(&value.to_le_bytes()); - } - Some(Kind::NumberValue(value)) => { - out.push(2); - out.extend_from_slice(&value.to_bits().to_le_bytes()); - } - Some(Kind::StringValue(value)) => { - out.push(3); - append_canonical_bytes(&mut out, value.as_bytes()); - } - Some(Kind::BoolValue(value)) => { - out.push(4); - out.push(u8::from(*value)); - } - Some(Kind::StructValue(value)) => { - out.push(5); - append_canonical_bytes(&mut out, &canonical_struct_bytes(value)); - } - Some(Kind::ListValue(value)) => { - out.push(6); - out.extend_from_slice( - &u64::try_from(value.values.len()) - .expect("list length fits in u64") - .to_le_bytes(), - ); - for item in &value.values { - append_canonical_bytes(&mut out, &canonical_value_bytes(item)); - } - } - } - out -} - -fn canonical_middleware_bytes( - middleware: &openshell_core::proto::NetworkMiddlewareConfig, -) -> Vec { - let mut map_free = middleware.clone(); - map_free.config = None; - let mut out = Vec::new(); - append_canonical_message(&mut out, &map_free); - if let Some(config) = &middleware.config { - append_canonical_bytes(&mut out, &canonical_struct_bytes(config)); - } - out -} - -fn canonical_policy_bytes(policy: &ProtoSandboxPolicy) -> Vec { - let mut map_free = policy.clone(); - map_free.network_policies.clear(); - map_free.network_middlewares.clear(); - let mut out = Vec::new(); - append_canonical_message(&mut out, &map_free); - - let mut policy_entries = policy.network_policies.iter().collect::>(); - policy_entries.sort_by_key(|(key, _)| key.as_str()); - append_canonical_bytes(&mut out, b"network_policies"); - out.extend_from_slice( - &u64::try_from(policy_entries.len()) - .expect("policy count fits in u64") - .to_le_bytes(), - ); - for (key, rule) in policy_entries { - append_canonical_bytes(&mut out, key.as_bytes()); - append_canonical_bytes(&mut out, &canonical_rule_bytes(rule)); - } - - let mut middleware_entries = policy.network_middlewares.iter().collect::>(); - middleware_entries.sort_by_key(|(key, _)| key.as_str()); - append_canonical_bytes(&mut out, b"network_middlewares"); - out.extend_from_slice( - &u64::try_from(middleware_entries.len()) - .expect("middleware count fits in u64") - .to_le_bytes(), - ); - for (key, middleware) in middleware_entries { - append_canonical_bytes(&mut out, key.as_bytes()); - append_canonical_bytes(&mut out, &canonical_middleware_bytes(middleware)); - } - out -} - -/// Compute a deterministic SHA-256 hash of a `SandboxPolicy`, recursively -/// sorting every protobuf map while preserving repeated-field order. -fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { - hex::encode(Sha256::digest(canonical_policy_bytes(policy))) -} - /// Rebuilds a policy revision's identity from its checked canonical payload. /// /// `PolicyRecord::policy_hash` is persisted metadata and cannot prove what the @@ -7440,7 +7298,7 @@ mod tests { /// Wrap a request with a sandbox `Principal` bound to `sandbox_id`. /// Use for tests that exercise sandbox-caller code paths. #[allow(dead_code)] - fn with_sandbox(mut request: Request, sandbox_id: &str) -> Request { + pub(super) fn with_sandbox(mut request: Request, sandbox_id: &str) -> Request { request .extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { @@ -7464,7 +7322,7 @@ mod tests { }) } - fn mcp_policy_with_versions(versions: &[&str]) -> ProtoSandboxPolicy { + pub(super) fn mcp_policy_with_versions(versions: &[&str]) -> ProtoSandboxPolicy { let mut policy = openshell_policy::restrictive_default_policy(); policy.network_policies.insert( "mcp".to_string(), @@ -8539,7 +8397,7 @@ mod tests { } #[test] - fn provider_credentialed_stamping_matches_host_patterns_and_ports() { + fn provider_credentialed_stamping_matches_host_ports_and_paths() { let mut policy = ProtoSandboxPolicy { network_policies: HashMap::from([( "test".to_string(), @@ -8548,18 +8406,28 @@ mod tests { NetworkEndpoint { host: "api.example.com".to_string(), port: 443, + path: "/mcp/private/**".to_string(), provider_credentialed: true, ..Default::default() }, NetworkEndpoint { host: "api.example.com".to_string(), port: 8443, + path: "/mcp/private/**".to_string(), provider_credentialed: true, ..Default::default() }, NetworkEndpoint { host: "*.api.example.com".to_string(), port: 443, + path: "/mcp/private/**".to_string(), + provider_credentialed: true, + ..Default::default() + }, + NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/mcp/public/**".to_string(), provider_credentialed: true, ..Default::default() }, @@ -8572,6 +8440,7 @@ mod tests { let scopes = vec![CredentialedEndpointScope { host: "*.example.com".to_string(), ports: vec![443], + path: "/mcp/private/**".to_string(), }]; clear_provider_credentialed_markers(&mut policy); @@ -8581,6 +8450,7 @@ mod tests { assert!(endpoints[0].provider_credentialed); assert!(!endpoints[1].provider_credentialed); assert!(!endpoints[2].provider_credentialed); + assert!(!endpoints[3].provider_credentialed); } #[test] @@ -8588,6 +8458,7 @@ mod tests { let mut scopes = vec![CredentialedEndpointScope { host: "profile.example.com".to_string(), ports: vec![443], + path: "/**".to_string(), }]; let bindings = HashMap::from([ ( @@ -8635,10 +8506,17 @@ mod tests { CredentialedEndpointScope { host: "profile.example.com".to_string(), ports: vec![443], + path: "/**".to_string(), }, CredentialedEndpointScope { host: "api.bound.example".to_string(), ports: vec![8443], + path: "/v1".to_string(), + }, + CredentialedEndpointScope { + host: "api.bound.example".to_string(), + ports: vec![8443], + path: "/v2".to_string(), }, ] ); @@ -9648,7 +9526,7 @@ mod tests { left } - fn test_sandbox( + pub(super) fn test_sandbox( id: &str, name: &str, policy: ProtoSandboxPolicy, diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status.rs b/crates/openshell-server/src/grpc/policy/endpoint_status.rs new file mode 100644 index 000000000..5a0e45443 --- /dev/null +++ b/crates/openshell-server/src/grpc/policy/endpoint_status.rs @@ -0,0 +1,758 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Passive network results for configured tool server endpoints. + +use super::{ + apply_effective_policy_context, canonical_policy_record_identity, + compute_provider_env_revision_with_catalog_and_policy_bindings, + current_effective_policy_for_sandbox, decode_policy_from_global_settings, + deterministic_policy_hash, load_global_settings, policy_static_credential_endpoint_bindings, +}; +use crate::ServerState; +use crate::persistence::{ObjectId, ObjectName, ObjectWorkspace}; +use crate::policy_store::PolicyStoreExt; +use crate::provider_profile_sources::EffectiveProviderProfileCatalog; +use crate::supervisor_session::EndpointReportCursor; +use chrono::{SecondsFormat, Utc}; +use openshell_core::GetResourceVersion; +use openshell_core::endpoint_status::initial_endpoint_status; +use openshell_core::mcp::is_mcp_protocol; +use openshell_core::proto::{ + EndpointResult, EndpointStatus, PolicySource, ReportEndpointStatusRequest, + ReportEndpointStatusResponse, Sandbox, SandboxPolicy as ProtoSandboxPolicy, SandboxStatus, +}; +use prost::Message; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; +use tonic::{Request, Response, Status}; +use tracing::warn; + +const ENDPOINT_STARTUP_RECONCILIATION_PAGE_SIZE: u32 = 100; +const ENDPOINT_DISCONNECT_RETRY_INITIAL_BACKOFF: std::time::Duration = + std::time::Duration::from_millis(100); +const ENDPOINT_DISCONNECT_RETRY_MAX_BACKOFF: std::time::Duration = + std::time::Duration::from_secs(5); + +#[derive(Clone, Debug, PartialEq)] +struct ExpectedEndpoint { + // Credential attribution is derived from the effective policy so a + // sandbox cannot claim credential failure for an unauthenticated endpoint. + provider_credentialed: bool, + // The public status comes only from the validated policy endpoint, + // never from observed request data or supervisor-supplied text. + status: EndpointStatus, +} + +struct EndpointContext { + policy_hash: String, + provider_env_revision: u64, + endpoints: BTreeMap, +} + +/// Accept the last observed result for each configured tool server endpoint. +/// +/// Addresses and identifiers come from the authoritative policy. The supervisor +/// supplies only typed results, so request data and upstream text cannot enter status. +pub(in crate::grpc) async fn handle_report_endpoint_status( + state: &Arc, + request: Request, +) -> Result, Status> { + let sandbox_id = request.get_ref().sandbox_id.clone(); + crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; + let req = request.into_inner(); + if req.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if req.policy_hash.is_empty() { + return Err(Status::invalid_argument("policy_hash is required")); + } + if req.supervisor_session_id.is_empty() { + return Err(Status::invalid_argument( + "supervisor_session_id is required", + )); + } + if req.report_sequence == 0 { + return Err(Status::invalid_argument("report_sequence is required")); + } + + // Session validation, configuration derivation, and persistence share the + // sandbox mutation boundary. A newly registered supervisor can therefore + // invalidate its predecessor before any stale report reaches the CAS. + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + if !state + .supervisor_sessions + .is_endpoint_status_authority(&req.sandbox_id, &req.supervisor_session_id) + { + return Err(Status::permission_denied( + "tool server endpoint status requires the active supervisor session", + )); + } + let sandbox = state + .store + .get_message::(&req.sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let context = active_endpoint_context(state.as_ref(), &sandbox).await?; + if req.policy_hash != context.policy_hash + || req.provider_env_revision != context.provider_env_revision + { + return Err(Status::failed_precondition( + "tool server endpoint status revisions do not match the current sandbox configuration", + )); + } + + let current_cursor = state + .supervisor_sessions + .endpoint_report_cursor(&req.sandbox_id, &req.supervisor_session_id); + let report_digest: [u8; 32] = Sha256::digest(req.encode_to_vec()).into(); + if let Some(cursor) = current_cursor.as_ref() { + if req.report_sequence < cursor.report_sequence { + return Err(Status::failed_precondition( + "tool server endpoint report sequence is older than the current session cursor", + )); + } + if req.report_sequence == cursor.report_sequence { + if cursor.policy_hash != req.policy_hash + || cursor.provider_env_revision != req.provider_env_revision + || cursor.report_digest != report_digest + { + return Err(Status::invalid_argument( + "a tool server endpoint report sequence must reuse the identical request", + )); + } + // The prior write committed but its acknowledgement was lost. Do + // not fabricate observation times by reconciling the same batch. + return Ok(Response::new(ReportEndpointStatusResponse {})); + } + } + // Sequences belong to the authenticated session, not configuration values. + // Gaps represent snapshots superseded by an inventory reset; accepting a + // newer complete snapshot retires every lower sequence, including an RPC + // cancelled after dispatch or a report from an earlier equal-valued epoch. + + let (reports, observed_endpoint_ids) = validate_endpoint_snapshot( + &req.observations, + &req.observed_endpoint_ids, + &context.endpoints, + )?; + validate_endpoint_observation_markers(&sandbox, &reports, &observed_endpoint_ids)?; + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let expected_resource_version = sandbox.get_resource_version(); + let updated = state + .store + .update_message_cas::(&req.sandbox_id, expected_resource_version, |sandbox| { + reconcile_endpoint_statuses(sandbox, &reports, &observed_endpoint_ids, &now); + }) + .await + .map_err(|error| { + super::super::persistence_error_to_status(error, "persist tool server endpoint status") + })?; + + state.sandbox_index.update_from_sandbox(&updated); + state.sandbox_watch_bus.notify(&req.sandbox_id); + if !state.supervisor_sessions.commit_endpoint_report_cursor( + &req.sandbox_id, + &req.supervisor_session_id, + EndpointReportCursor { + policy_hash: req.policy_hash, + provider_env_revision: req.provider_env_revision, + report_sequence: req.report_sequence, + report_digest, + }, + ) { + return Err(Status::permission_denied( + "tool server endpoint status supervisor session was replaced while persisting the report", + )); + } + + Ok(Response::new(ReportEndpointStatusResponse {})) +} + +async fn current_endpoint_context( + state: &ServerState, + sandbox: &Sandbox, +) -> Result { + let workspace = sandbox.object_workspace(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let policy = current_effective_policy_for_sandbox( + state, + &catalog, + workspace, + sandbox, + sandbox.object_id(), + ) + .await?; + derive_endpoint_context(state, sandbox, &catalog, policy).await +} + +async fn active_endpoint_context( + state: &ServerState, + sandbox: &Sandbox, +) -> Result { + let version = sandbox.current_policy_version(); + if version == 0 { + // Before the first load acknowledgement there is no active revision. + // The effective policy is the candidate sent to the supervisor, so it + // is the only configuration against which the initial session can be + // reset and subsequently report. + return current_endpoint_context(state, sandbox).await; + } + + // A newer stored policy may still be pending while the supervisor runs the + // acknowledged revision. Endpoint observations belong to that active runtime + // configuration until another load acknowledgement commits the transition. + endpoint_context_for_loaded_policy(state, sandbox, i64::from(version)).await +} + +async fn endpoint_context_for_loaded_policy( + state: &ServerState, + sandbox: &Sandbox, + version: i64, +) -> Result { + let workspace = sandbox.object_workspace(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let global_settings = load_global_settings(state.store.as_ref()).await?; + let global_policy = decode_policy_from_global_settings(&global_settings)?; + endpoint_context_for_loaded_policy_with_inputs( + state, + sandbox, + version, + &catalog, + global_policy.as_ref(), + ) + .await +} + +async fn endpoint_context_for_loaded_policy_with_inputs( + state: &ServerState, + sandbox: &Sandbox, + version: i64, + catalog: &EffectiveProviderProfileCatalog, + global_policy: Option<&ProtoSandboxPolicy>, +) -> Result { + let workspace = sandbox.object_workspace(); + let provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.clone()) + .unwrap_or_default(); + let policy = if let Some(global_policy) = global_policy { + apply_effective_policy_context( + state, + catalog, + workspace, + &provider_names, + global_policy.clone(), + PolicySource::Global, + ) + .await? + } else { + let record = state + .store + .get_policy_by_version(sandbox.object_id(), version) + .await + .map_err(|error| Status::internal(format!("fetch policy revision failed: {error}")))? + .ok_or_else(|| Status::not_found("policy revision not found"))?; + let policy = canonical_policy_record_identity(&record)?.0; + apply_effective_policy_context( + state, + catalog, + workspace, + &provider_names, + policy, + PolicySource::Sandbox, + ) + .await? + }; + derive_endpoint_context(state, sandbox, catalog, policy).await +} + +async fn derive_endpoint_context( + state: &ServerState, + sandbox: &Sandbox, + catalog: &EffectiveProviderProfileCatalog, + policy: ProtoSandboxPolicy, +) -> Result { + let workspace = sandbox.object_workspace(); + let provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.clone()) + .unwrap_or_default(); + let bindings = policy_static_credential_endpoint_bindings(Some(&policy))?; + let provider_env_revision = compute_provider_env_revision_with_catalog_and_policy_bindings( + state.store.as_ref(), + catalog, + workspace, + &provider_names, + &bindings, + ) + .await?; + + Ok(EndpointContext { + policy_hash: deterministic_policy_hash(&policy), + provider_env_revision, + endpoints: expected_endpoint_statuses(&policy), + }) +} + +fn unknown_endpoint_reports( + endpoints: &BTreeMap, +) -> BTreeMap { + endpoints + .iter() + .map(|(endpoint_id, endpoint)| { + ( + endpoint_id.clone(), + EndpointStatus { + last_result: EndpointResult::NoObservedExchange as i32, + ..endpoint.status.clone() + }, + ) + }) + .collect() +} + +/// Reset endpoint evidence before acknowledging a newly authenticated supervisor. +pub async fn reset_endpoint_status_for_supervisor_session( + state: &Arc, + sandbox_id: &str, + supervisor_session_id: &str, +) -> Result<(), Status> { + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + if !state + .supervisor_sessions + .is_current_session(sandbox_id, supervisor_session_id) + { + return Err(Status::failed_precondition( + "supervisor session was replaced before endpoint status reset", + )); + } + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let context = active_endpoint_context(state.as_ref(), &sandbox).await?; + let reports = unknown_endpoint_reports(&context.endpoints); + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let expected_resource_version = sandbox.get_resource_version(); + let updated = state + .store + .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { + reconcile_endpoint_statuses(sandbox, &reports, &HashSet::new(), &now); + }) + .await + .map_err(|error| { + super::super::persistence_error_to_status( + error, + "reset tool server endpoint status for supervisor", + ) + })?; + state.sandbox_index.update_from_sandbox(&updated); + state.sandbox_watch_bus.notify(sandbox_id); + Ok(()) +} + +/// Reset endpoint observations after the active supervisor stream disconnects. +/// +/// A concurrently registered replacement owns its own pre-acknowledgement +/// reset, so this path leaves that session's cursor alone. +pub async fn reset_endpoint_status_after_supervisor_disconnect( + state: &Arc, + sandbox_id: &str, +) -> Result<(), Status> { + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + if state + .supervisor_sessions + .current_session_id(sandbox_id) + .is_some() + { + return Ok(()); + } + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let context = active_endpoint_context(state.as_ref(), &sandbox).await?; + let reports = unknown_endpoint_reports(&context.endpoints); + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let expected_resource_version = sandbox.get_resource_version(); + let updated = state + .store + .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { + reconcile_endpoint_statuses(sandbox, &reports, &HashSet::new(), &now); + }) + .await + .map_err(|error| { + super::super::persistence_error_to_status( + error, + "reset tool server endpoint status after disconnect", + ) + })?; + state.sandbox_index.update_from_sandbox(&updated); + state.sandbox_watch_bus.notify(sandbox_id); + Ok(()) +} + +/// Retry disconnect invalidation until it is durable or a replacement session +/// takes ownership. +/// +/// A transient storage failure must not leave a previously successful endpoint +/// usable after its observation authority has disappeared. Gateway restart is +/// covered separately by startup reconciliation before listeners are bound. +pub async fn retry_endpoint_status_after_supervisor_disconnect( + state: Arc, + sandbox_id: String, +) { + let mut backoff = ENDPOINT_DISCONNECT_RETRY_INITIAL_BACKOFF; + loop { + match reset_endpoint_status_after_supervisor_disconnect(&state, &sandbox_id).await { + Ok(()) => return, + Err(error) if error.code() == tonic::Code::NotFound => return, + Err(error) => { + warn!( + sandbox_id = %sandbox_id, + error = %error, + retry_after_ms = backoff.as_millis(), + "supervisor session: retrying tool server endpoint-status invalidation" + ); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(ENDPOINT_DISCONNECT_RETRY_MAX_BACKOFF); + } + } + } +} + +/// Invalidate endpoint results left by sessions from an earlier gateway process. +/// +/// Supervisor sessions are intentionally process-local. This reconciliation +/// runs before gateway listeners are bound, so persisted success can never be +/// served without a session in the current process that owns the observation. +pub async fn invalidate_endpoint_status_on_startup(state: &Arc) -> Result<(), Status> { + let mut offset = 0; + loop { + let sandboxes = state + .store + .list_all_messages::(ENDPOINT_STARTUP_RECONCILIATION_PAGE_SIZE, offset) + .await + .map_err(|error| { + Status::internal(format!( + "list sandboxes for tool server endpoint-status startup reconciliation failed: {error}" + )) + })?; + if sandboxes.is_empty() { + return Ok(()); + } + + for sandbox in &sandboxes { + let has_endpoint_status = sandbox + .status + .as_ref() + .is_some_and(|status| !status.endpoint_statuses.is_empty()); + if !has_endpoint_status { + continue; + } + let sandbox_id = sandbox.object_id(); + let expected_resource_version = sandbox.get_resource_version(); + let updated = state + .store + .update_message_cas::( + sandbox_id, + expected_resource_version, + invalidate_endpoint_status_without_session, + ) + .await + .map_err(|error| { + super::super::persistence_error_to_status( + error, + "invalidate tool server endpoint status during gateway startup", + ) + })?; + state.sandbox_index.update_from_sandbox(&updated); + } + + let page_len = sandboxes.len() as u32; + if page_len < ENDPOINT_STARTUP_RECONCILIATION_PAGE_SIZE { + return Ok(()); + } + offset = offset.checked_add(page_len).ok_or_else(|| { + Status::internal( + "sandbox pagination overflow during tool server endpoint-status reconciliation", + ) + })?; + } +} + +fn invalidate_endpoint_status_without_session(sandbox: &mut Sandbox) { + let Some(status) = sandbox.status.as_mut() else { + return; + }; + // Losing observation authority clears evidence, while policy-derived identity + // remains available so callers can still identify the configured endpoint. + for endpoint in &mut status.endpoint_statuses { + endpoint.last_result = EndpointResult::NoObservedExchange as i32; + endpoint.last_reported_at.clear(); + } +} + +fn expected_endpoint_statuses(policy: &ProtoSandboxPolicy) -> BTreeMap { + let mut endpoints = BTreeMap::new(); + for endpoint in policy + .network_policies + .values() + .flat_map(|rule| &rule.endpoints) + .filter(|endpoint| is_mcp_protocol(&endpoint.protocol)) + { + let status = initial_endpoint_status(endpoint); + endpoints + .entry(status.endpoint_id.clone()) + .and_modify(|expected: &mut ExpectedEndpoint| { + expected.provider_credentialed |= endpoint.provider_credentialed; + }) + .or_insert(ExpectedEndpoint { + provider_credentialed: endpoint.provider_credentialed, + status, + }); + } + endpoints +} + +fn validate_endpoint_snapshot( + reported: &[openshell_core::proto::EndpointObservation], + reported_observed_endpoint_ids: &[String], + expected: &BTreeMap, +) -> Result<(BTreeMap, HashSet), Status> { + let mut validated = BTreeMap::new(); + for endpoint in reported { + if endpoint.endpoint_id.is_empty() { + return Err(Status::invalid_argument("endpoint_id is required")); + } + let Some(expected_endpoint) = expected.get(&endpoint.endpoint_id) else { + return Err(Status::failed_precondition( + "tool server endpoint status contains an endpoint_id outside the current policy", + )); + }; + let result = EndpointResult::try_from(endpoint.result) + .map_err(|_| Status::invalid_argument("tool server endpoint result is invalid"))?; + if result == EndpointResult::Unspecified { + return Err(Status::invalid_argument( + "tool server endpoint result is required", + )); + } + if result == EndpointResult::CredentialUnavailable + && !expected_endpoint.provider_credentialed + { + return Err(Status::invalid_argument( + "credential-unavailable requires a credentialed tool server endpoint", + )); + } + if validated + .insert( + endpoint.endpoint_id.clone(), + EndpointStatus { + last_result: result as i32, + ..expected_endpoint.status.clone() + }, + ) + .is_some() + { + return Err(Status::invalid_argument("endpoint_ids must be unique")); + } + } + if validated.len() != expected.len() + || validated + .keys() + .zip(expected.keys()) + .any(|(reported, expected)| reported != expected) + { + return Err(Status::failed_precondition( + "tool server endpoint status must contain the complete current endpoint set", + )); + } + let mut observed_endpoint_ids = HashSet::new(); + for endpoint_id in reported_observed_endpoint_ids { + let Some(endpoint) = validated.get(endpoint_id) else { + return Err(Status::failed_precondition( + "observed endpoint_id is outside the current snapshot", + )); + }; + if endpoint.last_result == EndpointResult::NoObservedExchange as i32 { + return Err(Status::invalid_argument( + "an observed tool server endpoint cannot report no observed exchange", + )); + } + if !observed_endpoint_ids.insert(endpoint_id.clone()) { + return Err(Status::invalid_argument( + "observed endpoint_ids must be unique", + )); + } + } + Ok((validated, observed_endpoint_ids)) +} + +fn validate_endpoint_observation_markers( + sandbox: &Sandbox, + reports: &BTreeMap, + observed_endpoint_ids: &HashSet, +) -> Result<(), Status> { + let previous = sandbox + .status + .as_ref() + .map(|status| { + status + .endpoint_statuses + .iter() + .map(|endpoint| (endpoint.endpoint_id.as_str(), endpoint.last_result)) + .collect::>() + }) + .unwrap_or_default(); + + for (endpoint_id, report) in reports { + if report.last_result == EndpointResult::NoObservedExchange as i32 { + continue; + } + // A retained result may reuse its previous report time. New evidence, + // including evidence accepted after a reset, must carry a marker. + if previous.get(endpoint_id.as_str()) != Some(&report.last_result) + && !observed_endpoint_ids.contains(endpoint_id) + { + return Err(Status::invalid_argument( + "a new tool server endpoint result must be marked as observed", + )); + } + } + Ok(()) +} + +/// Build an endpoint reset only when the acknowledged policy changes observations' configuration. +/// The caller commits any reset and the active policy version in one CAS. +pub(super) async fn endpoint_status_reset_for_loaded_policy( + state: &ServerState, + sandbox: &Sandbox, + version: i64, +) -> Result>, Status> { + // Both policy versions must use one provider/profile and global-policy view; + // an external catalog can change between independent snapshot requests. + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), sandbox.object_workspace()) + .await?; + let global_settings = load_global_settings(state.store.as_ref()).await?; + let global_policy = decode_policy_from_global_settings(&global_settings)?; + let context = endpoint_context_for_loaded_policy_with_inputs( + state, + sandbox, + version, + &catalog, + global_policy.as_ref(), + ) + .await?; + let current_version = i64::from(sandbox.current_policy_version()); + let same_configuration = if current_version == version { + // An acknowledgement can be retried after endpoint reports have already + // committed. Repeating activation must not erase their accepted evidence. + true + } else if current_version != 0 { + // Metadata-only revisions do not reinstall the runtime policy. Compare + // the active policy, not the report cursor: a cursor can outlive an + // intervening policy reset and cannot prove an A -> B -> A transition. + let current = endpoint_context_for_loaded_policy_with_inputs( + state, + sandbox, + current_version, + &catalog, + global_policy.as_ref(), + ) + .await?; + current.policy_hash == context.policy_hash + } else { + // The first endpoint report and load acknowledgement travel independently. + // Only the current session's accepted report proves which configuration + // produced evidence before any loaded policy version has been recorded. + state + .supervisor_sessions + .current_session_id(sandbox.object_id()) + .and_then(|session_id| { + state + .supervisor_sessions + .endpoint_report_cursor(sandbox.object_id(), &session_id) + }) + .is_some_and(|cursor| { + cursor.policy_hash == context.policy_hash + && cursor.provider_env_revision == context.provider_env_revision + }) + }; + Ok((!same_configuration).then(|| unknown_endpoint_reports(&context.endpoints))) +} + +/// Replace the endpoint inventory and evidence without changing lifecycle state. +pub(super) fn reconcile_endpoint_statuses( + sandbox: &mut Sandbox, + reports: &BTreeMap, + observed_endpoint_ids: &HashSet, + now: &str, +) { + let phase = sandbox.phase(); + let current_policy_version = sandbox.current_policy_version(); + let sandbox_name = if sandbox.object_name().is_empty() { + sandbox.object_id() + } else { + sandbox.object_name() + } + .to_string(); + let status = sandbox.status.get_or_insert_with(|| SandboxStatus { + sandbox_name, + phase, + current_policy_version, + ..Default::default() + }); + let previous = status + .endpoint_statuses + .iter() + .map(|endpoint| { + ( + endpoint.endpoint_id.as_str(), + endpoint.last_reported_at.as_str(), + ) + }) + .collect::>(); + // Replace the complete inventory to remove retired endpoints atomically. + // Only newly accepted evidence advances the gateway acceptance timestamp; + // unchanged evidence retains its timestamp and unknown results have none. + status.endpoint_statuses = reports + .iter() + .map(|(endpoint_id, report)| { + let mut endpoint = report.clone(); + endpoint.last_reported_at = + if endpoint.last_result == EndpointResult::NoObservedExchange as i32 { + String::new() + } else if observed_endpoint_ids.contains(endpoint_id) { + now.to_string() + } else { + previous + .get(endpoint_id.as_str()) + .copied() + .unwrap_or_default() + .to_string() + }; + endpoint + }) + .collect(); +} + +#[cfg(test)] +#[path = "endpoint_status_tests.rs"] +mod tests; diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs new file mode 100644 index 000000000..36c1b303b --- /dev/null +++ b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs @@ -0,0 +1,1231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::super::tests::{mcp_policy_with_versions, test_sandbox, with_sandbox}; +use super::super::{handle_get_sandbox_config, handle_report_policy_status, handle_update_config}; +use super::*; +use crate::grpc::test_support::{authed_request, test_server_state}; +use openshell_core::endpoint_status::endpoint_id; +use openshell_core::proto::{ + EndpointObservation, GetSandboxConfigRequest, GetSandboxRequest, NetworkEndpoint, + NetworkPolicyRule, PolicyStatus, ReportPolicyStatusRequest, SandboxCondition, SandboxPhase, + UpdateConfigRequest, workspace_selector, +}; +use tonic::Code; + +fn test_initial_endpoint_status(endpoint_id: &str, host: &str, path: &str) -> EndpointStatus { + EndpointStatus { + endpoint_id: endpoint_id.to_string(), + host: host.to_string(), + ports: vec![443], + path: path.to_string(), + last_result: EndpointResult::NoObservedExchange as i32, + last_reported_at: String::new(), + } +} + +fn ready_condition() -> SandboxCondition { + SandboxCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: "SupervisorReady".to_string(), + ..Default::default() + } +} + +fn register_session(state: &ServerState, sandbox_id: &str, session_id: &str) { + let (session_tx, _session_rx) = tokio::sync::mpsc::channel(1); + let (shutdown_tx, _shutdown_rx) = tokio::sync::oneshot::channel(); + state.supervisor_sessions.register( + sandbox_id.to_string(), + session_id.to_string(), + session_tx, + shutdown_tx, + ); +} + +async fn stored_sandbox(state: &ServerState, sandbox_id: &str) -> Sandbox { + state + .store + .get_message::(sandbox_id) + .await + .expect("load sandbox") + .expect("sandbox remains present") +} + +async fn public_status(state: &Arc, sandbox_id: &str) -> SandboxStatus { + crate::grpc::sandbox::handle_get_sandbox( + state, + authed_request(GetSandboxRequest { + name: sandbox_id.to_string(), + workspace_scope: Some(workspace_selector("default")), + }), + ) + .await + .expect("public sandbox status") + .into_inner() + .sandbox + .expect("sandbox remains present") + .status + .expect("status remains present") +} + +async fn acknowledge_loaded_policy(state: &Arc, sandbox_id: &str, version: u32) { + handle_report_policy_status( + state, + with_sandbox( + Request::new(ReportPolicyStatusRequest { + sandbox_id: sandbox_id.to_string(), + version, + status: PolicyStatus::Loaded as i32, + load_error: String::new(), + }), + sandbox_id, + ), + ) + .await + .expect("acknowledge loaded policy"); +} + +async fn sandbox_with_accepted_endpoint_result( + sandbox_id: &str, + acknowledge_before_observation: bool, +) -> (Arc, ReportEndpointStatusRequest) { + let state = test_server_state().await; + let mut policy = mcp_policy_with_versions(&["2025-11-25"]); + let endpoint = &mut policy + .network_policies + .get_mut("mcp") + .expect("MCP rule") + .endpoints[0]; + endpoint.host = "tools.example.com".to_string(); + endpoint.path = "/mcp".to_string(); + let mut sandbox = test_sandbox(sandbox_id, sandbox_id, policy.clone(), Vec::new()); + sandbox.status = Some(SandboxStatus { + sandbox_name: sandbox_id.to_string(), + phase: SandboxPhase::Ready as i32, + conditions: vec![ready_condition()], + ..Default::default() + }); + state + .store + .put_message(&sandbox) + .await + .expect("store sandbox"); + state + .store + .put_policy_revision( + &format!("{sandbox_id}-v1"), + sandbox_id, + "default", + 1, + &policy.encode_to_vec(), + &deterministic_policy_hash(&policy), + ) + .await + .expect("store policy revision"); + if acknowledge_before_observation { + acknowledge_loaded_policy(&state, sandbox_id, 1).await; + } + + register_session(&state, sandbox_id, "session-a"); + reset_endpoint_status_for_supervisor_session(&state, sandbox_id, "session-a") + .await + .expect("reset session evidence"); + assert!( + state + .supervisor_sessions + .initialize_endpoint_status_authority(sandbox_id, "session-a") + ); + let sandbox = stored_sandbox(&state, sandbox_id).await; + let context = active_endpoint_context(&state, &sandbox) + .await + .expect("active endpoint configuration"); + let endpoint_id = context + .endpoints + .keys() + .next() + .expect("MCP endpoint") + .clone(); + let report = ReportEndpointStatusRequest { + sandbox_id: sandbox_id.to_string(), + policy_hash: context.policy_hash, + provider_env_revision: context.provider_env_revision, + observations: vec![EndpointObservation { + endpoint_id: endpoint_id.clone(), + result: EndpointResult::HttpResponseReceived as i32, + }], + observed_endpoint_ids: vec![endpoint_id], + supervisor_session_id: "session-a".to_string(), + report_sequence: 1, + }; + handle_report_endpoint_status( + &state, + with_sandbox(Request::new(report.clone()), sandbox_id), + ) + .await + .expect("accept endpoint result"); + (state, report) +} + +async fn assert_loaded_ack_preserves_endpoint_evidence( + state: &Arc, + report: ReportEndpointStatusRequest, + version: u32, +) { + let before = public_status(state, &report.sandbox_id).await; + assert_eq!(before.endpoint_statuses.len(), 1); + assert_eq!( + before.endpoint_statuses[0].last_result, + EndpointResult::HttpResponseReceived as i32 + ); + assert!(!before.endpoint_statuses[0].last_reported_at.is_empty()); + let cursor = state + .supervisor_sessions + .endpoint_report_cursor(&report.sandbox_id, &report.supervisor_session_id); + + acknowledge_loaded_policy(state, &report.sandbox_id, version).await; + + let after = public_status(state, &report.sandbox_id).await; + assert_eq!(after.current_policy_version, version); + assert_eq!(after.endpoint_statuses, before.endpoint_statuses); + assert_eq!(after.phase, before.phase); + assert_eq!(after.conditions, before.conditions); + assert_eq!( + state + .supervisor_sessions + .endpoint_report_cursor(&report.sandbox_id, &report.supervisor_session_id), + cursor + ); + + // The endpoint RPC may have committed before its acknowledgement was lost. + // A later policy acknowledgement cannot invalidate that immutable retry. + let committed = stored_sandbox(state, &report.sandbox_id).await; + handle_report_endpoint_status( + state, + with_sandbox(Request::new(report.clone()), &report.sandbox_id), + ) + .await + .expect("retry accepted endpoint report"); + assert_eq!(stored_sandbox(state, &report.sandbox_id).await, committed); +} + +#[tokio::test] +async fn loaded_policy_retry_preserves_endpoint_evidence() { + let (state, report) = + sandbox_with_accepted_endpoint_result("endpoint-loaded-retry", true).await; + + assert_loaded_ack_preserves_endpoint_evidence(&state, report, 1).await; +} + +#[tokio::test] +async fn unchanged_policy_revision_preserves_endpoint_evidence() { + let sandbox_id = "endpoint-unchanged-revision"; + let (state, report) = sandbox_with_accepted_endpoint_result(sandbox_id, true).await; + let sandbox = stored_sandbox(&state, sandbox_id).await; + let revision = handle_update_config( + &state, + authed_request(UpdateConfigRequest { + name: sandbox_id.to_string(), + policy: sandbox.spec.expect("sandbox spec").policy, + annotations: HashMap::from([("audit".to_string(), "v2".to_string())]), + workspace_scope: Some(workspace_selector("default")), + ..Default::default() + }), + ) + .await + .expect("create metadata-only policy revision") + .into_inner(); + assert_eq!(revision.version, 2); + assert_eq!(revision.policy_hash, report.policy_hash); + + assert_loaded_ack_preserves_endpoint_evidence(&state, report, 2).await; +} + +#[tokio::test] +async fn initial_loaded_policy_ack_preserves_endpoint_evidence() { + let (state, report) = + sandbox_with_accepted_endpoint_result("endpoint-initial-ack", false).await; + assert_eq!( + stored_sandbox(&state, &report.sandbox_id) + .await + .current_policy_version(), + 0 + ); + + // Policy and endpoint reports use independent tasks, so the first exchange + // may reach the gateway before the initial loaded-policy acknowledgement. + assert_loaded_ack_preserves_endpoint_evidence(&state, report, 1).await; +} + +#[tokio::test] +async fn loaded_policy_comparison_uses_one_provider_profile_snapshot() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let sandbox_id = "endpoint-policy-catalog-snapshot"; + let (state, report) = sandbox_with_accepted_endpoint_result(sandbox_id, true).await; + let policy = stored_sandbox(&state, sandbox_id) + .await + .spec + .expect("sandbox spec") + .policy + .expect("sandbox policy"); + state + .store + .put_policy_revision( + "same-policy-new-revision", + sandbox_id, + "default", + 2, + &policy.encode_to_vec(), + &report.policy_hash, + ) + .await + .expect("store equivalent policy revision"); + let fetch_count = Arc::new(AtomicUsize::new(0)); + let mut state = Arc::into_inner(state).expect("uniquely owned test state"); + let mut profile_a = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == "github") + .expect("valid built-in provider profile") + .to_proto(); + profile_a.id = "snapshot-provider".to_string(); + profile_a.display_name = "catalog-a".to_string(); + let mut profile_b = profile_a.clone(); + profile_b.display_name = "catalog-b".to_string(); + state.provider_profile_sources = + crate::provider_profile_sources::ProviderProfileSources::from_test_snapshot_sequence( + vec![ + ("catalog-a".to_string(), vec![profile_a]), + ("catalog-b".to_string(), vec![profile_b]), + ], + Arc::clone(&fetch_count), + ); + let state = Arc::new(state); + let before = public_status(&state, sandbox_id).await; + + acknowledge_loaded_policy(&state, sandbox_id, 2).await; + + assert_eq!(fetch_count.load(Ordering::SeqCst), 1); + let after = public_status(&state, sandbox_id).await; + assert_eq!(after.current_policy_version, 2); + assert_eq!(after.endpoint_statuses, before.endpoint_statuses); + assert_eq!(after.phase, before.phase); + assert_eq!(after.conditions, before.conditions); +} + +#[tokio::test] +async fn loaded_policy_hash_cycle_resets_endpoint_evidence() { + let sandbox_id = "endpoint-policy-hash-cycle"; + let (state, report) = sandbox_with_accepted_endpoint_result(sandbox_id, true).await; + let sandbox = stored_sandbox(&state, sandbox_id).await; + let original_policy = sandbox + .spec + .expect("sandbox spec") + .policy + .expect("sandbox policy"); + let original_status = public_status(&state, sandbox_id).await; + let mut changed_policy = original_policy.clone(); + changed_policy + .network_policies + .get_mut("mcp") + .expect("MCP rule") + .endpoints[0] + .rules[0] + .allow + .as_mut() + .expect("MCP allow rule") + .method = "tools/call".to_string(); + + // A changed permission resets evidence even when the public endpoint address + // stays the same. Returning to the old hash cannot revive its old cursor. + for (policy, version) in [(changed_policy, 2), (original_policy, 3)] { + let revision = handle_update_config( + &state, + authed_request(UpdateConfigRequest { + name: sandbox_id.to_string(), + policy: Some(policy), + workspace_scope: Some(workspace_selector("default")), + ..Default::default() + }), + ) + .await + .expect("update policy permission") + .into_inner(); + assert_eq!(revision.version, version); + if version == 2 { + assert_ne!(revision.policy_hash, report.policy_hash); + } else { + assert_eq!(revision.policy_hash, report.policy_hash); + } + acknowledge_loaded_policy(&state, sandbox_id, version).await; + let status = public_status(&state, sandbox_id).await; + assert_eq!(status.current_policy_version, version); + assert_eq!(status.endpoint_statuses.len(), 1); + assert_eq!( + status.endpoint_statuses[0], + EndpointStatus { + last_result: EndpointResult::NoObservedExchange as i32, + last_reported_at: String::new(), + ..original_status.endpoint_statuses[0].clone() + } + ); + assert_eq!(status.phase, original_status.phase); + assert_eq!(status.conditions, original_status.conditions); + } + + let reset = stored_sandbox(&state, sandbox_id).await; + handle_report_endpoint_status(&state, with_sandbox(Request::new(report), sandbox_id)) + .await + .expect("acknowledge already committed endpoint report"); + assert_eq!(stored_sandbox(&state, sandbox_id).await, reset); +} + +#[tokio::test] +async fn global_policy_update_waits_for_endpoint_report_guard() { + let state = test_server_state().await; + let policy = mcp_policy_with_versions(&["2025-11-25"]); + let updates = [ + UpdateConfigRequest { + policy: Some(policy), + global: true, + ..Default::default() + }, + UpdateConfigRequest { + setting_key: super::super::POLICY_SETTING_KEY.to_string(), + delete_setting: true, + global: true, + ..Default::default() + }, + ]; + + for (index, update) in updates.into_iter().enumerate() { + let before = load_global_settings(state.store.as_ref()) + .await + .expect("read settings before update"); + let guard = state.compute.sandbox_sync_guard().await; + let mut pending = Box::pin(handle_update_config(&state, authed_request(update))); + + // Poll the actual writer while an endpoint report owns the mutation + // boundary. Both replacing and deleting global policy must wait. + let result = + tokio::time::timeout(std::time::Duration::from_millis(100), pending.as_mut()).await; + assert!( + result.is_err(), + "global policy update committed while endpoint report guard was held: {result:?}" + ); + let during = load_global_settings(state.store.as_ref()) + .await + .expect("read settings while writer waits"); + assert_eq!(during.revision, before.revision); + assert_eq!(during.settings, before.settings); + + drop(guard); + tokio::time::timeout(std::time::Duration::from_secs(5), pending) + .await + .expect("global policy update completes after guard release") + .expect("global policy update succeeds"); + let after = load_global_settings(state.store.as_ref()) + .await + .expect("read updated settings"); + assert_eq!( + decode_policy_from_global_settings(&after) + .expect("decode global policy") + .is_some(), + index == 0 + ); + } +} + +#[test] +fn expected_endpoint_statuses_canonicalize_identity_and_distinguish_paths() { + let policy = ProtoSandboxPolicy { + network_policies: HashMap::from([( + "mcp".to_string(), + NetworkPolicyRule { + endpoints: vec![ + NetworkEndpoint { + host: "API.Example.COM".to_string(), + ports: vec![443, 443], + path: "**".to_string(), + protocol: " MCP ".to_string(), + ..Default::default() + }, + NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/**".to_string(), + protocol: "mcp".to_string(), + provider_credentialed: true, + ..Default::default() + }, + NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/mcp/private/**".to_string(), + protocol: "mcp".to_string(), + ..Default::default() + }, + NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/ignored".to_string(), + protocol: "rest".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }, + )]), + ..Default::default() + }; + + let endpoints = expected_endpoint_statuses(&policy); + + assert_eq!(endpoints.len(), 2); + let root_endpoint_id = endpoint_id(&policy.network_policies["mcp"].endpoints[0]); + let private_endpoint_id = endpoint_id(&policy.network_policies["mcp"].endpoints[2]); + assert_ne!(root_endpoint_id, private_endpoint_id); + assert_eq!( + endpoints[&root_endpoint_id], + ExpectedEndpoint { + provider_credentialed: true, + status: test_initial_endpoint_status(&root_endpoint_id, "api.example.com", "/**"), + } + ); + assert_eq!( + endpoints[&private_endpoint_id].status, + test_initial_endpoint_status(&private_endpoint_id, "api.example.com", "/mcp/private/**") + ); +} + +#[tokio::test] +async fn report_endpoint_status_rejects_stale_configuration_epoch() { + let state = test_server_state().await; + let sandbox_id = "endpoint-stale"; + state + .store + .put_message(&test_sandbox( + sandbox_id, + "endpoint-stale", + mcp_policy_with_versions(&["2025-11-25"]), + Vec::new(), + )) + .await + .expect("store sandbox"); + let config = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + sandbox_id: sandbox_id.to_string(), + }), + sandbox_id, + ), + ) + .await + .expect("get sandbox config") + .into_inner(); + let endpoint = &config + .policy + .as_ref() + .expect("effective policy") + .network_policies["mcp"] + .endpoints[0]; + let observation = EndpointObservation { + endpoint_id: endpoint_id(endpoint), + result: EndpointResult::NoObservedExchange as i32, + }; + let (session_tx, _session_rx) = tokio::sync::mpsc::channel(1); + let (shutdown_tx, _shutdown_rx) = tokio::sync::oneshot::channel(); + state.supervisor_sessions.register( + sandbox_id.to_string(), + "session-current".to_string(), + session_tx, + shutdown_tx, + ); + assert!( + state + .supervisor_sessions + .initialize_endpoint_status_authority(sandbox_id, "session-current") + ); + + for request in [ + ReportEndpointStatusRequest { + sandbox_id: sandbox_id.to_string(), + policy_hash: format!("stale-{}", config.policy_hash), + provider_env_revision: config.provider_env_revision, + observations: vec![observation.clone()], + observed_endpoint_ids: Vec::new(), + supervisor_session_id: "session-current".to_string(), + report_sequence: 1, + }, + ReportEndpointStatusRequest { + sandbox_id: sandbox_id.to_string(), + policy_hash: config.policy_hash.clone(), + provider_env_revision: config.provider_env_revision.wrapping_add(1), + observations: vec![observation.clone()], + observed_endpoint_ids: Vec::new(), + supervisor_session_id: "session-current".to_string(), + report_sequence: 1, + }, + ] { + let error = + handle_report_endpoint_status(&state, with_sandbox(Request::new(request), sandbox_id)) + .await + .expect_err("stale endpoint status must be rejected"); + assert_eq!(error.code(), Code::FailedPrecondition); + } + + let stored = state + .store + .get_message::(sandbox_id) + .await + .expect("load sandbox") + .expect("sandbox remains present"); + assert!( + stored + .status + .as_ref() + .is_none_or(|status| status.endpoint_statuses.is_empty()) + ); +} + +#[test] +fn endpoint_snapshot_rejects_credential_failure_for_uncredentialed_path() { + let expected = BTreeMap::from([( + "endpoint:v1:public".to_string(), + ExpectedEndpoint { + provider_credentialed: false, + status: test_initial_endpoint_status( + "endpoint:v1:public", + "api.example.com", + "/mcp/public/**", + ), + }, + )]); + let reported = vec![EndpointObservation { + endpoint_id: "endpoint:v1:public".to_string(), + result: EndpointResult::CredentialUnavailable as i32, + }]; + let error = validate_endpoint_snapshot(&reported, &[], &expected) + .expect_err("uncredentialed endpoint cannot report credential failure"); + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!( + error.message(), + "credential-unavailable requires a credentialed tool server endpoint" + ); +} + +#[test] +fn endpoint_results_preserve_address_and_lifecycle_through_failure_recovery_and_reset() { + let mut sandbox = test_sandbox( + "sandbox-id", + "sandbox-name", + ProtoSandboxPolicy::default(), + Vec::new(), + ); + sandbox.status = Some(SandboxStatus { + sandbox_name: "sandbox-name".to_string(), + phase: SandboxPhase::Ready as i32, + conditions: vec![ready_condition()], + endpoint_statuses: vec![test_initial_endpoint_status( + "obsolete", + "retired.example.com", + "/old", + )], + ..Default::default() + }); + let initial = test_initial_endpoint_status("current", "api.example.com", "/mcp/private/**"); + for (result, time) in [ + (EndpointResult::TransportFailed, "2026-09-05T01:00:00.000Z"), + ( + EndpointResult::HttpResponseReceived, + "2026-09-05T02:00:00.000Z", + ), + ( + EndpointResult::NoObservedExchange, + "2026-09-05T03:00:00.000Z", + ), + ] { + let reports = BTreeMap::from([( + "current".to_string(), + EndpointStatus { + last_result: result as i32, + ..initial.clone() + }, + )]); + let observed = if result == EndpointResult::NoObservedExchange { + HashSet::new() + } else { + HashSet::from(["current".to_string()]) + }; + reconcile_endpoint_statuses(&mut sandbox, &reports, &observed, time); + + let status = sandbox.status.as_ref().expect("status remains present"); + assert_eq!(status.phase, SandboxPhase::Ready as i32); + assert_eq!(status.conditions, vec![ready_condition()]); + assert_eq!( + status.endpoint_statuses, + vec![EndpointStatus { + last_result: result as i32, + last_reported_at: if result == EndpointResult::NoObservedExchange { + String::new() + } else { + time.to_string() + }, + ..initial.clone() + }] + ); + } +} + +#[test] +fn endpoint_reconciliation_advances_only_observed_endpoint_timestamp() { + let mut sandbox = test_sandbox( + "sandbox-id", + "sandbox-name", + ProtoSandboxPolicy::default(), + Vec::new(), + ); + let endpoint_a = EndpointStatus { + last_result: EndpointResult::HttpResponseReceived as i32, + last_reported_at: "2026-09-05T01:01:00.000Z".to_string(), + ..test_initial_endpoint_status("a", "a.example.com", "/**") + }; + let endpoint_b = EndpointStatus { + last_result: EndpointResult::TransportFailed as i32, + last_reported_at: "2026-09-05T01:11:00.000Z".to_string(), + ..test_initial_endpoint_status("b", "b.example.com", "/**") + }; + sandbox.status = Some(SandboxStatus { + endpoint_statuses: vec![endpoint_a.clone(), endpoint_b.clone()], + ..Default::default() + }); + let reports = BTreeMap::from([ + ("a".to_string(), endpoint_a.clone()), + ("b".to_string(), endpoint_b.clone()), + ]); + reconcile_endpoint_statuses( + &mut sandbox, + &reports, + &HashSet::from(["a".to_string()]), + "2026-09-05T02:00:00.000Z", + ); + let status = sandbox.status.expect("status remains present"); + assert_eq!( + status.endpoint_statuses, + vec![ + EndpointStatus { + last_reported_at: "2026-09-05T02:00:00.000Z".to_string(), + ..endpoint_a + }, + endpoint_b + ] + ); +} + +#[test] +fn endpoint_snapshot_requires_marker_for_new_observed_result() { + let mut sandbox = test_sandbox( + "sandbox-id", + "sandbox-name", + ProtoSandboxPolicy::default(), + Vec::new(), + ); + let endpoint = EndpointStatus { + last_result: EndpointResult::HttpResponseReceived as i32, + ..test_initial_endpoint_status("endpoint", "api.example.com", "/**") + }; + let reports = BTreeMap::from([("endpoint".to_string(), endpoint)]); + let error = validate_endpoint_observation_markers(&sandbox, &reports, &HashSet::new()) + .expect_err("a new result requires an observation marker"); + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!( + error.message(), + "a new tool server endpoint result must be marked as observed" + ); + let observed = HashSet::from(["endpoint".to_string()]); + validate_endpoint_observation_markers(&sandbox, &reports, &observed) + .expect("new evidence marked"); + reconcile_endpoint_statuses( + &mut sandbox, + &reports, + &observed, + "2026-09-05T01:00:00.000Z", + ); + validate_endpoint_observation_markers(&sandbox, &reports, &HashSet::new()) + .expect("retained evidence"); + invalidate_endpoint_status_without_session(&mut sandbox); + assert!(validate_endpoint_observation_markers(&sandbox, &reports, &HashSet::new()).is_err()); + reconcile_endpoint_statuses( + &mut sandbox, + &reports, + &observed, + "2026-09-05T02:00:00.000Z", + ); + assert_eq!( + sandbox.status.expect("status").endpoint_statuses[0].last_reported_at, + "2026-09-05T02:00:00.000Z" + ); +} + +#[test] +fn endpoint_reconciliation_initializes_status_name_and_unknown_result() { + let mut sandbox = test_sandbox( + "sandbox-id", + "sandbox-name", + ProtoSandboxPolicy::default(), + Vec::new(), + ); + sandbox.status = None; + let expected_phase = sandbox.phase(); + let expected_policy_version = sandbox.current_policy_version(); + let endpoint = test_initial_endpoint_status("endpoint", "api.example.com", "/**"); + let reports = BTreeMap::from([("endpoint".to_string(), endpoint.clone())]); + reconcile_endpoint_statuses( + &mut sandbox, + &reports, + &HashSet::new(), + "2026-09-05T04:00:00.000Z", + ); + let status = sandbox.status.expect("status initialized"); + assert_eq!(status.endpoint_statuses, vec![endpoint]); + assert_eq!(status.sandbox_name, "sandbox-name"); + assert_eq!(status.phase, expected_phase); + assert_eq!(status.current_policy_version, expected_policy_version); + assert!(status.conditions.is_empty()); +} + +#[tokio::test] +async fn startup_reconciliation_invalidates_status_from_previous_sessions() { + let state = test_server_state().await; + let sandbox_id = "endpoint-startup-invalidation"; + let mut sandbox = test_sandbox( + sandbox_id, + sandbox_id, + mcp_policy_with_versions(&["2025-11-25"]), + Vec::new(), + ); + let initial = test_initial_endpoint_status("old-session", "api.example.com", "/mcp"); + sandbox.status = Some(SandboxStatus { + sandbox_name: sandbox_id.to_string(), + endpoint_statuses: vec![EndpointStatus { + last_result: EndpointResult::HttpResponseReceived as i32, + last_reported_at: "2026-09-05T01:01:00.000Z".to_string(), + ..initial.clone() + }], + conditions: vec![ready_condition()], + ..Default::default() + }); + state + .store + .put_message(&sandbox) + .await + .expect("store prior session status"); + invalidate_endpoint_status_on_startup(&state) + .await + .expect("startup reconciliation"); + let status = stored_sandbox(&state, sandbox_id) + .await + .status + .expect("status remains present"); + assert_eq!(status.endpoint_statuses, vec![initial]); + assert_eq!(status.conditions, vec![ready_condition()]); +} + +#[tokio::test] +async fn report_endpoint_status_is_session_bound_and_retry_idempotent() { + let state = test_server_state().await; + let sandbox_id = "endpoint-session"; + let mut policy = mcp_policy_with_versions(&["2025-11-25"]); + let endpoints = &mut policy + .network_policies + .get_mut("mcp") + .expect("MCP rule") + .endpoints; + endpoints[0].path = "/mcp/observed/**".to_string(); + let mut unobserved = endpoints[0].clone(); + unobserved.path = "/mcp/unobserved/**".to_string(); + endpoints.push(unobserved); + let mut sandbox = test_sandbox(sandbox_id, sandbox_id, policy, Vec::new()); + sandbox.status = Some(SandboxStatus { + sandbox_name: sandbox_id.to_string(), + phase: SandboxPhase::Ready as i32, + conditions: vec![ready_condition()], + ..Default::default() + }); + state + .store + .put_message(&sandbox) + .await + .expect("store sandbox"); + let config = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + sandbox_id: sandbox_id.to_string(), + }), + sandbox_id, + ), + ) + .await + .expect("sandbox config") + .into_inner(); + let endpoints = &config.policy.as_ref().expect("policy").network_policies["mcp"].endpoints; + let observed_id = endpoint_id(&endpoints[0]); + let unobserved_id = endpoint_id(&endpoints[1]); + assert_ne!(observed_id, unobserved_id); + let mut initial = endpoints + .iter() + .map(initial_endpoint_status) + .collect::>(); + initial.sort_by(|left, right| left.endpoint_id.cmp(&right.endpoint_id)); + + register_session(&state, sandbox_id, "session-a"); + reset_endpoint_status_for_supervisor_session(&state, sandbox_id, "session-a") + .await + .expect("session reset"); + assert!( + state + .supervisor_sessions + .initialize_endpoint_status_authority(sandbox_id, "session-a") + ); + let initial_status = public_status(&state, sandbox_id).await; + assert_eq!(initial_status.endpoint_statuses, initial); + assert_eq!(initial_status.conditions, vec![ready_condition()]); + + let report = ReportEndpointStatusRequest { + sandbox_id: sandbox_id.to_string(), + policy_hash: config.policy_hash, + provider_env_revision: config.provider_env_revision, + observations: vec![ + EndpointObservation { + endpoint_id: observed_id.clone(), + result: EndpointResult::TransportFailed as i32, + }, + EndpointObservation { + endpoint_id: unobserved_id, + result: EndpointResult::NoObservedExchange as i32, + }, + ], + observed_endpoint_ids: vec![observed_id.clone()], + supervisor_session_id: "session-a".to_string(), + report_sequence: 1, + }; + handle_report_endpoint_status( + &state, + with_sandbox(Request::new(report.clone()), sandbox_id), + ) + .await + .expect("first report"); + let first = stored_sandbox(&state, sandbox_id).await; + let public = public_status(&state, sandbox_id).await; + assert_eq!(public.phase, SandboxPhase::Ready as i32); + assert_eq!(public.conditions, vec![ready_condition()]); + let mut expected = initial.clone(); + let observed = expected + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == observed_id) + .expect("observed endpoint"); + observed.last_result = EndpointResult::TransportFailed as i32; + observed.last_reported_at.clone_from( + &public + .endpoint_statuses + .iter() + .find(|endpoint| endpoint.endpoint_id == observed_id) + .expect("public endpoint") + .last_reported_at, + ); + assert!(!observed.last_reported_at.is_empty()); + assert_eq!(public.endpoint_statuses, expected); + + handle_report_endpoint_status( + &state, + with_sandbox(Request::new(report.clone()), sandbox_id), + ) + .await + .expect("identical retry"); + assert_eq!(stored_sandbox(&state, sandbox_id).await, first); + let mut conflicting_retry = report.clone(); + conflicting_retry.observations[0].result = EndpointResult::UpstreamRejected as i32; + let error = handle_report_endpoint_status( + &state, + with_sandbox(Request::new(conflicting_retry), sandbox_id), + ) + .await + .expect_err("sequence body is immutable"); + assert_eq!(error.code(), Code::InvalidArgument); + + let mut newer = report.clone(); + newer.report_sequence = 9; + newer.observations[0].result = EndpointResult::HttpResponseReceived as i32; + handle_report_endpoint_status( + &state, + with_sandbox(Request::new(newer.clone()), sandbox_id), + ) + .await + .expect("new complete snapshot with a sequence gap"); + let newest = stored_sandbox(&state, sandbox_id).await; + let recovered = public_status(&state, sandbox_id).await; + assert_eq!(recovered.phase, SandboxPhase::Ready as i32); + assert_eq!(recovered.conditions, vec![ready_condition()]); + assert_eq!( + recovered + .endpoint_statuses + .iter() + .find(|endpoint| endpoint.endpoint_id == observed_id) + .expect("recovered endpoint") + .last_result, + EndpointResult::HttpResponseReceived as i32 + ); + let error = handle_report_endpoint_status( + &state, + with_sandbox(Request::new(report.clone()), sandbox_id), + ) + .await + .expect_err("older sequence rejected"); + assert_eq!(error.code(), Code::FailedPrecondition); + handle_report_endpoint_status(&state, with_sandbox(Request::new(newer), sandbox_id)) + .await + .expect("newest identical retry"); + assert_eq!(stored_sandbox(&state, sandbox_id).await, newest); + + assert!( + state + .supervisor_sessions + .remove_if_current(sandbox_id, "session-a") + .is_some() + ); + reset_endpoint_status_after_supervisor_disconnect(&state, sandbox_id) + .await + .expect("disconnect reset"); + let disconnected = public_status(&state, sandbox_id).await; + assert_eq!(disconnected.endpoint_statuses, initial); + assert_eq!(disconnected.conditions, vec![ready_condition()]); + register_session(&state, sandbox_id, "session-b"); + reset_endpoint_status_for_supervisor_session(&state, sandbox_id, "session-b") + .await + .expect("replacement reset"); + assert!( + state + .supervisor_sessions + .initialize_endpoint_status_authority(sandbox_id, "session-b") + ); + let error = + handle_report_endpoint_status(&state, with_sandbox(Request::new(report), sandbox_id)) + .await + .expect_err("superseded session rejected"); + assert_eq!(error.code(), Code::PermissionDenied); + assert_eq!( + public_status(&state, sandbox_id).await.endpoint_statuses, + initial + ); +} + +#[tokio::test] +async fn loaded_policy_and_unknown_endpoint_inventory_commit_atomically() { + let state = test_server_state().await; + let sandbox_id = "endpoint-policy-activation"; + let mut old_policy = mcp_policy_with_versions(&["2025-11-25"]); + old_policy + .network_policies + .get_mut("mcp") + .expect("MCP policy") + .endpoints[0] + .host = "retired.example.com".to_string(); + let initial_old = initial_endpoint_status(&old_policy.network_policies["mcp"].endpoints[0]); + let mut sandbox = test_sandbox(sandbox_id, sandbox_id, old_policy, Vec::new()); + sandbox.status = Some(SandboxStatus { + sandbox_name: sandbox_id.to_string(), + current_policy_version: 0, + endpoint_statuses: vec![EndpointStatus { + last_result: EndpointResult::HttpResponseReceived as i32, + last_reported_at: "2026-09-05T01:00:00.000Z".to_string(), + ..initial_old + }], + ..Default::default() + }); + state + .store + .put_message(&sandbox) + .await + .expect("store sandbox"); + let active_policy = mcp_policy_with_versions(&["2025-11-25"]); + let policy_hash = deterministic_policy_hash(&active_policy); + let initial_active = + initial_endpoint_status(&active_policy.network_policies["mcp"].endpoints[0]); + state + .store + .put_policy_revision( + "active-revision", + sandbox_id, + "default", + 1, + &active_policy.encode_to_vec(), + &policy_hash, + ) + .await + .expect("store active revision"); + let mut pending_policy = active_policy; + pending_policy + .network_policies + .get_mut("mcp") + .expect("MCP policy") + .endpoints[0] + .host = "pending.example.com".to_string(); + let initial_pending = + initial_endpoint_status(&pending_policy.network_policies["mcp"].endpoints[0]); + state + .store + .put_policy_revision( + "pending-revision", + sandbox_id, + "default", + 2, + &pending_policy.encode_to_vec(), + &deterministic_policy_hash(&pending_policy), + ) + .await + .expect("store pending revision"); + + handle_report_policy_status( + &state, + with_sandbox( + Request::new(ReportPolicyStatusRequest { + sandbox_id: sandbox_id.to_string(), + version: 1, + status: PolicyStatus::Loaded as i32, + load_error: String::new(), + }), + sandbox_id, + ), + ) + .await + .expect("activate acknowledged policy"); + let stored = stored_sandbox(&state, sandbox_id).await; + assert_eq!(stored.current_policy_version(), 1); + let context = active_endpoint_context(&state, &stored) + .await + .expect("active endpoint context"); + assert_eq!(context.policy_hash, policy_hash); + assert_eq!( + context.endpoints.keys().cloned().collect::>(), + vec![initial_active.endpoint_id.clone()] + ); + let status = stored.status.expect("status"); + assert_eq!(status.current_policy_version, 1); + assert_eq!(status.endpoint_statuses, vec![initial_active.clone()]); + + register_session(&state, sandbox_id, "active-session"); + reset_endpoint_status_for_supervisor_session(&state, sandbox_id, "active-session") + .await + .expect("reset active endpoints"); + assert!( + state + .supervisor_sessions + .initialize_endpoint_status_authority(sandbox_id, "active-session") + ); + assert_eq!( + stored_sandbox(&state, sandbox_id) + .await + .status + .expect("reset status") + .endpoint_statuses, + vec![initial_active.clone()] + ); + handle_report_endpoint_status( + &state, + with_sandbox( + Request::new(ReportEndpointStatusRequest { + sandbox_id: sandbox_id.to_string(), + policy_hash: context.policy_hash, + provider_env_revision: context.provider_env_revision, + observations: vec![EndpointObservation { + endpoint_id: initial_active.endpoint_id.clone(), + result: EndpointResult::HttpResponseReceived as i32, + }], + observed_endpoint_ids: vec![initial_active.endpoint_id.clone()], + supervisor_session_id: "active-session".to_string(), + report_sequence: 1, + }), + sandbox_id, + ), + ) + .await + .expect("active revision accepts reports while next policy is pending"); + let reported = stored_sandbox(&state, sandbox_id) + .await + .status + .expect("reported status"); + assert_eq!(reported.endpoint_statuses.len(), 1); + let endpoint = &reported.endpoint_statuses[0]; + assert!(!endpoint.last_reported_at.is_empty()); + assert_eq!( + endpoint, + &EndpointStatus { + last_result: EndpointResult::HttpResponseReceived as i32, + last_reported_at: endpoint.last_reported_at.clone(), + ..initial_active + } + ); + + // Only a load acknowledgement advances the active version and endpoint + // inventory; storing a pending policy cannot change either. + handle_report_policy_status( + &state, + with_sandbox( + Request::new(ReportPolicyStatusRequest { + sandbox_id: sandbox_id.to_string(), + version: 2, + status: PolicyStatus::Loaded as i32, + load_error: String::new(), + }), + sandbox_id, + ), + ) + .await + .expect("activate pending policy"); + let replaced = stored_sandbox(&state, sandbox_id) + .await + .status + .expect("replacement status"); + assert_eq!(replaced.current_policy_version, 2); + assert_eq!(replaced.endpoint_statuses, vec![initial_pending]); + + let mut empty_policy = pending_policy; + empty_policy.network_policies.clear(); + state + .store + .put_policy_revision( + "empty-revision", + sandbox_id, + "default", + 3, + &empty_policy.encode_to_vec(), + &deterministic_policy_hash(&empty_policy), + ) + .await + .expect("store empty inventory policy"); + handle_report_policy_status( + &state, + with_sandbox( + Request::new(ReportPolicyStatusRequest { + sandbox_id: sandbox_id.to_string(), + version: 3, + status: PolicyStatus::Loaded as i32, + load_error: String::new(), + }), + sandbox_id, + ), + ) + .await + .expect("activate empty inventory"); + let cleared = stored_sandbox(&state, sandbox_id) + .await + .status + .expect("cleared status"); + assert_eq!(cleared.current_policy_version, 3); + assert!(cleared.endpoint_statuses.is_empty()); + assert_eq!(cleared.conditions, replaced.conditions); + assert_eq!(cleared.phase, replaced.phase); +} diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 849123106..e3cb3f6b2 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -3720,6 +3720,10 @@ pub(super) async fn handle_update_provider( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; + // Provider material contributes to the route-report configuration epoch. + // Serialize its mutation with route-status validation so a report derived + // from the prior revision cannot commit after this update. + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index d428e6456..83e6358df 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -516,10 +516,13 @@ async fn handle_create_sandbox_inner( None => None, }; - let sandbox = state + let mut sandbox = state .compute .create_sandbox(sandbox, sandbox_token, await_main_process_attachment) .await?; + state + .supervisor_sessions + .project_endpoint_status(&mut sandbox); info!( sandbox_id = %id, @@ -687,7 +690,10 @@ pub(super) async fn handle_get_sandbox( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; - let sandbox = sandbox.ok_or_else(|| Status::not_found("sandbox not found"))?; + let mut sandbox = sandbox.ok_or_else(|| Status::not_found("sandbox not found"))?; + state + .supervisor_sessions + .project_endpoint_status(&mut sandbox); Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), })) @@ -738,12 +744,17 @@ pub(super) async fn handle_list_sandboxes( label_selector: selector, }, }; - let page = state + let mut page = state .store .list_message_page::(query, after.as_ref(), pagination.page_size()) .await .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; let next_page_token = pagination.next_object_token(page.next_cursor.as_ref()); + // Project only the selected page so observation authority cannot change + // the store's ordering or continuation cursor. + for sandbox in &mut page.messages { + state.supervisor_sessions.project_endpoint_status(sandbox); + } Ok(Response::new(ListSandboxesResponse { sandboxes: page.messages, next_page_token, @@ -1151,7 +1162,7 @@ pub(super) async fn handle_attach_sandbox_provider( let attached = Arc::new(AtomicBool::new(false)); let attached_clone = attached.clone(); - let sandbox = state + let mut sandbox = state .store .update_message_cas::( &sandbox_id, @@ -1173,6 +1184,9 @@ pub(super) async fn handle_attach_sandbox_provider( ) .await .map_err(|e| super::persistence_error_to_status(e, "attach sandbox provider"))?; + state + .supervisor_sessions + .project_endpoint_status(&mut sandbox); let attached = attached.load(Ordering::Relaxed); @@ -1250,7 +1264,7 @@ pub(super) async fn handle_detach_sandbox_provider( let detached = Arc::new(AtomicBool::new(false)); let detached_clone = detached.clone(); - let sandbox = state + let mut sandbox = state .store .update_message_cas::( &sandbox_id, @@ -1272,6 +1286,9 @@ pub(super) async fn handle_detach_sandbox_provider( ) .await .map_err(|e| super::persistence_error_to_status(e, "detach sandbox provider"))?; + state + .supervisor_sessions + .project_endpoint_status(&mut sandbox); let detached = detached.load(Ordering::Relaxed); @@ -1374,7 +1391,10 @@ async fn handle_stop_sandbox_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; - let sandbox = state.compute.stop_sandbox(&workspace, &req.name).await?; + let mut sandbox = state.compute.stop_sandbox(&workspace, &req.name).await?; + state + .supervisor_sessions + .project_endpoint_status(&mut sandbox); info!(sandbox_name = %req.name, "StopSandbox request completed successfully"); Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), @@ -1418,7 +1438,10 @@ async fn handle_start_sandbox_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; - let sandbox = state.compute.start_sandbox(&workspace, &req.name).await?; + let mut sandbox = state.compute.start_sandbox(&workspace, &req.name).await?; + state + .supervisor_sessions + .project_endpoint_status(&mut sandbox); info!(sandbox_name = %req.name, "StartSandbox request completed successfully"); Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), @@ -1558,7 +1581,10 @@ pub(super) async fn handle_watch_sandbox( // Re-read the snapshot now that we have subscriptions active. match state.store.get_message::(&sandbox_id).await { - Ok(Some(sandbox)) => { + Ok(Some(mut sandbox)) => { + state + .supervisor_sessions + .project_endpoint_status(&mut sandbox); state.sandbox_index.update_from_sandbox(&sandbox); let _ = tx .send(Ok(SandboxStreamEvent { @@ -1640,7 +1666,10 @@ pub(super) async fn handle_watch_sandbox( match res { Ok(()) => { match state.store.get_message::(&sandbox_id).await { - Ok(Some(sandbox)) => { + Ok(Some(mut sandbox)) => { + state + .supervisor_sessions + .project_endpoint_status(&mut sandbox); state.sandbox_index.update_from_sandbox(&sandbox); if tx.send(Ok(SandboxStreamEvent { payload: Some(openshell_core::proto::sandbox_stream_event::Payload::Sandbox(sandbox.clone()))})).await.is_err() { return; @@ -6321,6 +6350,110 @@ mod tests { ); } + #[tokio::test] + async fn list_sandboxes_projects_endpoint_status_across_pages() { + use openshell_core::proto::{EndpointResult, EndpointStatus}; + + let state = test_server_state().await; + let endpoint = EndpointStatus { + endpoint_id: "endpoint:v1:tools".to_string(), + host: "tools.example.com".to_string(), + ports: vec![443], + path: "/mcp".to_string(), + last_result: EndpointResult::HttpResponseReceived as i32, + last_reported_at: "2026-09-15T01:00:00.000Z".to_string(), + }; + for name in ["alpha", "beta", "gamma"] { + let mut sandbox = test_sandbox(name, Vec::new()); + sandbox + .status + .as_mut() + .expect("sandbox status") + .endpoint_statuses = vec![endpoint.clone()]; + state.store.put_message(&sandbox).await.unwrap(); + } + + // Only alpha has a live observation authority; the other persisted + // results must be reset in responses, including on the second page. + let (session_tx, _session_rx) = mpsc::channel(1); + let (shutdown_tx, _shutdown_rx) = oneshot::channel(); + state.supervisor_sessions.register( + "sandbox-alpha".to_string(), + "session-alpha".to_string(), + session_tx, + shutdown_tx, + ); + assert!( + state + .supervisor_sessions + .initialize_endpoint_status_authority("sandbox-alpha", "session-alpha") + ); + + let request = ListSandboxesRequest { + page_size: 2, + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }; + let first_page = handle_list_sandboxes(&state, authed_request(request.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!( + first_page + .sandboxes + .iter() + .map(ObjectName::object_name) + .collect::>(), + ["alpha", "beta"] + ); + assert!(!first_page.next_page_token.is_empty()); + let second_page = handle_list_sandboxes( + &state, + authed_request(ListSandboxesRequest { + page_token: first_page.next_page_token, + ..request + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(second_page.sandboxes.len(), 1); + assert_eq!(second_page.sandboxes[0].object_name(), "gamma"); + assert!(second_page.next_page_token.is_empty()); + + let unknown = EndpointStatus { + last_result: EndpointResult::NoObservedExchange as i32, + last_reported_at: String::new(), + ..endpoint.clone() + }; + for (sandbox, expected_endpoint) in [ + (&first_page.sandboxes[0], &endpoint), + (&first_page.sandboxes[1], &unknown), + (&second_page.sandboxes[0], &unknown), + ] { + let status = sandbox.status.as_ref().expect("sandbox status"); + assert_eq!(status.phase, SandboxPhase::Ready as i32); + assert_eq!( + status.endpoint_statuses.as_slice(), + std::slice::from_ref(expected_endpoint) + ); + } + // Read-time projection must not erase persisted reports. + let persisted = state + .store + .get_message::("sandbox-gamma") + .await + .unwrap() + .expect("persisted sandbox"); + assert_eq!( + persisted + .status + .expect("persisted status") + .endpoint_statuses, + [endpoint] + ); + } + #[tokio::test] async fn sandbox_crud_is_workspace_isolated() { use crate::persistence::ObjectType; diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index f76d8d02e..7ea7711a9 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -694,6 +694,14 @@ pub(crate) async fn run_server( error.message() )) })?; + grpc::policy::invalidate_endpoint_status_on_startup(&state) + .await + .map_err(|error| { + Error::execution(format!( + "tool server endpoint-status startup reconciliation failed: {}", + error.message() + )) + })?; let gateway_listeners = bind_gateway_listeners( config.bind_address, diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index f322be0dd..1fcf84eeb 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -1117,6 +1117,15 @@ async fn apply_minted_credential( updated.credential_expires_at_ms.remove(key); } } + // Acquire the shared sandbox mutation boundary only around validation and + // persistence, after any remote minting or credential staging. This + // prevents route status from committing against the old provider revision + // after the rotation writes, without holding the guard across network I/O. + let _sandbox_sync_guard = if let Some(compute) = compute { + Some(compute.sandbox_sync_guard().await) + } else { + None + }; if let Err(err) = crate::grpc::provider::validate_provider_update_against_attached_sandboxes( store, workspace, &updated, ) diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index a1beb39ca..eb5b77d89 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -119,11 +119,14 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "79c72615d957fc0653c672f61998bf7d8d21b757bc05d07b3fff92bd70fc8f52"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "8ac68c71d93e6a5e56406b8df1882ee40c6066270969e03eb99803f0e6396fc1"; + "26a2882f1fc82741f4821da90fb9674c9b5d66991760398a93a90354b7349d83"; const DURABLE_SCHEMA_SHA256: &str = - "369b36511c2e38b9df9621704a00123516c7538d8ee89a499158d7de5cee1882"; + "568ec5637c504726b40a616d286457f41b5be2f4761872c749313e1ee16b5c85"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "05add438ba041defc98d791038ae593d3f09352677cae43f2276d494205ce415"; + "f96d841e67da5c3443fa0aca15936dd14ac30d2e150ddf0439b0d195c4a0cfd9"; + // A persisted Sandbox without endpoint status retains its lifecycle fields; + // the absent repeated field decodes empty and needs no database rewrite. + const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964"; // Synthetic payloads generated with the public declarations at v0.0.116, // before their relocation into openshell.storage.v1. Values are deliberately // non-secret and the ordinary protobuf bytes contain no package names. @@ -447,14 +450,14 @@ mod tests { } } methods.sort(); - assert_eq!(compiled_method_count, 100, "classify every compiled RPC"); - assert_eq!(methods.len(), 74, "inventory every public gateway RPC"); + assert_eq!(compiled_method_count, 101, "classify every compiled RPC"); + assert_eq!(methods.len(), 75, "inventory every public gateway RPC"); assert_eq!( methods .iter() .filter(|method| method.starts_with("openshell.v1.OpenShell/")) .count(), - 74 + 75 ); assert!(methods.iter().all(|method| !method.contains(".storage."))); @@ -487,13 +490,13 @@ mod tests { assert_eq!( (public_closure.messages.len(), public_closure.enums.len()), - (278, 12) + (282, 13) ); assert_eq!( (durable_closure.messages.len(), durable_closure.enums.len()), - (81, 8) + (82, 9) ); - assert_eq!((overlap_messages.len(), overlap_enums.len()), (71, 8)); + assert_eq!((overlap_messages.len(), overlap_enums.len()), (72, 9)); assert_eq!( public_inventory_hash, PUBLIC_RPC_SCHEMA_SHA256, @@ -513,6 +516,28 @@ mod tests { hex::decode(encoded).expect("checked-in legacy fixture must be valid hex") } + #[test] + fn sandbox_payload_without_endpoint_status_decodes() { + use openshell_core::proto::{Sandbox, SandboxPhase}; + + let sandbox = Sandbox::decode(legacy_bytes(SANDBOX_WITHOUT_ENDPOINT_STATUS).as_slice()) + .expect("stored sandbox without endpoint status must decode"); + let metadata = sandbox.metadata.expect("sandbox metadata"); + assert_eq!(metadata.id, "sandbox-id"); + assert_eq!(metadata.name, "sandbox"); + assert_eq!(metadata.workspace, "default"); + let status = sandbox.status.expect("sandbox status"); + assert_eq!(status.sandbox_name, "sandbox"); + assert_eq!(status.phase(), SandboxPhase::Ready); + assert_eq!(status.current_policy_version, 7); + assert_eq!(status.main_process_instance_id, "supervisor-id"); + assert_eq!(status.exit_code, None); + assert_eq!(status.conditions.len(), 1); + assert_eq!(status.conditions[0].r#type, "Ready"); + assert_eq!(status.conditions[0].status, "True"); + assert!(status.endpoint_statuses.is_empty()); + } + #[test] fn pre_move_storage_payloads_decode_after_package_relocation() { let refresh = StoredProviderCredentialRefreshState::decode( diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 24413f0f1..09da9cf43 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -22,6 +22,7 @@ use openshell_core::transport_errors::is_expected_transport_close_status; use crate::ServerState; use crate::auth::principal::Principal; +use crate::persistence::ObjectId; const HEARTBEAT_INTERVAL_SECS: u32 = 15; const RELAY_PENDING_TIMEOUT: Duration = Duration::from_secs(10); @@ -61,10 +62,33 @@ struct LiveSession { /// Set after the supervisor confirms that every expected foreground /// attachment has closed and terminal output delivery is complete. terminal_delivery_finalized: bool, + /// Becomes true only after the gateway durably resets endpoint status for + /// this session and before it sends `SessionAccepted`. + endpoint_status_initialized: bool, + /// Last tool server endpoint-status batch committed for this authenticated session. + /// + /// The cursor is session authority state, not public sandbox status. A + /// gateway restart invalidates every session and startup reconciliation + /// resets any persisted endpoint result before requests are served. + endpoint_report_cursor: Option, #[allow(dead_code)] connected_at: Instant, } +/// Idempotency state for tool server endpoint-status reports from one live supervisor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EndpointReportCursor { + /// Active effective policy represented by the accepted report sequence. + pub(crate) policy_hash: String, + /// Provider environment revision represented by the accepted sequence. + pub(crate) provider_env_revision: u64, + /// Last accepted sequence in this session; superseded snapshots may leave gaps. + pub(crate) report_sequence: u64, + /// Digest of the accepted request, used to reject a different body that + /// reuses an already committed sequence number. + pub(crate) report_digest: [u8; 32], +} + /// Holds a oneshot sender that will deliver the upgraded relay stream or a /// target-open failure reported by the supervisor. type RelayStreamSender = oneshot::Sender>; @@ -129,6 +153,8 @@ impl SupervisorSessionRegistry { tx, shutdown, terminal_delivery_finalized: false, + endpoint_status_initialized: false, + endpoint_report_cursor: None, connected_at: Instant::now(), }, ); @@ -167,7 +193,7 @@ impl SupervisorSessionRegistry { /// This guards against the supersede race: an old session's task may /// finish long after a new session has taken its place. The old task's /// cleanup must not evict the new registration. - fn remove_if_current(&self, sandbox_id: &str, session_id: &str) -> Option { + pub(crate) fn remove_if_current(&self, sandbox_id: &str, session_id: &str) -> Option { let mut sessions = self.sessions.lock().unwrap(); let is_current = sessions .get(sandbox_id) @@ -241,6 +267,107 @@ impl SupervisorSessionRegistry { .is_some_and(|session| session.session_id == session_id) } + /// Mark the current session as the observation authority after its + /// public endpoint results have been durably reset. + pub(crate) fn initialize_endpoint_status_authority( + &self, + sandbox_id: &str, + session_id: &str, + ) -> bool { + let mut sessions = self.sessions.lock().unwrap(); + let Some(session) = sessions + .get_mut(sandbox_id) + .filter(|session| session.session_id == session_id) + else { + return false; + }; + session.endpoint_status_initialized = true; + true + } + + /// Return whether the named session has completed endpoint status + /// initialization and still owns reporting authority. + pub(crate) fn is_endpoint_status_authority(&self, sandbox_id: &str, session_id: &str) -> bool { + self.sessions + .lock() + .unwrap() + .get(sandbox_id) + .is_some_and(|session| { + session.session_id == session_id && session.endpoint_status_initialized + }) + } + + /// Fail closed when projecting persisted endpoint status without a live, + /// initialized observation authority in the current gateway process. + pub(crate) fn project_endpoint_status(&self, sandbox: &mut Sandbox) { + let sandbox_id = sandbox.object_id(); + let has_authority = self + .sessions + .lock() + .unwrap() + .get(sandbox_id) + .is_some_and(|session| session.endpoint_status_initialized); + if has_authority { + return; + } + let Some(status) = sandbox.status.as_mut() else { + return; + }; + // Status without its live observation authority is unknown. Keep the + // configured address so a caller can still identify each endpoint. + for endpoint in &mut status.endpoint_statuses { + endpoint.last_result = openshell_core::proto::EndpointResult::NoObservedExchange as i32; + endpoint.last_reported_at.clear(); + } + } + + /// Return the active supervisor session identifier for gateway-owned + /// status reconciliation. + pub fn current_session_id(&self, sandbox_id: &str) -> Option { + self.sessions + .lock() + .unwrap() + .get(sandbox_id) + .map(|session| session.session_id.clone()) + } + + /// Return the endpoint report cursor only when `session_id` still owns the + /// sandbox. Replacement sessions never inherit predecessor sequencing. + pub(crate) fn endpoint_report_cursor( + &self, + sandbox_id: &str, + session_id: &str, + ) -> Option { + self.sessions + .lock() + .unwrap() + .get(sandbox_id) + .filter(|session| session.session_id == session_id) + .and_then(|session| session.endpoint_report_cursor.clone()) + } + + /// Record a committed endpoint report for the current session. + /// + /// Returns `false` when a reconnect replaced the caller while its storage + /// write was in flight. The replacement performs its own pre-acknowledgment + /// reset, so it remains the sole observation authority. + pub(crate) fn commit_endpoint_report_cursor( + &self, + sandbox_id: &str, + session_id: &str, + cursor: EndpointReportCursor, + ) -> bool { + let mut sessions = self.sessions.lock().unwrap(); + let Some(session) = sessions + .get_mut(sandbox_id) + .filter(|session| session.session_id == session_id) + else { + return false; + }; + session.endpoint_report_cursor = Some(cursor); + true + } + fn pending_channel_ids(&self, sandbox_id: &str) -> Vec { self.pending_relays .lock() @@ -769,6 +896,30 @@ pub async fn handle_connect_supervisor( ); } + // A replacement stream is a new observation authority. Reset its endpoint + // results before acknowledging the session so it cannot inherit evidence + // reported by the superseded stream. + if let Err(error) = crate::grpc::policy::reset_endpoint_status_for_supervisor_session( + state, + &sandbox_id, + &session_id, + ) + .await + { + state + .supervisor_sessions + .remove_if_current(&sandbox_id, &session_id); + return Err(error); + } + if !state + .supervisor_sessions + .initialize_endpoint_status_authority(&sandbox_id, &session_id) + { + return Err(Status::failed_precondition( + "supervisor session was replaced during endpoint status initialization", + )); + } + // Step 3: Send SessionAccepted. let accepted = GatewayMessage { payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted { @@ -828,6 +979,12 @@ pub async fn handle_connect_supervisor( state_clone .telemetry .sandbox_session_disconnected(&sandbox_id_clone); + tokio::spawn( + crate::grpc::policy::retry_endpoint_status_after_supervisor_disconnect( + Arc::clone(&state_clone), + sandbox_id_clone.clone(), + ), + ); if let Err(err) = state_clone .compute .supervisor_session_disconnected(&sandbox_id_clone, terminal_finalized) @@ -1082,6 +1239,64 @@ mod tests { } } + #[test] + fn endpoint_status_projection_requires_initialized_live_authority() { + use openshell_core::proto::{ + EndpointResult, EndpointStatus, SandboxCondition, SandboxStatus, + }; + + let registry = SupervisorSessionRegistry::new(); + let mut sandbox = sandbox_record("sandbox-1", "sandbox-1"); + let endpoint = EndpointStatus { + endpoint_id: "endpoint:v1:test".to_string(), + host: "api.example.com".to_string(), + ports: vec![443], + path: "/mcp".to_string(), + last_result: EndpointResult::HttpResponseReceived as i32, + last_reported_at: "2026-09-05T01:01:00.000Z".to_string(), + }; + let ready = SandboxCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + ..Default::default() + }; + sandbox.status = Some(SandboxStatus { + endpoint_statuses: vec![endpoint.clone()], + conditions: vec![ready.clone()], + ..Default::default() + }); + let unknown = EndpointStatus { + last_result: EndpointResult::NoObservedExchange as i32, + last_reported_at: String::new(), + ..endpoint.clone() + }; + + let mut without_session = sandbox.clone(); + registry.project_endpoint_status(&mut without_session); + let projected_status = without_session.status.expect("status"); + assert_eq!(projected_status.endpoint_statuses, vec![unknown.clone()]); + assert_eq!(projected_status.conditions, vec![ready.clone()]); + + let (session_tx, _session_rx) = mpsc::channel(1); + registry.register( + "sandbox-1".to_string(), + "session-1".to_string(), + session_tx, + make_shutdown(), + ); + let mut before_initialization = sandbox.clone(); + registry.project_endpoint_status(&mut before_initialization); + let uninitialized_status = before_initialization.status.expect("status"); + assert_eq!(uninitialized_status.endpoint_statuses, vec![unknown]); + assert_eq!(uninitialized_status.conditions, vec![ready.clone()]); + + assert!(registry.initialize_endpoint_status_authority("sandbox-1", "session-1")); + registry.project_endpoint_status(&mut sandbox); + let initialized_status = sandbox.status.expect("status"); + assert_eq!(initialized_status.endpoint_statuses, vec![endpoint]); + assert_eq!(initialized_status.conditions, vec![ready]); + } + fn sandbox_principal(sandbox_id: &str) -> Principal { Principal::Sandbox(SandboxPrincipal { sandbox_id: sandbox_id.to_string(), diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 695d8d8f2..fe159dcf6 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -53,6 +53,15 @@ pub struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ReportEndpointStatusResponse {}, + )) + } + async fn begin_rootfs_tar_staging( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 448ae2cc7..448f3e476 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -48,6 +48,15 @@ struct RelayGateway { #[tonic::async_trait] impl OpenShell for RelayGateway { + async fn report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ReportEndpointStatusResponse {}, + )) + } + async fn begin_rootfs_tar_staging( &self, _request: tonic::Request, diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 77e4497d4..2085b4668 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -21,11 +21,18 @@ pub mod tls; pub(crate) mod token_grant_injection; pub(crate) mod websocket; +use openshell_core::endpoint_status::{ + EndpointObservationContext, EndpointObservationHandle, EndpointObservationSender, + EndpointResult, +}; use openshell_core::mcp::McpProtocolVersion; pub use openshell_policy::L7Protocol; use openshell_policy::{ L7EndpointFields, validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, }; +use std::sync::{Arc, Mutex}; + +use crate::opa::PolicyGenerationGuard; pub(crate) fn build_credential_endpoint_mismatch_finding( policy_name: &str, @@ -108,6 +115,17 @@ pub enum EnforcementMode { #[derive(Debug, Clone)] pub struct L7EndpointConfig { pub protocol: L7Protocol, + /// Opaque endpoint identity derived from the matched policy endpoint. + /// + /// The value is added to the supervisor's internal OPA data and carried + /// through path selection unchanged. It is never derived from the concrete + /// request host because that host may have matched a wildcard selector. + pub endpoint_id: String, + /// Hash of the complete canonical policy that supplied this endpoint. + /// + /// Internal OPA metadata binds a selected endpoint to its observation + /// inventory even while policy activation and inventory publication overlap. + pub policy_hash: String, /// Optional endpoint-level HTTP path glob used to select between L7 /// protocols that share the same host:port. pub path: String, @@ -151,6 +169,102 @@ pub struct L7EndpointConfig { pub signing_region: String, } +/// Report the first network result for a selected tool endpoint. +/// +/// Clones share the same pending handle, so only the first terminal result is +/// accepted. The lock is never held across an await, and reporting remains +/// nonblocking because the core sender uses a bounded `try_send` path. +#[derive(Clone)] +pub(crate) struct EndpointObserver { + sender: EndpointObservationSender, + observation: Arc>>, + uses_provider_credentials: bool, + policy_generation: Option, +} + +impl EndpointObserver { + /// Bind a selected endpoint to authority captured before request-state lookup. + /// + /// Selection, provider material, and observation authority must describe the + /// same installation. A stale policy selection never acquires a new epoch. + pub(crate) fn begin_captured( + sender: Option<&EndpointObservationSender>, + config: &L7EndpointConfig, + context: Option<&EndpointObservationContext>, + provider_revision: Option, + guard: Option<&PolicyGenerationGuard>, + ) -> Option { + if config.protocol != L7Protocol::Mcp + || config.endpoint_id.is_empty() + || config.policy_hash.is_empty() + { + // Only internal metadata from a complete MCP policy identifies + // the endpoint and configuration whose result can be published. + return None; + } + if guard.is_some_and(PolicyGenerationGuard::is_stale) { + // Equal policy hashes can recur after a reload; the pinned guard + // prevents an earlier selection from becoming current again. + return None; + } + let sender = sender?.clone(); + let observation = sender.begin_captured( + context?, + config.endpoint_id.clone(), + &config.policy_hash, + provider_revision, + )?; + Some(Self { + sender, + observation: Arc::new(Mutex::new(Some(observation))), + uses_provider_credentials: config.provider_credentialed, + policy_generation: guard.cloned(), + }) + } + + /// Capture and bind an observation synchronously for isolated unit fixtures. + #[cfg(test)] + pub(crate) fn begin( + sender: Option<&EndpointObservationSender>, + config: &L7EndpointConfig, + ) -> Option { + let context = sender.and_then(EndpointObservationSender::capture); + Self::begin_captured(sender, config, context.as_ref(), None, None) + } + + /// Report the first terminal exchange result, dropping later attempts. + pub(crate) fn observe(&self, result: EndpointResult) { + let Ok(mut pending) = self.observation.lock() else { + // Status reporting cannot interrupt traffic if another observer panicked. + return; + }; + let Some(observation) = pending.take() else { + // Another clone already reported the result for this exchange. + return; + }; + if self + .policy_generation + .as_ref() + .is_some_and(PolicyGenerationGuard::is_stale) + { + // The request may finish after its policy was replaced but before + // the replacement inventory arrives. Its old result cannot fill + // that activation gap, including a return to equal policy values. + return; + } + let _accepted = self.sender.try_observe(observation, result); + } + + /// Report an endpoint mismatch or an unavailable provider credential. + pub(crate) fn observe_credential_failure(&self, endpoint_mismatch: bool) { + if endpoint_mismatch { + self.observe(EndpointResult::PolicyDenied); + } else if self.uses_provider_credentials { + self.observe(EndpointResult::CredentialUnavailable); + } + } +} + /// Result of an L7 policy decision for a single request. #[derive(Debug, Clone)] pub struct L7Decision { @@ -225,6 +339,8 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { let allow_uninspected_credentials = get_object_bool(val, "allow_uninspected_credentials").unwrap_or(false); let provider_credentialed = get_object_bool(val, "provider_credentialed").unwrap_or(false); + let endpoint_id = get_object_str(val, "endpoint_id").unwrap_or_default(); + let policy_hash = get_object_str(val, "policy_hash").unwrap_or_default(); let websocket_graphql_policy = protocol == L7Protocol::Websocket && endpoint_has_graphql_policy(val); let graphql_max_body_bytes = get_object_u64(val, "graphql_max_body_bytes") @@ -276,6 +392,8 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { Some(L7EndpointConfig { protocol, + endpoint_id, + policy_hash, path: get_object_str(val, "path").unwrap_or_default(), tls, enforcement, @@ -1845,6 +1963,271 @@ mod tests { assert_eq!(config.enforcement, EnforcementMode::Audit); } + #[tokio::test] + async fn endpoint_observation_classifies_only_bound_credential_failure() { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, EndpointResult, EndpointStatusCommand, + endpoint_status_channel, + }; + + let endpoint_id = "endpoint:v1:credential".to_string(); + let (sender, mut receiver) = endpoint_status_channel(); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy".to_string(), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: endpoint_id.clone(), + uses_provider_credentials: true, + }], + ) + .await + .expect("install MCP test inventory"); + let _reset = receiver.recv().await.expect("inventory reset"); + let value = regorus::Value::from_json_str(&format!( + r#"{{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"{endpoint_id}","policy_hash":"policy","provider_credentialed":true}}"# + )) + .expect("parse MCP config JSON"); + let config = parse_l7_config(&value).expect("parse MCP config"); + let observer = EndpointObserver::begin(Some(&sender), &config).expect("begin observation"); + + observer.observe_credential_failure(false); + + assert!(matches!( + receiver.recv().await.expect("credential observation"), + EndpointStatusCommand::Observe { + result: EndpointResult::CredentialUnavailable, + .. + } + )); + } + + #[tokio::test] + async fn endpoint_observation_rejects_old_policy_selection_after_inventory_reset() { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, endpoint_status_channel, + }; + + let endpoint_id = "endpoint:v1:shared"; + let inventory = vec![EndpointInventoryEntry { + endpoint_id: endpoint_id.to_string(), + uses_provider_credentials: false, + }]; + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy-a".to_string(), + provider_env_revision: 1, + }, + inventory.clone(), + ) + .await + .expect("install first policy inventory"); + assert!(tracker.apply(receiver.recv().await.expect("first inventory reset"))); + let value = regorus::Value::from_json_str( + r#"{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"endpoint:v1:shared","policy_hash":"policy-a"}"#, + ) + .expect("parse first policy selection"); + let selected = parse_l7_config(&value).expect("select first policy endpoint"); + + // The replacement retains the endpoint address while changing policy. + // A delayed selection must not acquire the replacement's authority. + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy-b".to_string(), + provider_env_revision: 1, + }, + inventory, + ) + .await + .expect("install replacement policy inventory"); + assert!(tracker.apply(receiver.recv().await.expect("replacement inventory reset"))); + if let Some(observer) = EndpointObserver::begin(Some(&sender), &selected) { + observer.observe(EndpointResult::PolicyDenied); + } + while let Ok(command) = receiver.try_recv() { + tracker.apply(command); + } + + let snapshot = tracker + .snapshot() + .expect("replacement inventory remains installed"); + assert_eq!(snapshot.config_version.policy_hash, "policy-b"); + assert_eq!(snapshot.endpoints.len(), 1); + assert_eq!( + snapshot.endpoints[0].result, + EndpointResult::NoObservedExchange + ); + } + + #[tokio::test] + async fn endpoint_observation_rejects_new_policy_selection_before_inventory_reset() { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, endpoint_status_channel, + }; + + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy-a".to_string(), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: "endpoint:v1:shared".to_string(), + uses_provider_credentials: false, + }], + ) + .await + .expect("install first policy inventory"); + assert!(tracker.apply(receiver.recv().await.expect("first inventory reset"))); + // The runtime can install the new policy before its inventory reset. + // The new policy's result must not be attributed to the old inventory. + let value = regorus::Value::from_json_str( + r#"{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"endpoint:v1:shared","policy_hash":"policy-b"}"#, + ) + .expect("parse replacement policy selection"); + let selected = parse_l7_config(&value).expect("select replacement policy endpoint"); + if let Some(observer) = EndpointObserver::begin(Some(&sender), &selected) { + observer.observe(EndpointResult::HttpResponseReceived); + } + while let Ok(command) = receiver.try_recv() { + tracker.apply(command); + } + + let snapshot = tracker + .snapshot() + .expect("first inventory remains installed"); + assert_eq!(snapshot.config_version.policy_hash, "policy-a"); + assert_eq!(snapshot.endpoints.len(), 1); + assert_eq!( + snapshot.endpoints[0].result, + EndpointResult::NoObservedExchange + ); + } + + #[tokio::test] + async fn endpoint_observation_rejects_stale_policy_guards_without_disabling_fresh_requests() { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, endpoint_status_channel, + }; + + let policy = openshell_policy::restrictive_default_policy(); + let engine = crate::opa::OpaEngine::from_proto(&policy).expect("test policy engine"); + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy".to_string(), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: "endpoint:v1:shared".to_string(), + uses_provider_credentials: false, + }], + ) + .await + .expect("install test inventory"); + assert!(tracker.apply(receiver.recv().await.expect("inventory reset"))); + let context = sender.capture().expect("capture installed authority"); + let value = regorus::Value::from_json_str( + r#"{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"endpoint:v1:shared","policy_hash":"policy"}"#, + ) + .expect("parse selected MCP policy"); + let selected = parse_l7_config(&value).expect("selected MCP endpoint"); + let previous_guard = engine + .generation_guard(engine.current_generation()) + .expect("capture initial policy guard"); + let previous_observer = EndpointObserver::begin_captured( + Some(&sender), + &selected, + Some(&context), + Some(1), + Some(&previous_guard), + ) + .expect("current policy can begin an observation"); + + // Runtime generations can advance while the canonical policy and + // inventory stay equal. Only new selections may use the new runtime. + engine + .reload_from_proto(&policy) + .expect("reinstall same policy"); + assert!( + EndpointObserver::begin_captured( + Some(&sender), + &selected, + Some(&context), + Some(1), + Some(&previous_guard), + ) + .is_none() + ); + previous_observer.observe(EndpointResult::HttpResponseReceived); + while let Ok(command) = receiver.try_recv() { + tracker.apply(command); + } + assert_eq!( + tracker + .snapshot() + .expect("inventory remains installed") + .endpoints[0] + .result, + EndpointResult::NoObservedExchange, + ); + + let current_guard = engine + .generation_guard(engine.current_generation()) + .expect("capture replacement policy guard"); + let current_observer = EndpointObserver::begin_captured( + Some(&sender), + &selected, + Some(&context), + Some(1), + Some(¤t_guard), + ) + .expect("fresh selection stays observable without an inventory reset"); + current_observer.observe(EndpointResult::HttpResponseReceived); + assert!(tracker.apply(receiver.recv().await.expect("fresh policy observation"))); + assert_eq!( + tracker + .snapshot() + .expect("inventory remains installed") + .endpoints[0] + .result, + EndpointResult::HttpResponseReceived, + ); + } + + #[tokio::test] + async fn non_mcp_endpoint_never_begins_an_observation() { + use openshell_core::endpoint_status::{EndpointConfigVersion, endpoint_status_channel}; + + let (sender, _receiver) = endpoint_status_channel(); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy".to_string(), + provider_env_revision: 1, + }, + Vec::new(), + ) + .await + .expect("install MCP test configuration"); + let value = regorus::Value::from_json_str( + r#"{"protocol":"rest","endpoint_id":"endpoint:v1:not-mcp"}"#, + ) + .expect("parse REST config JSON"); + let config = parse_l7_config(&value).expect("parse REST config"); + assert!(EndpointObserver::begin(Some(&sender), &config).is_none()); + } + #[test] fn parse_credential_signing_sigv4() { let val = regorus::Value::from_json_str( diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index aa036dee9..32de13f67 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -18,10 +18,11 @@ use crate::l7::middleware::{ }; use crate::l7::provider::{L7Provider, RelayOutcome}; use crate::l7::rest::WebSocketExtensionMode; -use crate::l7::{EnforcementMode, L7EndpointConfig, L7Protocol, L7RequestInfo}; +use crate::l7::{EndpointObserver, EnforcementMode, L7EndpointConfig, L7Protocol, L7RequestInfo}; use crate::opa::{PolicyGenerationGuard, TunnelPolicyEngine}; use miette::{IntoDiagnostic, Result, miette}; use openshell_core::activity::{ActivitySender, try_record_activity}; +use openshell_core::endpoint_status::{EndpointObservationSender, EndpointResult}; use openshell_core::secrets::{self, SecretResolver}; use openshell_ocsf::{ ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, @@ -80,6 +81,8 @@ pub struct L7EvalContext { Option>, /// Shared feature state for agent-driven policy proposals. pub(crate) agent_proposals: openshell_core::proposals::AgentProposals, + /// Bounded, nonblocking sink for privacy-safe tool server endpoint outcomes. + pub(crate) endpoint_observation_tx: Option, } fn request_default_port(ctx: &L7EvalContext) -> Option { @@ -169,6 +172,7 @@ where /// Enforce MCP request-version policy and emit a transport or policy rejection. /// Non-MCP adapters share this entry point without changing their behavior. +/// Record endpoint version-policy denials before client response delivery. pub(crate) async fn enforce_mcp_protocol_version( config: &L7EndpointConfig, request: &crate::l7::provider::L7Request, @@ -176,6 +180,7 @@ pub(crate) async fn enforce_mcp_protocol_version( client: &mut W, ctx: &L7EvalContext, redacted_target: &str, + observer: Option<&EndpointObserver>, ) -> Result where W: AsyncWrite + Unpin, @@ -191,6 +196,12 @@ where Ok(true) } Err(error) => { + if let Some(observer) = observer { + // Every explicit version-gate rejection is local to this MCP + // endpoint. Record it before delivery can fail; an invalid client + // version is not an upstream transport failure. + observer.observe(EndpointResult::PolicyDenied); + } let reason = error.to_string(); let summary = l7_protocol_log_summary(None, Some(info)); ocsf_emit!(build_l7_request_event( @@ -240,6 +251,7 @@ pub(crate) async fn enforce_final_mcp_protocol_version( client: &mut W, ctx: &L7EvalContext, redacted_target: &str, + observer: Option<&EndpointObserver>, ) -> Result where W: AsyncWrite + Unpin, @@ -251,7 +263,16 @@ where request, crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(config), )?; - enforce_mcp_protocol_version(config, request, &info, client, ctx, redacted_target).await + enforce_mcp_protocol_version( + config, + request, + &info, + client, + ctx, + redacted_target, + observer, + ) + .await } fn build_request_authority_mismatch_event(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { @@ -403,21 +424,56 @@ where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, { - match crate::l7::rest::relay_http_request_with_options_guarded( - request, client, upstream, options, + relay_http_request_with_credential_rejection_observed( + request, client, upstream, options, ctx, None, + ) + .await +} + +async fn relay_http_request_with_credential_rejection_observed( + request: &crate::l7::provider::L7Request, + client: &mut C, + upstream: &mut U, + options: crate::l7::rest::RelayRequestOptions<'_>, + ctx: &L7EvalContext, + observer: Option<&EndpointObserver>, +) -> Result> +where + C: AsyncRead + AsyncWrite + Unpin, + U: AsyncRead + AsyncWrite + Unpin, +{ + match crate::l7::rest::relay_http_request_with_options_guarded_observed( + request, client, upstream, options, observer, ) .await { Ok(outcome) => Ok(Some(outcome)), Err(report) => { if let Some(error) = report.downcast_ref::() { + // Body classification includes credentials for other destinations, + // so denial does not establish that this endpoint lacks credentials. + // Record the local decision before cleanup or client I/O can fail. + if let Some(observer) = observer { + observer.observe(EndpointResult::PolicyDenied); + } let _ = upstream.shutdown().await; reject_body_credential(client, *error).await?; Ok(None) } else if let Some(error) = report.downcast_ref::() { + if let Some(observer) = observer { + observer.observe_credential_failure(error.is_endpoint_mismatch()); + } reject_credential_resolution(client, ctx, error).await?; Ok(None) + } else if report + .downcast_ref::() + .is_some() + { + if let Some(observer) = observer { + observer.observe_credential_failure(false); + } + Err(report) } else { Err(report) } @@ -642,6 +698,12 @@ where return Ok(()); } + // Pin observation authority before parsing can await or a later + // provider lookup can select state from another installation. + let observation_context = ctx + .endpoint_observation_tx + .as_ref() + .and_then(EndpointObservationSender::capture); let mut req = match provider.parse_request(client).await { Ok(Some(req)) => req, Ok(None) => return Ok(()), @@ -688,6 +750,13 @@ where .await?; return Ok(()); }; + let observer = EndpointObserver::begin_captured( + ctx.endpoint_observation_tx.as_ref(), + config, + observation_context.as_ref(), + ctx.provider_credential_revision, + Some(engine.generation_guard()), + ); // The request was canonicalized before the matching config was known, // so `allow_encoded_slash` was taken permissively across every config // on this host:port. Re-check it against the config that actually @@ -701,6 +770,9 @@ where && crate::l7::path::canonical_path_has_encoded_slash(&req.target) { let detail = "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint"; + if let Some(observer) = observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } emit_parse_rejection(ctx, detail, engine_type_for_protocol(config.protocol)); crate::l7::rest::RestProvider::default() .deny_with_redacted_target( @@ -791,6 +863,9 @@ where let redacted_target = match secrets::redact_target_for_policy(&req.target) { Ok(target) => target, Err(error) => { + if let Some(observer) = observer.as_ref() { + observer.observe_credential_failure(error.is_endpoint_mismatch()); + } reject_credential_resolution(client, ctx, &error).await?; return Ok(()); } @@ -804,8 +879,16 @@ where jsonrpc: jsonrpc_info.clone(), }; if let Some(info) = jsonrpc_info.as_ref() - && !enforce_mcp_protocol_version(config, &req, info, client, ctx, &redacted_target) - .await? + && !enforce_mcp_protocol_version( + config, + &req, + info, + client, + ctx, + &redacted_target, + observer.as_ref(), + ) + .await? { return Ok(()); } @@ -876,6 +959,9 @@ where let req = match middleware_result? { MiddlewareApplyResult::Allowed(request) => request, MiddlewareApplyResult::Denied { denial, .. } => { + if let Some(observer) = observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } let denied_request = crate::l7::provider::L7Request { action: request_info.action.clone(), target: redacted_target.clone(), @@ -911,13 +997,30 @@ where return Ok(()); } }; - if !enforce_final_mcp_protocol_version(config, &req, client, ctx, &redacted_target) - .await? + if !enforce_final_mcp_protocol_version( + config, + &req, + client, + ctx, + &redacted_target, + observer.as_ref(), + ) + .await? { return Ok(()); } let scoped_ctx = scoped_context_for_request(ctx, &req); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + // Credential scoping can acquire a newer revision after middleware. + // Bind its actual snapshot to the original authority, never a fresh + // epoch; the earlier handle served only pre-forward local decisions. + let observer = EndpointObserver::begin_captured( + ctx.endpoint_observation_tx.as_ref(), + config, + observation_context.as_ref(), + ctx.provider_credential_revision, + Some(engine.generation_guard()), + ); let mut middleware_session = if let Some(chain) = websocket_chain.as_deref() { let preflight = websocket_middleware_preflight( &req, @@ -951,7 +1054,7 @@ where } else { None }; - let outcome_result = relay_http_request_with_credential_rejection( + let outcome_result = relay_http_request_with_credential_rejection_observed( &req, client, upstream, @@ -975,6 +1078,7 @@ where port: ctx.port, }, ctx, + observer.as_ref(), ) .await; let outcome_result = match outcome_result { @@ -1037,6 +1141,9 @@ where } } } else { + if let Some(observer) = observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } crate::l7::rest::RestProvider::default() .deny_with_redacted_target( &req, @@ -1887,6 +1994,12 @@ where return Ok(()); } + // Body inspection may await the caller while policy or provider state + // changes; a completed parse must retain its original authority. + let observation_context = ctx + .endpoint_observation_tx + .as_ref() + .and_then(EndpointObservationSender::capture); let parsed = match crate::l7::jsonrpc::parse_jsonrpc_http_request( client, config.json_rpc_max_body_bytes, @@ -1919,7 +2032,17 @@ where let req = parsed.request; let jsonrpc_info = parsed.info; + let observer = EndpointObserver::begin_captured( + ctx.endpoint_observation_tx.as_ref(), + config, + observation_context.as_ref(), + ctx.provider_credential_revision, + Some(engine.generation_guard()), + ); if !request_authority_matches_endpoint(&req, ctx) { + if let Some(observer) = observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } reject_request_authority_mismatch(client, ctx).await?; return Ok(()); } @@ -1930,6 +2053,9 @@ where let redacted_target = match secrets::redact_target_for_policy(&req.target) { Ok(target) => target, Err(error) => { + if let Some(observer) = observer.as_ref() { + observer.observe_credential_failure(error.is_endpoint_mismatch()); + } reject_credential_resolution(client, ctx, &error).await?; return Ok(()); } @@ -1942,8 +2068,16 @@ where graphql: None, jsonrpc: Some(jsonrpc_info.clone()), }; - if !enforce_mcp_protocol_version(config, &req, &jsonrpc_info, client, ctx, &redacted_target) - .await? + if !enforce_mcp_protocol_version( + config, + &req, + &jsonrpc_info, + client, + ctx, + &redacted_target, + observer.as_ref(), + ) + .await? { return Ok(()); } @@ -2023,6 +2157,9 @@ where { MiddlewareApplyResult::Allowed(request) => request, MiddlewareApplyResult::Denied { denial, .. } => { + if let Some(observer) = observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } let denied_request = crate::l7::provider::L7Request { action: request_info.action.clone(), target: redacted_target.clone(), @@ -2058,19 +2195,36 @@ where return Ok(()); } }; - if !enforce_final_mcp_protocol_version(config, &req, client, ctx, &redacted_target) - .await? + if !enforce_final_mcp_protocol_version( + config, + &req, + client, + ctx, + &redacted_target, + observer.as_ref(), + ) + .await? { return Ok(()); } let scoped_ctx = scoped_context_for_request(ctx, &req); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + // The outgoing resolver and revision are one snapshot. Rebind using + // the original authority so a newly scoped provider revision cannot + // publish its result under the earlier inventory. + let observer = EndpointObserver::begin_captured( + ctx.endpoint_observation_tx.as_ref(), + config, + observation_context.as_ref(), + ctx.provider_credential_revision, + Some(engine.generation_guard()), + ); // Future MCP response/SSE introspection or rewrite would hook here // before returning upstream bytes. The current policy schema has no // trusted-annotations or version-profile field, so MCP responses and // SSE streams are relayed unchanged; see McpOptions in // proto/sandbox.proto for planned policy extensions. - let Some(outcome) = relay_http_request_with_credential_rejection( + let Some(outcome) = relay_http_request_with_credential_rejection_observed( &req, client, upstream, @@ -2082,6 +2236,7 @@ where ..Default::default() }, ctx, + observer.as_ref(), ) .await? else { @@ -2102,6 +2257,9 @@ where } } } else { + if let Some(observer) = observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } crate::l7::rest::RestProvider::default() .deny_with_redacted_target( &req, @@ -3054,6 +3212,136 @@ mod tests { assert!(!sent.contains("openshell:resolve:")); } + async fn assert_body_denial_observation( + token: &str, + classifier: Option<&secrets::body::BodyCredentialClassifier>, + expected_reason: &str, + disconnect_client: bool, + ) { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, EndpointStatusCommand, + endpoint_status_channel, + }; + + let (sender, mut receiver) = endpoint_status_channel(); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy".into(), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: "endpoint:v1:body-test".into(), + uses_provider_credentials: true, + }], + ) + .await + .expect("install endpoint inventory"); + assert!(matches!( + receiver.recv().await, + Some(EndpointStatusCommand::Reset { .. }) + )); + let value = regorus::Value::from_json_str( + r#"{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"endpoint:v1:body-test","policy_hash":"policy","provider_credentialed":true}"#, + ) + .expect("parse endpoint configuration"); + let config = crate::l7::parse_l7_config(&value).expect("parse MCP configuration"); + let observer = EndpointObserver::begin(Some(&sender), &config).expect("begin observation"); + + let body = format!(r#"{{"input":"{token}"}}"#); + let req = crate::l7::provider::L7Request { + action: "POST".into(), + target: "/mcp".into(), + query_params: TestHashMap::new(), + raw_header: format!( + "POST /mcp HTTP/1.1\r\nHost: tools.example.test\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .into_bytes(), + body_length: crate::l7::provider::BodyLength::ContentLength(body.len() as u64), + }; + let (mut client, caller) = tokio::io::duplex(4096); + let mut caller = Some(caller); + if disconnect_client { + // The denial must survive failure to deliver the local HTTP response. + drop(caller.take()); + } + let (mut upstream, mut server) = tokio::io::duplex(4096); + let outcome = relay_http_request_with_credential_rejection_observed( + &req, + &mut client, + &mut upstream, + crate::l7::rest::RelayRequestOptions { + body_classifier: classifier, + deny_uninspected_credentials: true, + host: "tools.example.test", + port: 443, + ..Default::default() + }, + &L7EvalContext::default(), + Some(&observer), + ) + .await; + if disconnect_client { + assert!(outcome.is_err()); + } else { + assert!(outcome.expect("reject body locally").is_none()); + } + drop(client); + if let Some(mut caller) = caller { + let mut response = String::new(); + caller.read_to_string(&mut response).await.unwrap(); + assert!(response.starts_with("HTTP/1.1 403 Forbidden")); + let body: serde_json::Value = + serde_json::from_str(response.split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body["error"]["reason"], expected_reason); + assert!(!response.contains(token)); + } + let mut sent = String::new(); + server.read_to_string(&mut sent).await.unwrap(); + assert!(!sent.contains("openshell:resolve:")); + assert!(!sent.contains("403 Forbidden")); + assert!(matches!( + receiver.try_recv().expect("body denial observation"), + EndpointStatusCommand::Observe { + result: EndpointResult::PolicyDenied, + .. + } + )); + assert!(receiver.try_recv().is_err(), "only one result per exchange"); + } + + #[tokio::test] + async fn body_denial_observation_reports_policy_denied_before_client_io() { + for disconnect_client in [false, true] { + assert_body_denial_observation( + "openshell:resolve:env:KEY", + None, + "classification_unavailable", + disconnect_client, + ) + .await; + } + } + + #[tokio::test] + async fn body_denial_observation_does_not_attribute_unrelated_unavailable_credential() { + let (state, _) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".into(), + "private-test-secret".into(), + )])); + let (_, classifier, _) = + state.resolver_and_body_classifier_for_endpoint("tools.example.test", 443, "/mcp"); + let classifier = classifier.expect("body classifier"); + // This stale token belongs to allowed.example.test, not the observed server. + let token = "openshell:resolve:env:v999_API_TOKEN"; + assert_eq!( + classifier.check(token), + Err(secrets::body::BodyCredentialError::KnownUnavailable) + ); + assert_body_denial_observation(token, Some(&classifier), "known_unavailable", false).await; + } + const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); fn endpoint_binding(identity: &str) -> StaticCredentialBinding { @@ -7526,6 +7814,8 @@ network_policies: fn encoded_slash_scoping_configs() -> Vec { let rest = |path: &str, allow_encoded_slash: bool| L7EndpointConfig { protocol: L7Protocol::Rest, + endpoint_id: String::new(), + policy_hash: String::new(), path: path.into(), tls: crate::l7::TlsMode::Auto, enforcement: EnforcementMode::Enforce, @@ -7971,6 +8261,8 @@ network_policies: .unwrap(); let configs = vec![L7EndpointConfig { protocol: L7Protocol::Rest, + endpoint_id: String::new(), + policy_hash: String::new(), path: "/ws".into(), tls: crate::l7::TlsMode::Auto, enforcement: EnforcementMode::Enforce, @@ -8174,6 +8466,8 @@ network_policies: .unwrap(); let configs = vec![L7EndpointConfig { protocol: L7Protocol::Websocket, + endpoint_id: String::new(), + policy_hash: String::new(), path: "/ws".into(), tls: crate::l7::TlsMode::Auto, enforcement: EnforcementMode::Enforce, @@ -8301,6 +8595,8 @@ network_policies: .unwrap(); let configs = vec![L7EndpointConfig { protocol: L7Protocol::Websocket, + endpoint_id: String::new(), + policy_hash: String::new(), path: "/graphql".into(), tls: crate::l7::TlsMode::Auto, enforcement: EnforcementMode::Enforce, @@ -8917,6 +9213,348 @@ network_policies: assert!(forwarded.is_empty(), "rejected request reached upstream"); } + const MCP_VERSION_DENIALS: &[(&str, &str, &str)] = &[ + ("", "403 Forbidden", "mcp_protocol_version_not_allowed"), + ( + "MCP-Protocol-Version: \r\n", + "400 Bad Request", + "invalid_mcp_protocol_version_header", + ), + ( + "MCP-Protocol-Version: 2025-11-25\r\nMCP-Protocol-Version: 2025-11-25\r\n", + "400 Bad Request", + "invalid_mcp_protocol_version_header", + ), + ( + "MCP-Protocol-Version: 2026-07-28\r\n", + "400 Bad Request", + "unsupported_mcp_protocol_version", + ), + ( + "MCP-Protocol-Version: 2025-06-18\r\n", + "403 Forbidden", + "mcp_protocol_version_not_allowed", + ), + ]; + + #[tokio::test] + async fn endpoint_observation_binds_scoped_provider_revision() { + use openshell_core::endpoint_status::EndpointStatusCommand; + + for route_selected in [false, true] { + for scoped_revision in [1, 2] { + let scenario = format!( + "route_selected={route_selected}, inventory_revision=1, parent_revision=1, scoped_revision={scoped_revision}" + ); + let (mut config, engine, mut ctx) = mcp_test_relay_context(); + config.provider_credentialed = true; + let mut receiver = install_mcp_test_observation(&mut config, &mut ctx).await; + // The tunnel retains revision 1 while request scoping may obtain + // revision 2 before its replacement inventory has been published. + ctx.provider_credential_revision = Some(1); + let credentials = ProviderCredentialState::from_bound_environment( + scoped_revision, + TestHashMap::from([("API_TOKEN".into(), "scoped-secret".into())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".into(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: ctx.host.clone(), + port: u32::from(ctx.port), + path: "/mcp".into(), + }], + credential_identity: "provider:API_TOKEN".into(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .unwrap_or_else(|error| panic!("{scenario}: invalid provider fixture: {error}")); + // Endpoint-bound input must use the workload-issued handle; + // a canonical alias carries no authorized credential identity. + let placeholder = credentials.snapshot().child_env["API_TOKEN"].clone(); + ctx.provider_credentials = Some(credentials); + let (mut app, mut client) = tokio::io::duplex(8192); + let (mut upstream, mut server) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + if route_selected { + relay_with_route_selection( + &[config], + engine, + &mut client, + &mut upstream, + &ctx, + ) + .await + } else { + relay_with_inspection(&config, engine, &mut client, &mut upstream, &ctx) + .await + } + }); + let body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#; + let request = format!( + "POST /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nAuthorization: Bearer {placeholder}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + app.write_all(request.as_bytes()).await.unwrap(); + app.write_all(body).await.unwrap(); + let mut forwarded = Vec::new(); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !forwarded.ends_with(body) { + let mut buffer = [0; 1024]; + let count = server.read(&mut buffer).await.unwrap(); + assert_ne!( + count, 0, + "{scenario}: request closed before forwarding its body" + ); + forwarded.extend_from_slice(&buffer[..count]); + } + }) + .await + .unwrap_or_else(|error| panic!("{scenario}: upstream exchange timed out: {error}")); + assert!( + String::from_utf8_lossy(&forwarded) + .contains("Authorization: Bearer scoped-secret\r\n"), + "{scenario}: endpoint-scoped credential was not injected" + ); + server + .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .unwrap_or_else(|error| panic!("{scenario}: relay timed out: {error}")) + .unwrap_or_else(|error| panic!("{scenario}: relay task failed: {error}")) + .unwrap_or_else(|error| panic!("{scenario}: relay failed: {error}")); + + if scoped_revision == 1 { + assert!( + matches!( + receiver.try_recv().unwrap_or_else(|error| panic!( + "{scenario}: matching revision observation missing: {error}" + )), + EndpointStatusCommand::Observe { + result: EndpointResult::HttpResponseReceived, + .. + } + ), + "{scenario}: matching revision produced an unexpected observation" + ); + } + assert!( + receiver.try_recv().is_err(), + "{scenario}: another provider revision cannot supply this inventory's result" + ); + } + } + } + + #[tokio::test] + async fn endpoint_observation_records_mcp_version_policy_denial() { + use openshell_core::endpoint_status::EndpointStatusCommand; + + for &(version_headers, status, response_code) in MCP_VERSION_DENIALS { + for route_selected in [false, true] { + for disconnect_client in [false, true] { + let (mut config, engine, mut ctx) = mcp_test_relay_context(); + let mut receiver = install_mcp_test_observation(&mut config, &mut ctx).await; + let (mut app, mut client) = tokio::io::duplex(8192); + let (mut upstream, mut server) = tokio::io::duplex(8192); + let body = br#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#; + let request = format!( + "POST /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nContent-Type: application/json\r\n{version_headers}Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + app.write_all(request.as_bytes()).await.unwrap(); + app.write_all(body).await.unwrap(); + let mut app = Some(app); + if disconnect_client { + // The buffered request remains readable after its caller + // disconnects, but delivering the local denial must fail. + drop(app.take()); + } + + let result = tokio::time::timeout(std::time::Duration::from_secs(1), async { + if route_selected { + relay_with_route_selection( + &[config], + engine, + &mut client, + &mut upstream, + &ctx, + ) + .await + } else { + relay_with_inspection(&config, engine, &mut client, &mut upstream, &ctx) + .await + } + }) + .await + .expect("version policy must reject before upstream I/O"); + assert_eq!(result.is_err(), disconnect_client); + drop(client); + drop(upstream); + if let Some(mut app) = app { + let mut response = String::new(); + app.read_to_string(&mut response).await.unwrap(); + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "{response}" + ); + assert!(response.contains(response_code), "{response}"); + } + let mut sent = Vec::new(); + server.read_to_end(&mut sent).await.unwrap(); + assert!(sent.is_empty(), "disallowed version reached upstream"); + assert!(matches!( + receiver + .try_recv() + .expect("version policy denial observation"), + EndpointStatusCommand::Observe { + result: EndpointResult::PolicyDenied, + .. + } + )); + assert!(receiver.try_recv().is_err(), "one result per exchange"); + } + } + } + } + + async fn install_mcp_test_observation( + config: &mut L7EndpointConfig, + ctx: &mut L7EvalContext, + ) -> openshell_core::endpoint_status::EndpointStatusReceiver { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, EndpointStatusCommand, + endpoint_status_channel, + }; + + config.endpoint_id = "endpoint:v1:mcp-version".into(); + config.policy_hash = "mcp-version-policy".into(); + config.mcp_versions = vec![openshell_core::mcp::McpProtocolVersion::V2025_11_25]; + let (sender, mut receiver) = endpoint_status_channel(); + sender + .reset( + EndpointConfigVersion { + policy_hash: config.policy_hash.clone(), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: config.endpoint_id.clone(), + uses_provider_credentials: config.provider_credentialed, + }], + ) + .await + .expect("install MCP endpoint inventory"); + assert!(matches!( + receiver.recv().await, + Some(EndpointStatusCommand::Reset { .. }) + )); + ctx.endpoint_observation_tx = Some(sender); + receiver + } + + #[tokio::test] + async fn endpoint_observation_records_final_mcp_version_policy_denial() { + use openshell_core::endpoint_status::EndpointStatusCommand; + + for &(version_headers, status, response_code) in MCP_VERSION_DENIALS { + for disconnect_client in [false, true] { + let (mut config, _, mut ctx) = mcp_test_relay_context(); + let mut receiver = install_mcp_test_observation(&mut config, &mut ctx).await; + // One request handle survives initialize's version exemption and + // the final check after a middleware changes the outgoing body. + let observer = + EndpointObserver::begin(ctx.endpoint_observation_tx.as_ref(), &config) + .expect("begin transformed request observation"); + let buffered_request = |body: &[u8]| { + let mut raw_header = format!( + "POST /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nContent-Type: application/json\r\n{version_headers}Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + raw_header.extend_from_slice(body); + crate::l7::provider::L7Request { + action: "POST".into(), + target: "/mcp".into(), + query_params: TestHashMap::new(), + raw_header, + body_length: crate::l7::provider::BodyLength::ContentLength( + body.len() as u64 + ), + } + }; + let initialize = buffered_request( + br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#, + ); + let (mut client, app) = tokio::io::duplex(2048); + assert!( + enforce_final_mcp_protocol_version( + &config, + &initialize, + &mut client, + &ctx, + "/mcp", + Some(&observer), + ) + .await + .expect("initialize version exemption") + ); + assert!( + receiver.try_recv().is_err(), + "allowed request is not a terminal result" + ); + let rewritten = + buffered_request(br#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#); + let mut app = Some(app); + if disconnect_client { + // Dropping the peer makes the final rejection undeliverable. + drop(app.take()); + } + let result = enforce_final_mcp_protocol_version( + &config, + &rewritten, + &mut client, + &ctx, + "/mcp", + Some(&observer), + ) + .await; + if disconnect_client { + assert!( + result.is_err(), + "local denial cannot reach a disconnected client" + ); + } else { + assert!(!result.expect("reject rewritten request version")); + } + drop(client); + if let Some(mut app) = app { + let mut response = String::new(); + app.read_to_string(&mut response).await.unwrap(); + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "{response}" + ); + assert!(response.contains(response_code), "{response}"); + } + assert!(matches!( + receiver + .try_recv() + .expect("final version policy denial observation"), + EndpointStatusCommand::Observe { + result: EndpointResult::PolicyDenied, + .. + } + )); + assert!(receiver.try_recv().is_err(), "one result per exchange"); + } + } + } + #[tokio::test] async fn final_mcp_version_check_reclassifies_a_rewritten_initialize_body() { let (config, _, ctx) = mcp_test_relay_context(); @@ -8936,10 +9574,16 @@ network_policies: }; let (mut client, mut relay_client) = tokio::io::duplex(2048); - let allowed = - enforce_final_mcp_protocol_version(&config, &request, &mut relay_client, &ctx, "/mcp") - .await - .expect("final request inspection"); + let allowed = enforce_final_mcp_protocol_version( + &config, + &request, + &mut relay_client, + &ctx, + "/mcp", + None, + ) + .await + .expect("final request inspection"); assert!( !allowed, "rewritten non-initialize request must require a version" diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index d34a05bd1..41682f516 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -7,11 +7,13 @@ //! policy, and relays allowed requests to upstream. Handles Content-Length //! and chunked transfer encoding for body framing. +use crate::l7::EndpointObserver; use crate::l7::provider::{BodyLength, L7Provider, L7Request, RelayOutcome}; use crate::opa::PolicyGenerationGuard; use aws_sigv4::http_request::SignableBody; use base64::Engine as _; use miette::{IntoDiagnostic, Result, miette}; +use openshell_core::endpoint_status::EndpointResult; use openshell_core::proto::{ExistingHeaderAction, HeaderMutation, header_mutation}; use openshell_core::secrets::{ SecretResolver, contains_reserved_credential_marker, contains_reserved_credential_marker_bytes, @@ -768,6 +770,31 @@ pub(crate) struct CredentialGenerationGuard<'a> { revision: u64, } +/// Typed marker for provider credential material that cannot be used. +/// +/// The type deliberately carries no secret or raw provider error, allowing +/// callers to classify the outcome without inspecting error strings. +#[derive(Debug)] +pub(crate) struct CredentialUnavailableError { + reason: &'static str, +} + +impl CredentialUnavailableError { + fn new(reason: &'static str) -> Self { + Self { reason } + } +} + +impl fmt::Display for CredentialUnavailableError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.reason) + } +} + +impl std::error::Error for CredentialUnavailableError {} + +impl miette::Diagnostic for CredentialUnavailableError {} + impl<'a> CredentialGenerationGuard<'a> { pub(crate) fn new( state: &'a openshell_core::provider_credentials::ProviderCredentialState, @@ -780,9 +807,9 @@ impl<'a> CredentialGenerationGuard<'a> { if self.state.revision() == self.revision { Ok(()) } else { - Err(miette!( - "provider credential generation changed before upstream write" - )) + Err(miette::Report::new(CredentialUnavailableError::new( + "provider credential generation changed before upstream write", + ))) } } } @@ -800,6 +827,37 @@ pub(crate) async fn relay_http_request_with_options_guarded( upstream: &mut U, options: RelayRequestOptions<'_>, ) -> Result +where + C: AsyncRead + AsyncWrite + Unpin, + U: AsyncRead + AsyncWrite + Unpin, +{ + relay_http_request_with_options_guarded_observed(req, client, upstream, options, None).await +} + +/// Relay one request while attributing only terminal, privacy-safe MCP outcomes. +pub(crate) async fn relay_http_request_with_options_guarded_observed( + req: &L7Request, + client: &mut C, + upstream: &mut U, + options: RelayRequestOptions<'_>, + observer: Option<&EndpointObserver>, +) -> Result +where + C: AsyncRead + AsyncWrite + Unpin, + U: AsyncRead + AsyncWrite + Unpin, +{ + let mut upstream = ObservedUpstream { upstream, observer }; + relay_http_request_with_options_guarded_inner(req, client, &mut upstream, options, observer) + .await +} + +async fn relay_http_request_with_options_guarded_inner( + req: &L7Request, + client: &mut C, + upstream: &mut U, + options: RelayRequestOptions<'_>, + observer: Option<&EndpointObserver>, +) -> Result where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, @@ -1066,15 +1124,15 @@ where openshell_ocsf::ocsf_emit!(event); } _ => { - return Err(miette!( - "SigV4 signing configured but AWS credentials not found in provider" - )); + return Err(miette::Report::new(CredentialUnavailableError::new( + "SigV4 signing configured but AWS credentials not found in provider", + ))); } } } else { - return Err(miette!( - "SigV4 signing configured but no secret resolver available" - )); + return Err(miette::Report::new(CredentialUnavailableError::new( + "SigV4 signing configured but no secret resolver available", + ))); } } else if options.request_body_credential_rewrite { let body = collect_and_rewrite_request_body( @@ -1153,6 +1211,7 @@ where websocket_extensions: options.websocket_extensions, websocket: websocket_response, client_requested_upgrade, + observer, }, ) .await?; @@ -1172,6 +1231,71 @@ fn ensure_body_generation_current(options: RelayRequestOptions<'_>) -> Result<() Ok(()) } +/// Upstream-only I/O adapter that cannot accidentally classify client errors. +/// +/// Once a valid response status consumes the observation token, later stream +/// failures are ignored and cannot overwrite the endpoint's first network result. +struct ObservedUpstream<'a, U> { + upstream: &'a mut U, + observer: Option<&'a EndpointObserver>, +} + +impl AsyncRead for ObservedUpstream<'_, U> { + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + let this = self.get_mut(); + let result = std::pin::Pin::new(&mut *this.upstream).poll_read(cx, buf); + if matches!(result, std::task::Poll::Ready(Err(_))) + && let Some(observer) = this.observer + { + observer.observe(EndpointResult::TransportFailed); + } + result + } +} + +impl AsyncWrite for ObservedUpstream<'_, U> { + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + let this = self.get_mut(); + let result = std::pin::Pin::new(&mut *this.upstream).poll_write(cx, buf); + if matches!(result, std::task::Poll::Ready(Err(_))) + && let Some(observer) = this.observer + { + observer.observe(EndpointResult::TransportFailed); + } + result + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let this = self.get_mut(); + let result = std::pin::Pin::new(&mut *this.upstream).poll_flush(cx); + if matches!(result, std::task::Poll::Ready(Err(_))) + && let Some(observer) = this.observer + { + observer.observe(EndpointResult::TransportFailed); + } + result + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let this = self.get_mut(); + std::pin::Pin::new(&mut *this.upstream).poll_shutdown(cx) + } +} + async fn write_body_bytes( writer: &mut W, bytes: &[u8], @@ -3110,18 +3234,20 @@ fn find_crlf(buf: &[u8], start: usize) -> Option { } #[derive(Clone)] -struct RelayResponseOptions { +struct RelayResponseOptions<'a> { websocket_extensions: WebSocketExtensionMode, client_requested_upgrade: bool, websocket: Option, + observer: Option<&'a EndpointObserver>, } -impl Default for RelayResponseOptions { +impl Default for RelayResponseOptions<'_> { fn default() -> Self { Self { websocket_extensions: WebSocketExtensionMode::Preserve, client_requested_upgrade: true, websocket: None, + observer: None, } } } @@ -3130,7 +3256,7 @@ async fn relay_response( request_method: &str, upstream: &mut U, client: &mut C, - options: RelayResponseOptions, + options: RelayResponseOptions<'_>, ) -> Result where U: AsyncRead + Unpin, @@ -3140,14 +3266,49 @@ where let mut buf = Vec::with_capacity(4096); let mut tmp = [0u8; 1024]; - // Read response headers - loop { - if buf.len() > MAX_HEADER_BYTES { + let mut informational_header_bytes = 0; + let header_end = loop { + let header_end = buf + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|end| end + 4); + let remaining_header_bytes = MAX_HEADER_BYTES - informational_header_bytes; + if header_end.is_some_and(|end| end > remaining_header_bytes) + || (header_end.is_none() && buf.len() >= remaining_header_bytes) + { + if let Some(observer) = options.observer.as_ref() { + observer.observe(EndpointResult::TransportFailed); + } return Err(miette!("HTTP response headers exceed limit")); } + if let Some(header_end) = header_end { + let header_str = String::from_utf8_lossy(&buf[..header_end]); + if matches!( + parse_observed_http_status_code(&header_str), + Some(100 | 102..=199) + ) { + // Informational responses keep this exchange and its observation + // pending. Preserve any coalesced final response, and charge every + // header block to one budget so repeated 1xx frames stay bounded. + informational_header_bytes += header_end; + client + .write_all(&buf[..header_end]) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + buf.drain(..header_end); + continue; + } + // A 101 response transfers ownership to the upgrade handler below. + break header_end; + } + let n = upstream.read(&mut tmp).await.into_diagnostic()?; if n == 0 { + if let Some(observer) = options.observer.as_ref() { + observer.observe(EndpointResult::TransportFailed); + } // Upstream closed — forward whatever we have if !buf.is_empty() { client.write_all(&buf).await.into_diagnostic()?; @@ -3155,16 +3316,25 @@ where return Ok(RelayOutcome::Consumed); } buf.extend_from_slice(&tmp[..n]); - - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - - let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; + }; // Parse response framing let header_str = String::from_utf8_lossy(&buf[..header_end]); + let observed_status = parse_observed_http_status_code(&header_str); + if let Some(observer) = options.observer.as_ref() { + match observed_status { + Some(status_code) if status_code < 400 => { + observer.observe(EndpointResult::HttpResponseReceived); + } + Some(_) => observer.observe(EndpointResult::UpstreamRejected), + None => { + observer.observe(EndpointResult::TransportFailed); + } + } + } + // Preserve the relay's legacy framing fallback for non-MCP traffic and + // malformed upstream responses. Endpoint status uses the strict parser above, + // so this fallback can never become a false successful observation. let status_code = parse_status_code(&header_str).unwrap_or(200); let server_wants_close = parse_connection_close(&header_str); let event_stream = response_is_event_stream(&header_str); @@ -3308,6 +3478,23 @@ fn parse_status_code(headers: &str) -> Option { code_str.parse().ok() } +/// Parse a syntactically valid HTTP/1.x response status for endpoint reporting. +fn parse_observed_http_status_code(headers: &str) -> Option { + let status_line = headers.lines().next()?; + let mut fields = status_line.split_whitespace(); + match fields.next()? { + "HTTP/1.0" | "HTTP/1.1" => {} + _ => return None, + } + let code = fields.next()?; + if code.len() != 3 || !code.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + code.parse() + .ok() + .filter(|status| (100..=999).contains(status)) +} + /// Check if the response headers contain `Connection: close`. fn parse_connection_close(headers: &str) -> bool { for line in headers.lines().skip(1) { @@ -3629,6 +3816,7 @@ mod tests { use super::*; use crate::opa::OpaEngine; use flate2::{Compress, Compression, Decompress, FlushCompress, FlushDecompress, Status}; + use openshell_core::endpoint_status::{EndpointStatusCommand, EndpointStatusReceiver}; use openshell_core::proposals::AgentProposals; use openshell_core::secrets::SecretResolver; use std::pin::Pin; @@ -5473,6 +5661,304 @@ mod tests { assert_eq!(parse_status_code(""), None); } + async fn test_endpoint_observer() -> (EndpointObserver, EndpointStatusReceiver) { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, endpoint_status_channel, + }; + + let endpoint_id = "endpoint:v1:test".to_string(); + let (sender, mut receiver) = endpoint_status_channel(); + sender + .reset( + EndpointConfigVersion { + policy_hash: "policy".to_string(), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: endpoint_id.clone(), + uses_provider_credentials: true, + }], + ) + .await + .expect("install MCP test inventory"); + let reset = receiver.recv().await.expect("inventory reset"); + assert!(matches!(reset, EndpointStatusCommand::Reset { .. })); + let value = regorus::Value::from_json_str(&format!( + r#"{{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"{endpoint_id}","policy_hash":"policy","provider_credentialed":true}}"# + )) + .expect("parse MCP config JSON"); + let config = crate::l7::parse_l7_config(&value).expect("parse MCP observation config"); + let observer = + EndpointObserver::begin(Some(&sender), &config).expect("begin MCP observation"); + (observer, receiver) + } + + async fn assert_observed_response_result(response: &'static [u8], expected: EndpointResult) { + let (observer, mut receiver) = test_endpoint_observer().await; + let (mut upstream, mut upstream_peer) = tokio::io::duplex(4096); + let writer = tokio::spawn(async move { + upstream_peer + .write_all(response) + .await + .expect("write response"); + }); + let mut client = tokio::io::sink(); + relay_response( + "POST", + &mut upstream, + &mut client, + RelayResponseOptions { + observer: Some(&observer), + ..Default::default() + }, + ) + .await + .expect("relay observed response"); + writer.await.expect("join upstream writer"); + let command = receiver.recv().await.expect("endpoint observation"); + assert!(matches!( + command, + EndpointStatusCommand::Observe { result, .. } if result == expected + )); + } + + #[tokio::test] + async fn endpoint_observation_records_http_response_at_header_receipt() { + assert_observed_response_result( + b"HTTP/1.1 204 No Content\r\n\r\n", + EndpointResult::HttpResponseReceived, + ) + .await; + } + + #[tokio::test] + async fn endpoint_observation_classifies_upstream_http_rejections() { + for response in [ + &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..], + &b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n"[..], + ] { + assert_observed_response_result(response, EndpointResult::UpstreamRejected).await; + } + } + + #[tokio::test] + async fn endpoint_observation_waits_for_final_response_after_coalesced_interim() { + let response = b"HTTP/1.1 103 Early Hints\r\n\r\nHTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 503 Service Unavailable\r\nContent-Length: 4\r\n\r\ndeny"; + let (observer, mut receiver) = test_endpoint_observer().await; + let mut upstream = &response[..]; + let mut client = Vec::new(); + + let outcome = relay_response( + "POST", + &mut upstream, + &mut client, + RelayResponseOptions { + observer: Some(&observer), + ..Default::default() + }, + ) + .await + .expect("relay informational and final responses"); + + assert!(matches!( + receiver.try_recv().expect("final response observation"), + EndpointStatusCommand::Observe { + result: EndpointResult::UpstreamRejected, + .. + } + )); + assert_eq!( + client, response, + "preserve coalesced final headers and body" + ); + assert!(matches!(outcome, RelayOutcome::Reusable)); + assert!(receiver.try_recv().is_err(), "one result per exchange"); + } + + #[tokio::test] + async fn endpoint_observation_relays_split_interim_and_final_headers() { + let first = b"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 "; + let last = b"200 OK\r\nContent-Length: 4\r\n\r\nbody"; + let (observer, mut receiver) = test_endpoint_observer().await; + // Separate reads split the final status line after the complete interim + // block; draining interim bytes must preserve the partial final header. + let mut upstream = first.as_slice().chain(last.as_slice()); + let mut client = Vec::new(); + + relay_response( + "POST", + &mut upstream, + &mut client, + RelayResponseOptions { + observer: Some(&observer), + ..Default::default() + }, + ) + .await + .expect("relay split informational and final responses"); + + assert_eq!(client, [first.as_slice(), last.as_slice()].concat()); + assert!(matches!( + receiver.try_recv().expect("final response observation"), + EndpointStatusCommand::Observe { + result: EndpointResult::HttpResponseReceived, + .. + } + )); + assert!(receiver.try_recv().is_err(), "one result per exchange"); + } + + #[tokio::test] + async fn endpoint_observation_reports_transport_failure_after_interim_eof() { + for response in [ + &b"HTTP/1.1 100 Continue\r\n\r\n"[..], + &b"HTTP/1.1 103 Early Hints\r\n\r\n"[..], + ] { + assert_observed_response_result(response, EndpointResult::TransportFailed).await; + } + } + + #[tokio::test] + async fn endpoint_observation_bounds_informational_response_headers() { + let interim = b"HTTP/1.1 103 Early Hints\r\n\r\n"; + let mut response = interim.repeat(MAX_HEADER_BYTES / interim.len() + 1); + response.extend_from_slice(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let (observer, mut receiver) = test_endpoint_observer().await; + let mut upstream = response.as_slice(); + let mut client = Vec::new(); + + relay_response( + "POST", + &mut upstream, + &mut client, + RelayResponseOptions { + observer: Some(&observer), + ..Default::default() + }, + ) + .await + .expect_err("informational headers must share a bounded exchange budget"); + + assert!(matches!( + receiver.try_recv().expect("header limit observation"), + EndpointStatusCommand::Observe { + result: EndpointResult::TransportFailed, + .. + } + )); + assert!(client.len() <= MAX_HEADER_BYTES); + assert!(receiver.try_recv().is_err(), "one result per exchange"); + } + + #[tokio::test] + async fn endpoint_observation_preserves_response_result_after_body_read_error() { + struct FailedReader; + + impl AsyncRead for FailedReader { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "upstream response body interrupted", + ))) + } + } + + for (headers, expected) in [ + ( + &b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n"[..], + EndpointResult::HttpResponseReceived, + ), + ( + &b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 4\r\n\r\n"[..], + EndpointResult::UpstreamRejected, + ), + ] { + let (observer, mut receiver) = test_endpoint_observer().await; + let mut response = headers.chain(FailedReader); + let mut upstream = ObservedUpstream { + upstream: &mut response, + observer: Some(&observer), + }; + let mut client = Vec::new(); + relay_response( + "POST", + &mut upstream, + &mut client, + RelayResponseOptions { + observer: Some(&observer), + ..Default::default() + }, + ) + .await + .expect_err("response body read must fail"); + assert_eq!(client, headers); + assert!(matches!( + receiver.try_recv().expect("HTTP response observation"), + EndpointStatusCommand::Observe { result, .. } if result == expected + )); + assert!( + receiver.try_recv().is_err(), + "body error must not replace the response result" + ); + } + } + + #[tokio::test] + async fn endpoint_observation_rejects_malformed_http_status() { + assert_observed_response_result( + b"not-http 200 nope\r\nContent-Length: 0\r\n\r\n", + EndpointResult::TransportFailed, + ) + .await; + } + + #[tokio::test] + async fn endpoint_observation_reports_sse_headers_before_body_eof() { + let (observer, mut receiver) = test_endpoint_observer().await; + let (mut upstream, mut upstream_peer) = tokio::io::duplex(4096); + let mut client = tokio::io::sink(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let writer = async move { + upstream_peer + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n") + .await + .expect("write SSE headers"); + let _ = release_rx.await; + }; + let relay = relay_response( + "POST", + &mut upstream, + &mut client, + RelayResponseOptions { + observer: Some(&observer), + ..Default::default() + }, + ); + let assertion = async { + let command = receiver.recv().await.expect("SSE endpoint observation"); + assert!(matches!( + command, + EndpointStatusCommand::Observe { + result: EndpointResult::HttpResponseReceived, + .. + } + )); + let _ = release_tx.send(()); + }; + Box::pin(tokio::time::timeout( + std::time::Duration::from_secs(1), + async { tokio::join!(writer, relay, assertion) }, + )) + .await + .expect("SSE observation must not wait for body EOF") + .1 + .expect("relay SSE response"); + } + #[test] fn test_parse_connection_close() { assert!(parse_connection_close( diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index c8f303d64..b550a5823 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -9,9 +9,11 @@ use miette::Result; use openshell_core::host_pattern::HostSelector; +use openshell_core::mcp::is_mcp_protocol; use openshell_core::policy::{ FilesystemPolicy, LandlockCompatibility, LandlockPolicy, ProcessPolicy, }; +use openshell_core::policy_identity::deterministic_policy_hash; use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; use openshell_policy::{L7ConfigStanza, L7Protocol as PolicyL7Protocol}; use openshell_supervisor_middleware::{ChainEntry, ChainRunner, MiddlewareRegistry}; @@ -1947,6 +1949,7 @@ fn l7_matchers_to_json( /// kernel-resolved canonical paths reported by `/proc//exe` (e.g., /// `/usr/bin/python3.11`). fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> String { + let policy_hash = deterministic_policy_hash(proto); let filesystem_policy = proto.filesystem.as_ref().map_or_else( || { serde_json::json!({ @@ -2129,6 +2132,17 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if e.provider_credentialed { ep["provider_credentialed"] = true.into(); } + if is_mcp_protocol(&e.protocol) { + // Derive endpoint identity from the policy endpoint while + // it is still available. Request handling carries this + // opaque value through exact path selection and never + // recomputes identity from a concrete request host. + ep["endpoint_id"] = + openshell_core::endpoint_status::endpoint_id(e).into(); + // The selected endpoint must retain its policy identity + // so it cannot bind to a replacement observation inventory. + ep["policy_hash"] = policy_hash.clone().into(); + } if !e.credential_signing.is_empty() { ep["credential_signing"] = e.credential_signing.clone().into(); } @@ -5477,6 +5491,120 @@ network_policies: } } + #[test] + fn l7_endpoint_config_derives_endpoint_id_from_proto() { + let mut policy = defaultable_mcp_proto(None); + let endpoint = policy + .network_policies + .get_mut("mcp") + .and_then(|rule| rule.endpoints.first_mut()) + .expect("MCP test endpoint"); + endpoint.path = "/mcp".to_string(); + let expected = openshell_core::endpoint_status::endpoint_id(endpoint); + policy + .network_policies + .get_mut("mcp") + .expect("MCP test rule") + .binaries = vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + }]; + let canonical = openshell_policy::validate_and_canonicalize_sandbox_policy(policy.clone()) + .expect("canonical MCP test policy"); + let expected_hash = deterministic_policy_hash(&canonical); + let engine = OpaEngine::from_proto(&policy).expect("engine from proto"); + let config = engine + .query_endpoint_config(&NetworkInput { + host: "mcp.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: String::new(), + ancestors: vec![], + cmdline_paths: vec![], + }) + .expect("query endpoint config") + .expect("MCP endpoint config"); + let l7 = crate::l7::parse_l7_config(&config).expect("parse MCP config"); + assert_eq!( + l7.endpoint_id, expected, + "OPA must carry the ID derived from the exact proto endpoint" + ); + assert_eq!(l7.policy_hash, expected_hash); + } + + #[tokio::test] + async fn mixed_case_mcp_policy_keeps_endpoint_observation_identity() { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, EndpointResult, endpoint_id, + endpoint_status_channel, + }; + use openshell_core::policy_identity::deterministic_policy_hash; + + for protocol in ["Mcp", "MCP"] { + let mut policy = defaultable_mcp_proto(None); + let rule = policy + .network_policies + .get_mut("mcp") + .expect("MCP test rule"); + rule.endpoints[0].protocol = protocol.to_string(); + rule.binaries = vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + }]; + let policy = openshell_policy::validate_and_canonicalize_sandbox_policy(policy) + .expect("mixed-case MCP policy is valid"); + let expected_id = endpoint_id(&policy.network_policies["mcp"].endpoints[0]); + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + sender + .reset( + EndpointConfigVersion { + policy_hash: deterministic_policy_hash(&policy), + provider_env_revision: 1, + }, + vec![EndpointInventoryEntry { + endpoint_id: expected_id.clone(), + uses_provider_credentials: false, + }], + ) + .await + .expect("install mixed-case MCP inventory"); + assert!(tracker.apply(receiver.recv().await.expect("inventory reset"))); + let engine = OpaEngine::from_proto(&policy).expect("engine from mixed-case MCP proto"); + let selected = engine + .query_endpoint_config(&NetworkInput { + host: "mcp.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: String::new(), + ancestors: vec![], + cmdline_paths: vec![], + }) + .expect("query mixed-case MCP endpoint") + .expect("mixed-case MCP endpoint config"); + let selected = + crate::l7::parse_l7_config(&selected).expect("parse selected MCP config"); + assert_eq!(selected.protocol, crate::l7::L7Protocol::Mcp); + assert_eq!( + selected.endpoint_id, expected_id, + "{protocol} must retain inventory identity" + ); + let observer = crate::l7::EndpointObserver::begin(Some(&sender), &selected) + .expect("mixed-case MCP endpoint begins an observation"); + observer.observe(EndpointResult::HttpResponseReceived); + while let Ok(command) = receiver.try_recv() { + tracker.apply(command); + } + let snapshot = tracker + .snapshot() + .expect("mixed-case MCP inventory remains installed"); + assert_eq!(snapshot.endpoints.len(), 1); + assert_eq!(snapshot.endpoints[0].endpoint_id, expected_id); + assert_eq!( + snapshot.endpoints[0].result, + EndpointResult::HttpResponseReceived + ); + } + } + #[test] fn l7_endpoint_config_preserves_proto_allow_encoded_slash() { let mut network_policies = std::collections::HashMap::new(); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 668972536..ddd977583 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -9,6 +9,7 @@ mod relay; #[cfg(target_os = "linux")] use crate::identity::BinaryIdentityCache; +use crate::l7::EndpointObserver; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; #[cfg(target_os = "linux")] @@ -18,6 +19,9 @@ use crate::upstream_proxy::{self, UpstreamProxyConfig}; use miette::{IntoDiagnostic, Result}; use openshell_core::activity::{ActivitySender, try_record_activity}; use openshell_core::denial::DenialEvent; +use openshell_core::endpoint_status::{ + EndpointObservationContext, EndpointObservationSender, EndpointResult, +}; use openshell_core::net::{ connect_tcp_nodelay_best_effort, is_always_blocked_ip, is_internal_ip, is_link_local_ip, set_tcp_nodelay_best_effort, @@ -210,6 +214,7 @@ impl ProxyHandle { activity_tx: Option, engine_ready: tokio::sync::watch::Receiver, upstream_proxy_args: &upstream_proxy::UpstreamProxyArgs, + endpoint_observation_tx: Option, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default // to loopback:3128. The default allows the proxy to function when no @@ -349,6 +354,7 @@ impl ProxyHandle { }); let dtx = denial_tx.clone(); let atx = activity_tx.clone(); + let endpoint_observations = endpoint_observation_tx.clone(); tokio::spawn(async move { #[allow(clippy::large_futures)] if let Err(err) = handle_tcp_connection( @@ -365,6 +371,7 @@ impl ProxyHandle { dynamic_credentials, dtx, atx, + endpoint_observations, ) .await { @@ -660,13 +667,16 @@ async fn handle_transparent_tcp_connection( &decision, None, None, - activity_tx.clone(), None, agent_proposals, // The transparent TCP path carries no PolicyLocalContext, so no // workspace is available here; matches the CONNECT path default when // policy-local context is absent. String::new(), + relay::RelaySignals { + activity: activity_tx.clone(), + endpoint_observation: None, + }, ); let middleware_gate = middleware_uninspectable_gate(&opa_engine, &ctx)?; if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::Deny { @@ -1453,6 +1463,7 @@ fn build_forward_policy_deny_ocsf_event( fn destination_denial_detail(kind: DestinationDenialKind) -> &'static str { match kind { + DestinationDenialKind::Resolution => "destination resolution failed", DestinationDenialKind::TrustedGateway => "trusted-gateway check failed", DestinationDenialKind::InvalidAllowedIps => "invalid allowed_ips in policy", DestinationDenialKind::AllowedIps => "allowed_ips check failed", @@ -1461,6 +1472,14 @@ fn destination_denial_detail(kind: DestinationDenialKind) -> &'static str { } } +fn endpoint_result_for_destination_failure(kind: DestinationDenialKind) -> EndpointResult { + if kind == DestinationDenialKind::Resolution { + EndpointResult::TransportFailed + } else { + EndpointResult::PolicyDenied + } +} + #[allow(clippy::too_many_arguments)] fn build_connect_destination_deny_ocsf_event( denial: &DestinationDenial, @@ -1653,7 +1672,13 @@ async fn handle_tcp_connection( >, denial_tx: Option>, activity_tx: Option, + endpoint_observation_tx: Option, ) -> Result<()> { + // Capture authority before request parsing or policy selection can yield. + // A later inventory installation cannot acquire this connection's result. + let endpoint_observation_context = endpoint_observation_tx + .as_ref() + .and_then(EndpointObservationSender::capture); let mut buf = vec![0u8; MAX_HEADER_BYTES]; let mut used = 0usize; @@ -1700,7 +1725,8 @@ async fn handle_tcp_connection( let target = parts.next().unwrap_or(""); if method != "CONNECT" { - return handle_forward_proxy( + // Keep forward-proxy request state off the connection future's stack. + return Box::pin(handle_forward_proxy( method, target, &buf[..], @@ -1716,7 +1742,8 @@ async fn handle_tcp_connection( dynamic_credentials, denial_tx.as_ref(), activity_tx.as_ref(), - ) + endpoint_observation_tx, + )) .await; } @@ -1852,12 +1879,25 @@ async fn handle_tcp_connection( hydrate_tls_mode(&mut decision); let effective_tls_skip = decision.endpoint.tls_mode == crate::l7::TlsMode::Skip; let credential_guard = query_endpoint_credential_guard(&opa_engine, &decision, &host_lc, port)?; + // Route materialization is safe before destination dialing and lets an + // unambiguous MCP authority report local fail-closed outcomes that occur + // before the HTTP request path becomes available. + hydrate_l7_route(&mut decision); + let connect_endpoint_observer = begin_unambiguous_endpoint_observation( + decision.endpoint.l7_route.as_ref(), + endpoint_observation_tx.as_ref(), + endpoint_observation_context.as_ref(), + &connect_generation_guard, + ); let sandbox_entrypoint_pid = identity_mode.entrypoint_pid(); match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { + if let Some(observer) = connect_endpoint_observer.as_ref() { + observer.observe(endpoint_result_for_destination_failure(denial.kind)); + } deny_connect_destination( &mut client, &denial, @@ -1894,6 +1934,11 @@ async fn handle_tcp_connection( { Ok(connector) => connector, Err(denial) => { + if let Some(observer) = connect_endpoint_observer.as_ref() { + // DNS failures deny the connection but represent unavailable + // transport, not rejection by the endpoint's destination policy. + observer.observe(endpoint_result_for_destination_failure(denial.kind)); + } deny_connect_destination( &mut client, &denial, @@ -1923,6 +1968,9 @@ async fn handle_tcp_connection( if refuse_connect_when_tls_unavailable(&mut client, tls_state.is_some(), effective_tls_skip) .await? { + if let Some(observer) = connect_endpoint_observer.as_ref() { + observer.observe(EndpointResult::TlsFailed); + } let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) @@ -1958,6 +2006,9 @@ async fn handle_tcp_connection( if credential_guard.blocks_connect() { const DETAIL: &str = "credentialed endpoint requires L7 inspection; raw tunnel is not explicitly allowed"; + if let Some(observer) = connect_endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) @@ -2002,7 +2053,6 @@ async fn handle_tcp_connection( // CONNECT must use one policy generation from authorization through route // materialization and relay startup. - hydrate_l7_route(&mut decision); let l7_route = decision.endpoint.l7_route.as_ref(); if let Err(error) = relay::validate_route_generation(l7_route, connect_generation_guard.captured_generation()) @@ -2032,7 +2082,15 @@ async fn handle_tcp_connection( .await?; return Ok(()); }; - let mut upstream = upstream_result.into_diagnostic()?; + let mut upstream = match upstream_result { + Ok(upstream) => upstream, + Err(error) => { + if let Some(observer) = connect_endpoint_observer.as_ref() { + observer.observe(EndpointResult::TransportFailed); + } + return Err(error).into_diagnostic(); + } + }; if let Err(error) = connect_generation_guard.ensure_current() { reject_stale_connect_policy(&mut client, &host_lc, port, activity_tx.as_ref(), error) .await?; @@ -2075,10 +2133,13 @@ async fn handle_tcp_connection( &decision, provider_credentials, secret_resolver.clone(), - activity_tx.clone(), dynamic_credentials.clone(), agent_proposals, workspace, + relay::RelaySignals { + activity: activity_tx.clone(), + endpoint_observation: endpoint_observation_tx, + }, ); if effective_tls_skip { @@ -2133,12 +2194,41 @@ async fn handle_tcp_connection( // TLS detected — terminate unconditionally. if let Some(ref tls) = tls_state { ctx.request_default_port = Some(443); + // Complete the client handshake before observing the upstream + // connection. A malformed client handshake provides no evidence + // of an endpoint network failure. + let mut tls_client = + match crate::l7::tls::tls_terminate_client(client, tls, &host_lc).await { + Ok(client) => client, + Err(error) => { + debug!(host = %host_lc, port, "client TLS handshake failed"); + return Err(error); + } + }; + let mut tls_upstream = match crate::l7::tls::tls_connect_upstream( + upstream, + &host_lc, + tls.upstream_config(), + ) + .await + { + Ok(upstream) => upstream, + Err(error) => { + if let Some(observer) = connect_endpoint_observer.as_ref() { + observer.observe(EndpointResult::TlsFailed); + } + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .message("Upstream TLS establishment failed") + .build(); + ocsf_emit!(event); + return Err(error); + } + }; let tls_result = async { - let mut tls_client = - crate::l7::tls::tls_terminate_client(client, tls, &host_lc).await?; - let mut tls_upstream = - crate::l7::tls::tls_connect_upstream(upstream, &host_lc, tls.upstream_config()) - .await?; let Some(relay_context) = relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) else { @@ -2891,6 +2981,28 @@ fn select_l7_config_for_path<'a>( .max_by_key(|snapshot| snapshot.config.path_specificity()) } +/// Begin a pre-path observation only when an authority identifies one tool server endpoint. +/// +/// CONNECT dialing and upstream TLS happen before the HTTP path is available. +/// Multiple MCP configs on the same authority are therefore intentionally +/// ambiguous and cannot identify the endpoint whose connection failed. +fn begin_unambiguous_endpoint_observation( + route: Option<&L7RouteSnapshot>, + sender: Option<&EndpointObservationSender>, + context: Option<&EndpointObservationContext>, + guard: &PolicyGenerationGuard, +) -> Option { + let mut mcp_configs = route?.configs.iter().filter(|snapshot| { + snapshot.config.protocol == crate::l7::L7Protocol::Mcp + && !snapshot.config.endpoint_id.is_empty() + }); + let config = &mcp_configs.next()?.config; + if mcp_configs.any(|candidate| candidate.config.endpoint_id != config.endpoint_id) { + return None; + } + EndpointObserver::begin_captured(sender, config, context, None, Some(guard)) +} + /// Query the TLS mode for an endpoint, independent of L7 config. /// /// This extracts `tls: skip` from the endpoint even when no `protocol` is set. @@ -2993,6 +3105,31 @@ fn normalize_host_lookup_key(host: &str) -> &str { h.strip_suffix('.').unwrap_or(h) } +/// Separates resolver failures from address-policy rejections while preserving +/// the existing diagnostic text used by proxy responses and tests. +#[derive(Debug)] +pub(crate) enum DestinationCheckError { + /// Name resolution did not produce an address set to authorize. + Resolution(String), + /// The resolved address set violated destination policy. + Denied(String), +} + +impl DestinationCheckError { + #[cfg(test)] + fn contains(&self, needle: &str) -> bool { + self.to_string().contains(needle) + } +} + +impl std::fmt::Display for DestinationCheckError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Resolution(reason) | Self::Denied(reason) => formatter.write_str(reason), + } + } +} + /// Returns `true` if `host` is one of the well-known driver-injected aliases /// for the host machine (e.g. `host.openshell.internal`). pub(crate) fn is_host_gateway_alias(host: &str) -> bool { @@ -3132,43 +3269,43 @@ async fn resolve_and_check_trusted_gateway( port: u16, trusted_gw: IpAddr, entrypoint_pid: u32, -) -> std::result::Result, String> { +) -> std::result::Result, DestinationCheckError> { if BLOCKED_CONTROL_PLANE_PORTS.contains(&port) { - return Err(format!( + return Err(DestinationCheckError::Denied(format!( "port {port} is a blocked control-plane port, connection rejected" - )); + ))); } let addrs = resolve_socket_addrs(host, port, entrypoint_pid).await?; if addrs.is_empty() { - return Err(format!( + return Err(DestinationCheckError::Denied(format!( "DNS resolution returned no addresses for {}", normalize_host_lookup_key(host) - )); + ))); } for addr in &addrs { if is_cloud_metadata_ip(addr.ip()) { - return Err(format!( + return Err(DestinationCheckError::Denied(format!( "{host} resolves to cloud metadata address {}, connection rejected", addr.ip() - )); + ))); } if addr.ip() != trusted_gw { - return Err(format!( + return Err(DestinationCheckError::Denied(format!( "{host} resolves to {} which does not match trusted host gateway \ {trusted_gw}, connection rejected", addr.ip() - )); + ))); } // Defense-in-depth: even if the resolved IP matches trusted_gw, reject // any non-link-local address. detect_trusted_host_gateway() already // enforces this at startup, but we re-check here to guard against any // unanticipated code path that might admit a private or loopback IP. if !is_link_local_ip(addr.ip()) { - return Err(format!( + return Err(DestinationCheckError::Denied(format!( "{host} resolves to non-link-local address {}, \ connection rejected", addr.ip() - )); + ))); } } Ok(addrs) @@ -3260,7 +3397,7 @@ async fn resolve_socket_addrs( host: &str, port: u16, entrypoint_pid: u32, -) -> std::result::Result, String> { +) -> std::result::Result, DestinationCheckError> { if let Some(addrs) = resolve_ip_literal(host, port) { return Ok(addrs); } @@ -3275,13 +3412,17 @@ async fn resolve_socket_addrs( .unwrap_or(host); let addrs: Vec = tokio::net::lookup_host((dns_host, port)) .await - .map_err(|e| format!("DNS resolution failed for {dns_host}:{port}: {e}"))? + .map_err(|e| { + DestinationCheckError::Resolution(format!( + "DNS resolution failed for {dns_host}:{port}: {e}" + )) + })? .collect(); if addrs.is_empty() { - return Err(format!( + return Err(DestinationCheckError::Resolution(format!( "DNS resolution returned no addresses for {dns_host}:{port}" - )); + ))); } Ok(addrs) @@ -3483,9 +3624,9 @@ async fn resolve_and_reject_internal( host: &str, port: u16, entrypoint_pid: u32, -) -> std::result::Result, String> { +) -> std::result::Result, DestinationCheckError> { let addrs = resolve_socket_addrs(host, port, entrypoint_pid).await?; - reject_internal_resolved_addrs(host, &addrs)?; + reject_internal_resolved_addrs(host, &addrs).map_err(DestinationCheckError::Denied)?; Ok(addrs) } @@ -3502,9 +3643,10 @@ async fn resolve_and_check_allowed_ips( port: u16, allowed_ips: &[ipnet::IpNet], entrypoint_pid: u32, -) -> std::result::Result, String> { +) -> std::result::Result, DestinationCheckError> { let addrs = resolve_socket_addrs(host, port, entrypoint_pid).await?; - validate_allowed_ips_for_resolved_addrs(host, port, &addrs, allowed_ips)?; + validate_allowed_ips_for_resolved_addrs(host, port, &addrs, allowed_ips) + .map_err(DestinationCheckError::Denied)?; Ok(addrs) } @@ -3517,9 +3659,10 @@ async fn resolve_and_check_declared_endpoint( host: &str, port: u16, entrypoint_pid: u32, -) -> std::result::Result, String> { +) -> std::result::Result, DestinationCheckError> { let addrs = resolve_socket_addrs(host, port, entrypoint_pid).await?; - validate_declared_endpoint_resolved_addrs(host, port, &addrs)?; + validate_declared_endpoint_resolved_addrs(host, port, &addrs) + .map_err(DestinationCheckError::Denied)?; Ok(addrs) } @@ -4112,6 +4255,7 @@ struct ForwardRelayOptions<'a> { signing_region: &'a str, host: &'a str, port: u16, + endpoint_observer: Option<&'a EndpointObserver>, } async fn relay_rewritten_forward_request( @@ -4141,7 +4285,7 @@ where body_length, }; - crate::l7::rest::relay_http_request_with_options_guarded( + crate::l7::rest::relay_http_request_with_options_guarded_observed( &req, client, upstream, @@ -4159,6 +4303,7 @@ where host: options.host, port: options.port, }, + options.endpoint_observer, ) .await } @@ -4221,7 +4366,13 @@ async fn handle_forward_proxy( >, denial_tx: Option<&mpsc::UnboundedSender>, activity_tx: Option<&ActivitySender>, + endpoint_observation_tx: Option, ) -> Result<()> { + // Capture authority before asynchronous authorization or credential selection. + // Policy and provider snapshots must belong to this same installation. + let endpoint_observation_context = endpoint_observation_tx + .as_ref() + .and_then(EndpointObservationSender::capture); let mut telemetry_path = forward_telemetry_path(target_uri); // 1. Parse the absolute-form URI. Every external forward target is // canonicalized below before credential binding, policy-path evaluation, @@ -4451,6 +4602,7 @@ async fn handle_forward_proxy( let mut request_body_credential_rewrite = false; let mut deny_uninspected_credentials = false; let mut l7_activity_pending = false; + let mut endpoint_observer = None; // 4b. If the endpoint has L7 config, evaluate the request against // L7 policy. The forward proxy handles exactly one request per @@ -4511,10 +4663,13 @@ async fn handle_forward_proxy( &decision, provider_credentials, secret_resolver.clone(), - activity_tx.cloned(), dynamic_credentials.clone(), agent_proposals, workspace, + relay::RelaySignals { + activity: activity_tx.cloned(), + endpoint_observation: endpoint_observation_tx, + }, ); l7_ctx.request_default_port = match scheme.as_str() { "http" => Some(80), @@ -4617,6 +4772,13 @@ async fn handle_forward_proxy( .await?; return Ok(()); }; + endpoint_observer = EndpointObserver::begin_captured( + l7_ctx.endpoint_observation_tx.as_ref(), + &l7_config.config, + endpoint_observation_context.as_ref(), + None, + Some(&forward_generation_guard), + ); // `canonicalize_options` was built before the matching config was // known, so `allow_encoded_slash` was taken permissively across every // config on this route. Re-check it against the config that actually @@ -4627,6 +4789,9 @@ async fn handle_forward_proxy( if !l7_config.config.allow_encoded_slash && crate::l7::path::canonical_path_has_encoded_slash(&path) { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } ocsf_emit!(build_forward_l7_parse_rejection_ocsf_event( workload_addr, method, @@ -4654,6 +4819,9 @@ async fn handle_forward_proxy( return Ok(()); } if crate::l7::rest::request_is_h2c_upgrade(&forward_request_bytes) { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(ActionId::Denied) @@ -4825,6 +4993,7 @@ async fn handle_forward_proxy( client, &l7_ctx, &telemetry_path, + endpoint_observer.as_ref(), ) .await? { @@ -4940,6 +5109,9 @@ async fn handle_forward_proxy( || (!allowed && l7_config.config.enforcement == crate::l7::EnforcementMode::Enforce); if effectively_denied { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } emit_activity_simple(activity_tx, true, "l7_policy"); emit_denial_simple( denial_tx, @@ -4982,6 +5154,9 @@ async fn handle_forward_proxy( match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } deny_forward_destination( client, &denial, @@ -5019,6 +5194,9 @@ async fn handle_forward_proxy( { Ok(connector) => connector, Err(denial) => { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(endpoint_result_for_destination_failure(denial.kind)); + } deny_forward_destination( client, &denial, @@ -5042,6 +5220,9 @@ async fn handle_forward_proxy( }; if let Err(e) = forward_generation_guard.ensure_current() { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } warn!( host = %host_lc, port, @@ -5077,6 +5258,9 @@ async fn handle_forward_proxy( let (chain, generation) = opa_engine.query_middleware_chain_with_generation(&middleware_input)?; if generation != forward_generation_guard.captured_generation() { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } emit_l7_tunnel_close_after_policy_change( &host_lc, port, @@ -5125,6 +5309,9 @@ async fn handle_forward_proxy( forward_request_bytes = match pipeline.apply(request, client, chain).await? { crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request.raw_header, crate::l7::middleware::MiddlewareApplyResult::Denied { denial, .. } => { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } emit_activity_simple(activity_tx, true, "middleware"); let response = denial.as_ref().map_or_else( || build_middleware_failure_response(&l7_ctx.policy_name), @@ -5176,6 +5363,9 @@ async fn handle_forward_proxy( }; crate::l7::middleware::emit_websocket_preflight_events(&l7_ctx, &preflight); if preflight.terminal_reason.is_some() { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } let response = preflight.denial.as_ref().map_or_else( || build_middleware_failure_response(&l7_ctx.policy_name), |denial| build_middleware_deny_response(&l7_ctx.policy_name, denial), @@ -5200,6 +5390,9 @@ async fn handle_forward_proxy( { Ok(bytes) => bytes, Err(e) => { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe_credential_failure(false); + } warn!( dst_host = %host_lc, dst_port = port, @@ -5235,6 +5428,18 @@ async fn handle_forward_proxy( port, &path, ); + // Rebind using the exact revision that supplied the request resolver. An + // inventory still publishing an older provider revision cannot claim a + // result produced with newly installed credentials. + endpoint_observer = forward_upgrade_config.as_ref().and_then(|config| { + EndpointObserver::begin_captured( + l7_ctx.endpoint_observation_tx.as_ref(), + config, + endpoint_observation_context.as_ref(), + endpoint_credentials.revision, + Some(&forward_generation_guard), + ) + }); let secret_resolver = endpoint_credentials.resolver; let credential_generation = match ( l7_ctx.provider_credentials.as_ref(), @@ -5245,8 +5450,13 @@ async fn handle_forward_proxy( )), _ => None, }; - if let Some(guard) = credential_generation { - guard.ensure_current()?; + if let Some(guard) = credential_generation + && let Err(error) = guard.ensure_current() + { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe_credential_failure(false); + } + return Err(error); } // 9. Rewrite request and forward to upstream @@ -5271,6 +5481,9 @@ async fn handle_forward_proxy( .await; } if e.is_endpoint_mismatch() { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe_credential_failure(true); + } emit_credential_endpoint_mismatch(&host_lc, port, policy_str); respond( client, @@ -5283,6 +5496,9 @@ async fn handle_forward_proxy( ) .await?; } else { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe_credential_failure(false); + } respond( client, &build_json_error_response( @@ -5315,6 +5531,7 @@ async fn handle_forward_proxy( client, &l7_ctx, &telemetry_path, + endpoint_observer.as_ref(), ) .await? { @@ -5331,6 +5548,9 @@ async fn handle_forward_proxy( }; if let Err(e) = forward_generation_guard.ensure_current() { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } warn!( host = %host_lc, port, @@ -5366,6 +5586,9 @@ async fn handle_forward_proxy( let mut upstream = match dial_result { Ok(s) => s, Err(e) => { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::TransportFailed); + } let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Low) @@ -5405,6 +5628,9 @@ async fn handle_forward_proxy( }; if let Err(e) = forward_generation_guard.ensure_current() { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } warn!( host = %host_lc, port, @@ -5462,12 +5688,18 @@ async fn handle_forward_proxy( signing_region, host: &host_lc, port, + endpoint_observer: endpoint_observer.as_ref(), }, ) .await; let outcome_result = match outcome_result { Err(report) => { if let Some(error) = report.downcast_ref::() { + // Body classification also covers credentials for other endpoints. + // Record the local denial before cleanup or client I/O can fail. + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe(EndpointResult::PolicyDenied); + } if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) @@ -5478,6 +5710,9 @@ async fn handle_forward_proxy( return Ok(()); } if let Some(error) = report.downcast_ref::() { + if let Some(observer) = endpoint_observer.as_ref() { + observer.observe_credential_failure(error.is_endpoint_mismatch()); + } if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) @@ -5486,6 +5721,13 @@ async fn handle_forward_proxy( crate::l7::relay::reject_credential_resolution(client, &l7_ctx, error).await?; return Ok(()); } + if report + .downcast_ref::() + .is_some() + && let Some(observer) = endpoint_observer.as_ref() + { + observer.observe_credential_failure(false); + } Err(report) } outcome => outcome, @@ -5798,6 +6040,18 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; + #[test] + fn endpoint_result_distinguishes_resolution_from_policy() { + assert_eq!( + endpoint_result_for_destination_failure(DestinationDenialKind::Resolution), + EndpointResult::TransportFailed + ); + assert_eq!( + endpoint_result_for_destination_failure(DestinationDenialKind::InternalAddress), + EndpointResult::PolicyDenied + ); + } + struct DenyWebSocketPreflight; #[tonic::async_trait] @@ -5976,6 +6230,7 @@ network_policies: {} None, None, None, + None, )) .await .expect("malformed request should be handled"); @@ -6118,7 +6373,7 @@ network_policies: tokio::time::timeout( std::time::Duration::from_secs(30), - handle_forward_proxy( + Box::pin(handle_forward_proxy( "POST", &target, request.as_bytes(), @@ -6134,7 +6389,8 @@ network_policies: None, None, None, - ), + None, + )), ) .await .expect("MCP forwarding should complete") @@ -6157,6 +6413,220 @@ network_policies: } } + async fn assert_plaintext_mcp_body_denial_observation( + observe_endpoint: bool, + close_response_stream: bool, + ) { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, EndpointStatusCommand, endpoint_id, + endpoint_status_channel, + }; + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + + let upstream_ip = + non_loopback_test_ipv4().expect("routable non-loopback IPv4 test address"); + let upstream_listener = TcpListener::bind((upstream_ip, 0)) + .await + .expect("bind MCP upstream listener"); + let upstream_port = upstream_listener.local_addr().unwrap().port(); + let executable = std::env::current_exe().expect("current executable"); + let data = format!( + r#"version: 1 +network_policies: + mcp-upstream: + endpoints: + - host: "{upstream_ip}" + port: {upstream_port} + path: /mcp + protocol: mcp + enforcement: enforce + rules: + - allow: + method: tools/call + tool: echo + binaries: + - {{ path: "{executable}" }} +"#, + executable = executable.display(), + ); + let mut policy = openshell_policy::parse_sandbox_policy(&data).expect("parse MCP policy"); + let endpoint = &mut policy + .network_policies + .get_mut("mcp-upstream") + .expect("MCP rule") + .endpoints[0]; + // Credential provenance is installed by the supervisor and cannot be + // authored in YAML. Preserve the same typed activation boundary here. + endpoint.provider_credentialed = true; + let observed_endpoint_id = endpoint_id(endpoint); + let engine = Arc::new(OpaEngine::from_proto(&policy).expect("activate MCP policy")); + let credentials = ProviderCredentialState::from_bound_environment( + 42, + TestHashMap::from([("API_TOKEN".into(), "private-test-secret".into())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".into(), + StaticCredentialBinding { + credential_identity: "mcp-provider".into(), + workload_credential_handle: String::new(), + endpoints: vec![StaticCredentialEndpointBinding { + host: upstream_ip.to_string(), + port: u32::from(upstream_port), + path: "/mcp".into(), + }], + }, + )]), + vec![], + ) + .expect("install bound MCP credential"); + let (sender, mut receiver) = endpoint_status_channel(); + sender + .reset( + EndpointConfigVersion { + policy_hash: openshell_core::policy_identity::deterministic_policy_hash( + &policy, + ), + provider_env_revision: 42, + }, + vec![EndpointInventoryEntry { + endpoint_id: observed_endpoint_id.clone(), + uses_provider_credentials: true, + }], + ) + .await + .expect("install endpoint inventory"); + assert!(matches!( + receiver.recv().await, + Some(EndpointStatusCommand::Reset { .. }) + )); + + let upstream = tokio::spawn(async move { + let (mut socket, _) = upstream_listener + .accept() + .await + .expect("accept MCP request"); + let mut request = Vec::new(); + socket + .read_to_end(&mut request) + .await + .expect("read upstream bytes"); + request + }); + let proxy_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind proxy listener"); + let mut caller = TcpStream::connect(proxy_listener.local_addr().unwrap()) + .await + .expect("connect MCP caller"); + let (mut proxy_connection, _) = proxy_listener.accept().await.expect("accept MCP caller"); + if close_response_stream { + // Keep the caller socket alive for process identity, but make the + // response write fail deterministically without TCP reset timing. + proxy_connection + .shutdown() + .await + .expect("close response stream"); + } + let target = format!("http://{upstream_ip}:{upstream_port}/mcp"); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"openshell:resolve:env:v999_API_TOKEN"}}}"#; + let request = format!( + "POST {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nContent-Type: application/json\r\nMCP-Protocol-Version: 2025-11-25\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + let result = Box::pin(handle_forward_proxy( + "POST", + &target, + request.as_bytes(), + request.len(), + &mut proxy_connection, + engine, + Arc::new(ProxyIdentityMode::static_binary(executable).expect("hash test executable")), + None, + AgentProposals::default(), + Arc::new(None), + Some(credentials), + None, + None, + None, + None, + observe_endpoint.then_some(sender), + )) + .await; + drop(proxy_connection); + + if close_response_stream { + assert!( + result.is_err(), + "the local 403 cannot be written after shutdown" + ); + } else { + result.expect("reject MCP body locally"); + let mut response = String::new(); + caller + .read_to_string(&mut response) + .await + .expect("read local denial"); + assert!(response.starts_with("HTTP/1.1 403 Forbidden"), "{response}"); + assert!(response.contains("known_unavailable"), "{response}"); + assert!(!response.contains("openshell:resolve:")); + assert!(!response.contains("private-test-secret")); + } + let forwarded = upstream.await.expect("join MCP upstream"); + let forwarded = String::from_utf8(forwarded).expect("UTF-8 upstream request"); + let (_, forwarded_body) = forwarded.split_once("\r\n\r\n").expect("upstream headers"); + assert!( + forwarded_body.is_empty(), + "rejected body must not reach upstream" + ); + assert!(!forwarded.contains("openshell:resolve:")); + assert!(!forwarded.contains("403 Forbidden")); + if observe_endpoint { + assert!(matches!( + receiver.try_recv().expect("body denial observation"), + EndpointStatusCommand::Observe { + endpoint_id, + result: EndpointResult::PolicyDenied, + .. + } if endpoint_id == observed_endpoint_id + )); + } + assert!( + receiver.try_recv().is_err(), + "at most one endpoint result per request" + ); + } + + #[tokio::test] + async fn plaintext_mcp_body_denial_records_policy_denied() { + tokio::time::timeout( + std::time::Duration::from_secs(30), + Box::pin(assert_plaintext_mcp_body_denial_observation(true, false)), + ) + .await + .expect("MCP body denial should complete"); + } + + #[tokio::test] + async fn plaintext_mcp_body_denial_records_policy_before_client_write_failure() { + tokio::time::timeout( + std::time::Duration::from_secs(30), + Box::pin(assert_plaintext_mcp_body_denial_observation(true, true)), + ) + .await + .expect("MCP body denial should survive a closed response stream"); + } + + #[tokio::test] + async fn plaintext_mcp_body_denial_without_observer_preserves_rejection() { + tokio::time::timeout( + std::time::Duration::from_secs(30), + Box::pin(assert_plaintext_mcp_body_denial_observation(false, false)), + ) + .await + .expect("MCP body denial must not depend on endpoint reporting"); + } + #[tokio::test] async fn plaintext_websocket_preflight_denial_does_not_connect_upstream() { if !cfg!(target_os = "linux") { @@ -6229,7 +6699,7 @@ network_policies: }); let (mut proxy_connection, _) = proxy_listener.accept().await.unwrap(); - tokio::time::timeout( + Box::pin(tokio::time::timeout( std::time::Duration::from_secs(30), handle_forward_proxy( "GET", @@ -6249,8 +6719,9 @@ network_policies: None, None, None, + None, ), - ) + )) .await .expect("denied preflight must complete without an upstream response") .expect("handle denied plaintext WebSocket upgrade"); @@ -6365,7 +6836,7 @@ network_policies: let (mut proxy_connection, _) = proxy_listener.accept().await.unwrap(); let handler = tokio::spawn(async move { - handle_forward_proxy( + Box::pin(handle_forward_proxy( "GET", &target, request.as_bytes(), @@ -6383,7 +6854,8 @@ network_policies: None, None, None, - ) + None, + )) .await }); let scenario = tokio::time::timeout(std::time::Duration::from_mins(1), async { @@ -6903,6 +7375,8 @@ network_policies: ) -> crate::l7::L7EndpointConfig { crate::l7::L7EndpointConfig { protocol, + endpoint_id: String::new(), + policy_hash: String::new(), path: "/**".to_string(), tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, @@ -7686,6 +8160,7 @@ network_policies: signing_region: "", host: "", port: 0, + endpoint_observer: None, }, ) .await?; @@ -7951,6 +8426,7 @@ network_policies: signing_region: "", host: "", port: 0, + endpoint_observer: None, }, ) .await?; @@ -8246,6 +8722,8 @@ network_policies: L7ConfigSnapshot { config: crate::l7::L7EndpointConfig { protocol: crate::l7::L7Protocol::Rest, + endpoint_id: String::new(), + policy_hash: String::new(), path: "/**".to_string(), tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, @@ -8267,6 +8745,8 @@ network_policies: L7ConfigSnapshot { config: crate::l7::L7EndpointConfig { protocol: crate::l7::L7Protocol::Graphql, + endpoint_id: String::new(), + policy_hash: String::new(), path: "/graphql".to_string(), tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, @@ -8296,6 +8776,310 @@ network_policies: assert_eq!(selected.config.protocol, crate::l7::L7Protocol::Rest); } + #[tokio::test] + async fn connect_observation_requires_one_distinct_endpoint() { + use openshell_core::endpoint_status::{EndpointConfigVersion, endpoint_status_channel}; + + let policy_hash = openshell_core::policy_identity::deterministic_policy_hash( + &ProtoSandboxPolicy::default(), + ); + let (sender, _receiver) = endpoint_status_channel(); + sender + .reset( + EndpointConfigVersion { + policy_hash: policy_hash.clone(), + provider_env_revision: 1, + }, + Vec::new(), + ) + .await + .expect("install test configuration"); + let mut first = websocket_l7_config(crate::l7::L7Protocol::Mcp, false); + first.path = "/first".to_string(); + first.endpoint_id = "endpoint:v1:first".to_string(); + first.policy_hash = policy_hash; + let duplicate = first.clone(); + let mut second = first.clone(); + second.path = "/second".to_string(); + second.endpoint_id = "endpoint:v1:second".to_string(); + + let duplicate_route = L7RouteSnapshot { + configs: vec![ + L7ConfigSnapshot { + config: first.clone(), + }, + L7ConfigSnapshot { config: duplicate }, + ], + l7_policy_generation: 1, + }; + let context = sender.capture().expect("capture installed inventory"); + let guard = forward_test_guard(); + assert!( + begin_unambiguous_endpoint_observation( + Some(&duplicate_route), + Some(&sender), + Some(&context), + &guard, + ) + .is_some(), + "equivalent configs share one canonical endpoint ID" + ); + + let ambiguous_route = L7RouteSnapshot { + configs: vec![ + L7ConfigSnapshot { config: first }, + L7ConfigSnapshot { config: second }, + ], + l7_policy_generation: 1, + }; + assert!( + begin_unambiguous_endpoint_observation( + Some(&ambiguous_route), + Some(&sender), + Some(&context), + &guard, + ) + .is_none(), + "CONNECT cannot guess between path-specific tool server endpoints" + ); + } + + fn observation_test_policy(tool: &str) -> ProtoSandboxPolicy { + openshell_policy::parse_sandbox_policy(&format!( + r#"version: 1 +network_policies: + mcp: + endpoints: + - host: tools.example.test + port: 443 + path: /mcp + protocol: mcp + enforcement: enforce + rules: + - allow: {{ method: tools/call, tool: {tool} }} + binaries: + - {{ path: /usr/bin/curl }} +"#, + )) + .expect("parse observation policy") + } + + fn observation_test_route(engine: &OpaEngine) -> L7RouteSnapshot { + let (configs, generation) = engine + .query_endpoint_configs_with_generation(&crate::opa::NetworkInput { + host: "tools.example.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: String::new(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }) + .expect("select observed route"); + L7RouteSnapshot { + configs: configs + .iter() + .map(|value| L7ConfigSnapshot { + config: crate::l7::parse_l7_config(value).expect("parse observed endpoint"), + }) + .collect(), + l7_policy_generation: generation, + } + } + + #[tokio::test] + async fn connect_observation_rejects_retired_context_after_policy_cycle() { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointStatusCommand, endpoint_status_channel, + }; + use openshell_core::policy_identity::deterministic_policy_hash; + + let policy_a = observation_test_policy("echo"); + let policy_b = observation_test_policy("other"); + let engine = OpaEngine::from_proto(&policy_a).expect("activate initial policy"); + let (sender, mut receiver) = endpoint_status_channel(); + sender + .reset( + EndpointConfigVersion { + policy_hash: deterministic_policy_hash(&policy_a), + provider_env_revision: 1, + }, + Vec::new(), + ) + .await + .expect("install initial inventory"); + let _reset = receiver.recv().await.expect("initial inventory reset"); + let stale_context = sender.capture().expect("capture initial authority"); + let old_route = observation_test_route(&engine); + let old_guard = engine + .generation_guard(old_route.l7_policy_generation) + .unwrap(); + for policy in [&policy_b, &policy_a] { + engine.reload_from_proto(policy).expect("replace policy"); + sender + .reset( + EndpointConfigVersion { + policy_hash: deterministic_policy_hash(policy), + provider_env_revision: 1, + }, + Vec::new(), + ) + .await + .expect("replace observation inventory"); + let _reset = receiver.recv().await.expect("replacement inventory reset"); + } + + let current_route = observation_test_route(&engine); + let current_guard = engine + .generation_guard(current_route.l7_policy_generation) + .unwrap(); + let old_config = select_l7_config_for_path(&old_route.configs, "/mcp").unwrap(); + let current_config = select_l7_config_for_path(¤t_route.configs, "/mcp").unwrap(); + assert_eq!( + old_config.config.endpoint_id, + current_config.config.endpoint_id + ); + // Even a current route and generation guard cannot revive the old + // request's authority when its policy hash returns to the same value. + assert!( + begin_unambiguous_endpoint_observation( + Some(¤t_route), + Some(&sender), + Some(&stale_context), + ¤t_guard, + ) + .is_none() + ); + let fresh_context = sender.capture().expect("capture replacement authority"); + assert!( + begin_unambiguous_endpoint_observation( + Some(&old_route), + Some(&sender), + Some(&fresh_context), + &old_guard, + ) + .is_none() + ); + let observer = begin_unambiguous_endpoint_observation( + Some(¤t_route), + Some(&sender), + Some(&fresh_context), + ¤t_guard, + ) + .expect("fresh request binds the current route"); + observer.observe(EndpointResult::PolicyDenied); + assert!( + matches!(receiver.try_recv().expect("fresh observation"), EndpointStatusCommand::Observe { + endpoint_id, + result: EndpointResult::PolicyDenied, + .. + } if endpoint_id == current_config.config.endpoint_id) + ); + assert!(receiver.try_recv().is_err()); + } + + #[tokio::test] + async fn forward_observation_rejects_retired_context_after_provider_cycle() { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointStatusCommand, endpoint_status_channel, + }; + use openshell_core::policy_identity::deterministic_policy_hash; + + let policy = observation_test_policy("echo"); + let engine = OpaEngine::from_proto(&policy).expect("activate policy"); + let route = observation_test_route(&engine); + let config = &select_l7_config_for_path(&route.configs, "/mcp") + .unwrap() + .config; + let guard = engine.generation_guard(route.l7_policy_generation).unwrap(); + let credentials = ProviderCredentialState::from_child_env_snapshot(1, TestHashMap::new()); + let (sender, mut receiver) = endpoint_status_channel(); + sender + .reset( + EndpointConfigVersion { + policy_hash: deterministic_policy_hash(&policy), + provider_env_revision: 1, + }, + Vec::new(), + ) + .await + .expect("install initial inventory"); + let _reset = receiver.recv().await.expect("initial inventory reset"); + let stale_context = sender.capture().expect("capture initial authority"); + + for revision in [2, 1] { + credentials.install_child_env_snapshot(revision, TestHashMap::new()); + sender + .reset( + EndpointConfigVersion { + policy_hash: deterministic_policy_hash(&policy), + provider_env_revision: revision, + }, + Vec::new(), + ) + .await + .expect("replace provider inventory"); + let _reset = receiver.recv().await.expect("replacement inventory reset"); + } + let selected = endpoint_credentials_for_request( + Some(&credentials), + None, + "tools.example.test", + 443, + "/mcp", + ); + assert!( + EndpointObserver::begin_captured( + Some(&sender), + config, + Some(&stale_context), + selected.revision, + Some(&guard), + ) + .is_none() + ); + + let fresh_context = sender.capture().expect("capture replacement authority"); + let observer = EndpointObserver::begin_captured( + Some(&sender), + config, + Some(&fresh_context), + selected.revision, + Some(&guard), + ) + .expect("fresh request uses the selected provider revision"); + observer.observe(EndpointResult::PolicyDenied); + assert!( + matches!(receiver.try_recv().expect("fresh observation"), EndpointStatusCommand::Observe { + endpoint_id, + result: EndpointResult::PolicyDenied, + .. + } if endpoint_id == config.endpoint_id) + ); + assert!(receiver.try_recv().is_err()); + + // Provider activation can precede inventory publication. Bind against + // the resolver's revision, even if a later snapshot changes again. + credentials.install_child_env_snapshot(2, TestHashMap::new()); + let mismatched = endpoint_credentials_for_request( + Some(&credentials), + None, + "tools.example.test", + 443, + "/mcp", + ); + credentials.install_child_env_snapshot(1, TestHashMap::new()); + assert!( + EndpointObserver::begin_captured( + Some(&sender), + config, + Some(&fresh_context), + mismatched.revision, + Some(&guard), + ) + .is_none() + ); + } + // -- is_internal_ip: IPv4 -- #[test] @@ -10458,6 +11242,7 @@ network_policies: signing_region: "", host: "", port: 0, + endpoint_observer: None, }, ) .await @@ -10540,6 +11325,7 @@ network_policies: signing_region: "us-west-2", host: "api.example.com", port: 80, + endpoint_observer: None, }, ) .await @@ -10629,6 +11415,7 @@ network_policies: signing_region: "", host: "", port: 0, + endpoint_observer: None, }, ) .await; @@ -10680,6 +11467,7 @@ network_policies: signing_region: "", host: "", port: 0, + endpoint_observer: None, }, ) .await; @@ -11007,6 +11795,22 @@ network_policies: } } + fn connect_handler_test_policy(endpoint_yaml: &str) -> ProtoSandboxPolicy { + let exe = std::env::current_exe().expect("current_exe"); + let data = format!( + r#"version: 1 +network_policies: + test_allow: + name: test_allow + endpoints: +{endpoint_yaml} binaries: + - {{ path: "{exe}" }} +"#, + exe = exe.display(), + ); + openshell_policy::parse_sandbox_policy(&data).expect("parse CONNECT test policy") + } + /// Drives a real `CONNECT` through the full `handle_tcp_connection` entry /// point and returns `(completed, client_response_bytes, denial_stages)`. /// `completed` is `false` when the handler was still running at `budget` @@ -11026,23 +11830,15 @@ network_policies: endpoint_yaml: &str, connect_target: &str, budget: std::time::Duration, + endpoint_observation_tx: Option, ) -> (bool, Vec, Vec) { - const POLICY_REGO: &str = include_str!("../data/sandbox-policy.rego"); - // Allow the current test binary — the in-process client's // `/proc//exe` — to reach the endpoint. Matching is by path. let exe = std::env::current_exe().expect("current_exe"); - let data = format!( - r#"network_policies: - test_allow: - name: test_allow - endpoints: -{endpoint_yaml} binaries: - - {{ path: "{exe}" }} -"#, - exe = exe.display(), - ); - let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, &data).expect("load policy")); + // Typed activation derives each endpoint ID from its configured + // endpoint, as it does for the sandbox's installed endpoint inventory. + let policy = connect_handler_test_policy(endpoint_yaml); + let engine = Arc::new(OpaEngine::from_proto(&policy).expect("load policy")); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let proxy_port = listener.local_addr().unwrap().port(); @@ -11081,6 +11877,7 @@ network_policies: None, // dynamic_credentials Some(denial_tx), // denial_tx — positive allow/deny signal None, // activity_tx + endpoint_observation_tx, )), ) .await @@ -11146,6 +11943,7 @@ network_policies: None, Some(denial_tx), None, + None, )) .await .expect("forward handler should complete"); @@ -11177,6 +11975,7 @@ network_policies: " - { host: \"203.0.113.10\", port: 443 }\n", "203.0.113.10:443", std::time::Duration::from_secs(30), + None, ) .await; @@ -11210,6 +12009,7 @@ network_policies: " - { host: \"127.0.0.1\", port: 443 }\n", "127.0.0.1:443", std::time::Duration::from_secs(30), + None, ) .await; @@ -11229,6 +12029,96 @@ network_policies: ); } + #[cfg(target_os = "linux")] + #[tokio::test] + async fn connect_handler_reports_endpoint_destination_failures() { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, EndpointResult, EndpointStatusCommand, + endpoint_id, endpoint_status_channel, + }; + + for (host, expected_result, expected_detail) in [ + ( + "openshell-tool-endpoint-does-not-exist.invalid", + EndpointResult::TransportFailed, + "destination resolution failed", + ), + ( + "127.0.0.1", + EndpointResult::PolicyDenied, + "declared endpoint check failed", + ), + ] { + let endpoint_id = endpoint_id(&NetworkEndpoint { + host: host.to_string(), + port: 443, + path: "/mcp".to_string(), + protocol: "mcp".to_string(), + ..Default::default() + }); + // One configured MCP endpoint makes CONNECT attribution + // unambiguous before an HTTP request path is available. + let endpoint_yaml = format!( + " - host: \"{host}\"\n port: 443\n path: /mcp\n protocol: mcp\n enforcement: enforce\n rules:\n - allow: {{ method: initialize }}\n" + ); + let config_version = EndpointConfigVersion { + policy_hash: openshell_core::policy_identity::deterministic_policy_hash( + &connect_handler_test_policy(&endpoint_yaml), + ), + provider_env_revision: 1, + }; + let (sender, mut observations) = endpoint_status_channel(); + sender + .reset( + config_version.clone(), + vec![EndpointInventoryEntry { + endpoint_id: endpoint_id.clone(), + uses_provider_credentials: false, + }], + ) + .await + .expect("install observation inventory"); + assert!(matches!( + observations.try_recv().expect("inventory reset is queued"), + EndpointStatusCommand::Reset { .. } + )); + + let (completed, response, denial_stages) = drive_connect_through_handler( + &endpoint_yaml, + &format!("{host}:443"), + std::time::Duration::from_secs(30), + Some(sender), + ) + .await; + assert!(completed, "destination validation must finish for {host}"); + let response = String::from_utf8_lossy(&response); + assert!(response.starts_with("HTTP/1.1 403 Forbidden"), "{response}"); + assert!(response.contains("ssrf_denied"), "{response}"); + assert!(response.contains(expected_detail), "{response}"); + assert!(!response.contains("200 Connection Established")); + assert_eq!(denial_stages, ["ssrf"]); + + let observation = observations + .try_recv() + .expect("CONNECT must publish the MCP destination failure"); + assert!( + matches!( + observation, + EndpointStatusCommand::Observe { + config_version: observed_config_version, + endpoint_id: observed_endpoint_id, + result, + .. + } if observed_config_version == config_version + && observed_endpoint_id == endpoint_id + && result == expected_result + ), + "CONNECT must report {expected_result:?} for the configured endpoint {host}" + ); + assert!(observations.try_recv().is_err()); + } + } + #[tokio::test] async fn forward_handler_preserves_ssrf_response_and_denial_stage() { if !cfg!(target_os = "linux") { @@ -11278,6 +12168,7 @@ network_policies: " - { host: \"203.0.113.10\", port: 443, tls: skip }\n", "203.0.113.10:443", std::time::Duration::from_millis(800), + None, ) .await; diff --git a/crates/openshell-supervisor-network/src/proxy/destination.rs b/crates/openshell-supervisor-network/src/proxy/destination.rs index 1ce514133..c0a2bd902 100644 --- a/crates/openshell-supervisor-network/src/proxy/destination.rs +++ b/crates/openshell-supervisor-network/src/proxy/destination.rs @@ -9,10 +9,10 @@ //! Shared external destination validation and upstream dial boundary. use super::{ - BLOCKED_CONTROL_PLANE_PORTS, implicit_allowed_ips_for_ip_host, is_cloud_metadata_ip, - is_host_gateway_alias, is_link_local_ip, parse_allowed_ips, resolve_and_check_allowed_ips, - resolve_and_check_declared_endpoint, resolve_and_check_trusted_gateway, - resolve_and_reject_internal, + BLOCKED_CONTROL_PLANE_PORTS, DestinationCheckError, implicit_allowed_ips_for_ip_host, + is_cloud_metadata_ip, is_host_gateway_alias, is_link_local_ip, parse_allowed_ips, + resolve_and_check_allowed_ips, resolve_and_check_declared_endpoint, + resolve_and_check_trusted_gateway, resolve_and_reject_internal, }; use ipnet::IpNet; use openshell_core::net::{connect_tcp_nodelay_best_effort, is_always_blocked_ip, is_internal_ip}; @@ -56,6 +56,7 @@ pub(crate) struct DestinationRequest<'a> { /// and OCSF message shapes while sharing the underlying validation logic. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DestinationDenialKind { + Resolution, TrustedGateway, InvalidAllowedIps, AllowedIps, @@ -73,6 +74,15 @@ impl DestinationDenial { fn new(kind: DestinationDenialKind, reason: String) -> Self { Self { kind, reason } } + + fn from_check(error: DestinationCheckError, denied_kind: DestinationDenialKind) -> Self { + match error { + DestinationCheckError::Resolution(reason) => { + Self::new(DestinationDenialKind::Resolution, reason) + } + DestinationCheckError::Denied(reason) => Self::new(denied_kind, reason), + } + } } /// Select one current destination-validation mode without changing precedence. @@ -292,37 +302,37 @@ pub(crate) async fn validate_destination( AddressAuthorization::TrustedGatewayAlias { expected_ip } => { resolve_and_check_trusted_gateway(host, port, *expected_ip, sandbox_entrypoint_pid) .await - .map_err(|reason| { - DestinationDenial::new(DestinationDenialKind::TrustedGateway, reason) + .map_err(|error| { + DestinationDenial::from_check(error, DestinationDenialKind::TrustedGateway) })? } AddressAuthorization::ExplicitAllowedIps(networks) => { resolve_and_check_allowed_ips(host, port, networks, sandbox_entrypoint_pid) .await - .map_err(|reason| { - DestinationDenial::new(DestinationDenialKind::AllowedIps, reason) + .map_err(|error| { + DestinationDenial::from_check(error, DestinationDenialKind::AllowedIps) })? } AddressAuthorization::ImplicitIpLiteral(ip) => { let network = IpNet::from(*ip); resolve_and_check_allowed_ips(host, port, &[network], sandbox_entrypoint_pid) .await - .map_err(|reason| { - DestinationDenial::new(DestinationDenialKind::AllowedIps, reason) + .map_err(|error| { + DestinationDenial::from_check(error, DestinationDenialKind::AllowedIps) })? } AddressAuthorization::ExactDeclaredHost => { resolve_and_check_declared_endpoint(host, port, sandbox_entrypoint_pid) .await - .map_err(|reason| { - DestinationDenial::new(DestinationDenialKind::DeclaredEndpoint, reason) + .map_err(|error| { + DestinationDenial::from_check(error, DestinationDenialKind::DeclaredEndpoint) })? } AddressAuthorization::DefaultPublicOnly => { resolve_and_reject_internal(host, port, sandbox_entrypoint_pid) .await - .map_err(|reason| { - DestinationDenial::new(DestinationDenialKind::InternalAddress, reason) + .map_err(|error| { + DestinationDenial::from_check(error, DestinationDenialKind::InternalAddress) })? } AddressAuthorization::PinnedResolved(addresses) => addresses @@ -375,6 +385,19 @@ mod tests { assert_eq!(denial.kind, DestinationDenialKind::InternalAddress); } + #[tokio::test] + async fn resolver_failure_has_a_distinct_failure_kind() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::ExactDeclaredHost, + }; + let denial = validate_destination(request("does-not-resolve.invalid", &plan)) + .await + .err() + .expect("reserved invalid TLD must not resolve"); + + assert_eq!(denial.kind, DestinationDenialKind::Resolution); + } + #[tokio::test] async fn invalid_allowed_ips_has_a_distinct_denial_kind() { let denial = build_validation_plan( @@ -409,7 +432,7 @@ mod tests { expected_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), }, }; - let denial = validate_destination(request("host.openshell.internal", &plan)) + let denial = validate_destination(request("127.0.0.1", &plan)) .await .err() .expect("loopback cannot be a trusted gateway"); diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index b40f4dee6..4c89e5e38 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -8,6 +8,7 @@ use crate::l7::relay::L7EvalContext; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard, TunnelPolicyEngine}; use miette::{IntoDiagnostic, Result}; use openshell_core::activity::ActivitySender; +use openshell_core::endpoint_status::EndpointObservationSender; use openshell_core::proto::ProviderProfileCredential; use openshell_core::secrets::SecretResolver; use std::collections::HashMap; @@ -37,15 +38,23 @@ pub(super) struct RelayContext<'a> { middleware_engine: &'a OpaEngine, } +/// Non-blocking observation channels attached to an authorized HTTP relay. +pub(super) struct RelaySignals { + /// Receives general sandbox network activity. + pub(super) activity: Option, + /// Receives terminal tool server results for endpoint status reporting. + pub(super) endpoint_observation: Option, +} + /// Build the request-processing context shared by CONNECT and forward HTTP. pub(super) fn http_context( decision: &EgressDecision, provider_credentials: Option, secret_resolver: Option>, - activity_tx: Option, dynamic_credentials: Option, agent_proposals: openshell_core::proposals::AgentProposals, workspace: String, + signals: RelaySignals, ) -> L7EvalContext { // Provider-backed credentials must be acquired from the live state for // each request after middleware/token-grant awaits. Keep only the legacy @@ -83,13 +92,14 @@ pub(super) fn http_context( provider_credentials, provider_credential_revision: None, body_classifier: None, - activity_tx, + activity_tx: signals.activity, dynamic_credentials: dynamic_credentials.clone(), token_grant_resolver: dynamic_credentials .as_ref() .map(|_| crate::l7::token_grant_injection::default_resolver()), agent_proposals, workspace, + endpoint_observation_tx: signals.endpoint_observation, } } @@ -360,6 +370,7 @@ mod tests { token_grant_resolver: None, agent_proposals: openshell_core::proposals::AgentProposals::default(), workspace: String::new(), + endpoint_observation_tx: None, } } @@ -405,6 +416,8 @@ mod tests { configs: vec![super::super::L7ConfigSnapshot { config: crate::l7::L7EndpointConfig { protocol: crate::l7::L7Protocol::Rest, + endpoint_id: String::new(), + policy_hash: String::new(), path: "/**".to_string(), tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index c0e78484a..9b2b6e82a 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -60,6 +60,10 @@ fn assert_json_response( #[tokio::test] async fn destination_denials_preserve_adapter_specific_wire_contracts() { let cases = [ + ( + DestinationDenialKind::Resolution, + "destination resolution failed", + ), ( DestinationDenialKind::TrustedGateway, "trusted-gateway check failed", @@ -556,6 +560,7 @@ network_policies: None, None, None, + None, )) .await .unwrap(); diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index b11d9aa0d..d1372d843 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -26,6 +26,7 @@ use openshell_ocsf::{ use openshell_core::activity::ActivitySender; use openshell_core::denial::DenialEvent; +use openshell_core::endpoint_status::EndpointObservationSender; use openshell_core::proposals::AgentProposals; use tokio::sync::mpsc::UnboundedSender; @@ -200,6 +201,7 @@ pub async fn run_networking( openshell_endpoint: Option<&str>, denial_tx: Option>, activity_tx: Option, + endpoint_observation_tx: Option, agent_proposals: AgentProposals, workspace_rx: tokio::sync::watch::Receiver, upstream_proxy_args: &crate::upstream_proxy::UpstreamProxyArgs, @@ -455,6 +457,7 @@ pub async fn run_networking( activity_tx.clone(), engine_ready_rx, upstream_proxy_args, + endpoint_observation_tx, ) .await?; Some(proxy_handle) diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index ef40fbd76..4f64b63e1 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -81,6 +81,7 @@ pub async fn run_process( entrypoint_pid: Arc, entrypoint_started_tx: Option>, sidecar_exit_tx: Option>, + supervisor_session_updates: Option>>, provider_credentials: ProviderCredentialState, provider_env: std::collections::HashMap, ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, @@ -377,7 +378,10 @@ pub async fn run_process( ssh_netns_fd, None, Arc::clone(&supervisor_terminating), - main_instance_id.clone(), + crate::supervisor_session::SessionRuntimeContext { + instance_id: main_instance_id.clone(), + session_id_updates: supervisor_session_updates, + }, ); info!("supervisor session task spawned"); Some(task) diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index a8f60a568..50505ff94 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -28,7 +28,7 @@ use openshell_ocsf::{ SeverityId, StatusId, ocsf_emit, }; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; use tokio_stream::StreamExt; use tracing::{debug, warn}; @@ -266,6 +266,14 @@ fn map_session_stream_message( } } +/// Runtime identity and status channel shared with a supervisor session task. +pub struct SessionRuntimeContext { + /// Identifies the local supervisor process across gateway reconnects. + pub instance_id: String, + /// Publishes the currently accepted gateway session to sibling reporters. + pub session_id_updates: Option>>, +} + /// Spawn the supervisor session task. /// /// The task runs for the lifetime of the sandbox process, reconnecting with @@ -277,7 +285,7 @@ pub fn spawn( netns_fd: Option, expected_ssh_peer_pid: Option, terminating: Arc, - instance_id: String, + runtime: SessionRuntimeContext, ) -> tokio::task::JoinHandle<()> { let config = SessionConfig { endpoint, @@ -286,7 +294,8 @@ pub fn spawn( netns_fd, expected_ssh_peer_pid, terminating, - instance_id, + instance_id: runtime.instance_id, + session_id_updates: runtime.session_id_updates, }; tokio::spawn(run_session_loop(config)) } @@ -299,6 +308,9 @@ struct SessionConfig { expected_ssh_peer_pid: Option, terminating: Arc, instance_id: String, + /// Publishes the currently accepted session to sibling control-plane + /// reporters. `None` means no session is authorized to send observations. + session_id_updates: Option>>, } async fn run_session_loop(config: SessionConfig) { @@ -308,7 +320,11 @@ async fn run_session_loop(config: SessionConfig) { loop { attempt += 1; - match run_single_session(&config).await { + let result = run_single_session(&config).await; + if let Some(updates) = &config.session_id_updates { + updates.send_replace(None); + } + match result { Ok(()) => { let event = session_closed_event( openshell_ocsf::ctx::ctx(), @@ -381,6 +397,9 @@ async fn run_single_session( }; let heartbeat_secs = accepted.heartbeat_interval_secs.max(5); + if let Some(updates) = &config.session_id_updates { + updates.send_replace(Some(accepted.session_id.clone())); + } let event = session_established_event( openshell_ocsf::ctx::ctx(), &config.endpoint, diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index e5dadeb28..5a1e32869 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -532,12 +532,49 @@ For automation, use `--output json` or `--output yaml` to get machine-readable s openshell sandbox get my-sandbox --output json ``` -Print only that policy YAML for scripting (same effective policy, no metadata): +Print only the policy YAML for scripting (same effective policy, no metadata): ```shell openshell sandbox get my-sandbox --policy-only ``` +### Diagnose Failed Calls to Tool Servers + +A sandbox can be `Ready` while an agent's call to an external tool server fails with `fetch failed`. Run `openshell sandbox get my-sandbox` and look under `Tool server connections` for the server's address, `Last result`, and `Reported at`. This information is available for configured endpoints that use MCP over HTTP. It helps you find where the call failed without changing sandbox readiness. + +JSON and YAML output include an `endpoint_statuses` list. The API and SDK expose it as `Sandbox.status.endpoint_statuses`. Each record contains `endpoint_id`, `host`, `ports`, `path`, `last_result`, and `last_reported_at`. Select the endpoint by its address to read the result directly. Treat `endpoint_id` as opaque. + +For example, use `jq` to select the tool server at `tools.example.com`, path `/mcp`, and port `443`: + +```shell +openshell sandbox get my-sandbox --output json | jq -e \ + --arg host tools.example.com --arg path /mcp --argjson ports '[443]' ' + .endpoint_statuses[] + | select(.host == $host and .path == $path and .ports == $ports) + ' +``` + +Read `last_result` to choose the next check: + +- `NoObservedExchange`: no result has been reported for the current configuration and supervisor session. Try the operation and inspect its logs if no result appears. +- `HttpResponseReceived`: the server returned a final HTTP status below 400, including a protocol upgrade. Informational responses alone do not establish success. Check the client's response for protocol or tool errors; an HTTP 200 response can still contain an error. +- `PolicyDenied`: OpenShell denied the request, including MCP protocol-version or request-body policy checks. Check the sandbox policy and denial logs. +- `CredentialUnavailable`: required credentials were unavailable. Check the endpoint's attached provider. +- `TlsFailed`: TLS setup or the handshake failed. Check certificates and TLS configuration. +- `TransportFailed`: the network exchange failed. Check name resolution, connectivity, and the server process. +- `UpstreamRejected`: the server returned an HTTP status of 400 or higher. Check the server's response and logs. +- `Unspecified`: the result is unrecognized or absent. Do not infer success from it. + +For example, a stopped server can produce `TransportFailed` while the sandbox stays `Ready`. After the server recovers, an HTTP response below 400 updates the result to `HttpResponseReceived`. + +Results come from actual traffic and do not expire. A result can remain unchanged after the server stops until another observed exchange or configuration/session reset. Reports combine recent observations and can drop them when full, so this list is not a request history. The address remains available when a result resets to `NoObservedExchange`. To verify current tool availability, run the actual operation. + +`last_reported_at` is the RFC 3339 UTC time when the gateway accepted the result. It is empty until a result is reported. New accepted observations advance it, including repeated results; identical report retries do not. If a configuration reset supersedes a report whose acknowledgement was lost, the gateway can accept still-valid pending evidence again and advance this timestamp without another exchange. + +Addresses use lowercase hosts, canonical paths, and sorted distinct effective ports. Equivalent addresses share one record that combines observations from all callers and ports; it does not establish that every caller or port works. A failure before the HTTP path is known, such as a TLS failure during CONNECT, updates status only when the host and port identify one distinct endpoint. If several configured paths share that host and port, inspect the logs for the failure. + +### Inspect Logs and Activity + Stream sandbox logs to monitor agent activity and diagnose policy decisions: ```shell diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index b0df0429d..49ef45ac0 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -1075,6 +1075,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "sha2", "thiserror", "tokio", "tokio-stream", diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index f19951981..25286fd88 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -201,6 +201,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.12.0" @@ -302,6 +311,35 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -425,6 +463,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -914,6 +962,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "sha2", "thiserror", "tokio", "tokio-stream", @@ -1421,6 +1470,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1789,6 +1849,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicase" version = "2.9.0" @@ -1849,6 +1915,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" diff --git a/proto/openshell.proto b/proto/openshell.proto index def6d7c47..fd664a0f7 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -475,6 +475,14 @@ service OpenShell { }; } + // Replace the gateway's observed tool server endpoint status for one sandbox. + rpc ReportEndpointStatus(ReportEndpointStatusRequest) + returns (ReportEndpointStatusResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } + // Get provider environment for a sandbox (called by sandbox supervisor at startup). rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest) returns (GetSandboxProviderEnvironmentResponse) { @@ -1065,9 +1073,13 @@ message SandboxStatus { // Presence indicates that the canonical main process exited. Exit code 0 // produces Completed; nonzero and signal-normalized exits produce Error. optional int32 exit_code = 9; + // Last accepted network result for each configured tool server endpoint. + // Currently populated for MCP-over-HTTP endpoints. These passive results + // remain separate from sandbox lifecycle conditions and readiness. + repeated EndpointStatus endpoint_statuses = 10; } -// User-facing sandbox condition derived from driver-native conditions. +// User-facing sandbox condition derived from platform or gateway observations. message SandboxCondition { // Condition class, typically mirroring the underlying platform condition type. string type = 1; @@ -1077,7 +1089,7 @@ message SandboxCondition { string reason = 3; // Human-readable condition message. string message = 4; - // Timestamp reported by the underlying platform for the last transition. + // RFC 3339 UTC timestamp supplied by the condition owner for the last transition. string last_transition_time = 5; } @@ -3102,3 +3114,77 @@ message ExtensionServiceCredential { // Absolute expiry of the token, milliseconds since the epoch. int64 expires_at_ms = 3; } + +// Last observed network result for a configured external tool endpoint. +// Results describe accepted traffic observations, not present availability. +enum EndpointResult { + ENDPOINT_RESULT_UNSPECIFIED = 0; + // No exchange has been observed under the current configuration and session. + ENDPOINT_RESULT_NO_OBSERVED_EXCHANGE = 1; + // An upstream HTTP status below 400 was received. Its body can still contain + // an MCP error; this result does not establish tool-call success. + ENDPOINT_RESULT_HTTP_RESPONSE_RECEIVED = 2; + // OpenShell policy denied the request locally. + ENDPOINT_RESULT_POLICY_DENIED = 3; + // An applicable OpenShell-managed credential was unavailable. + ENDPOINT_RESULT_CREDENTIAL_UNAVAILABLE = 4; + // TLS setup for the upstream connection failed. + ENDPOINT_RESULT_TLS_FAILED = 5; + // The upstream transport failed before an HTTP response arrived. + ENDPOINT_RESULT_TRANSPORT_FAILED = 6; + // The upstream service returned an HTTP rejection. + ENDPOINT_RESULT_UPSTREAM_REJECTED = 7; +} + +// One redacted endpoint result in a supervisor's complete status report. +message EndpointObservation { + // Stable identifier derived from the configured host, ports, and path. + string endpoint_id = 1; + // Latest result under the reported configuration and supervisor session. + EndpointResult result = 2; +} + +// Complete endpoint status report for the caller's current configuration. +message ReportEndpointStatusRequest { + // Sandbox id. Must match the authenticated sandbox principal. + string sandbox_id = 1; + // Hash from the active effective policy delivered by the gateway. + string policy_hash = 2; + // Provider environment revision delivered with the active configuration. + uint64 provider_env_revision = 3; + // Exactly one result for every distinct observed endpoint in the policy. + repeated EndpointObservation observations = 4; + // Endpoints with a new observation in this batch. Omitted endpoints retain + // their prior report time; an identical retry never advances report time. + repeated string observed_endpoint_ids = 5; + // Active ConnectSupervisor session that owns these observations. + string supervisor_session_id = 6; + // Monotonically increasing sequence within the authenticated session. Gaps + // are allowed when an inventory reset supersedes a frozen snapshot. Retrying + // a report preserves its complete body and sequence for idempotent acknowledgement. + uint64 report_sequence = 7; +} + +// Empty acknowledgement for a persisted endpoint status report. +message ReportEndpointStatusResponse {} + +// A configured endpoint and its last accepted network result in one record. +// Address fields contain policy selectors, never request URLs or credentials. +message EndpointStatus { + // Stable identifier for selecting this endpoint without parsing display text. + string endpoint_id = 1; + // Lowercase configured endpoint host. + string host = 2; + // Sorted, deduplicated effective endpoint ports. Validated endpoints have at + // least one port. + repeated uint32 ports = 3; + // Canonical configured path selector; an unrestricted path is /**. + string path = 4; + // Last accepted result, aggregated across configured callers and ports. + // NoObservedExchange retains the address and has no report timestamp. + EndpointResult last_result = 5; + // RFC 3339 UTC time when the gateway accepted the observation. This is not + // the request time: still-valid evidence can be reaccepted after a reset. + // Identical same-sequence retries do not advance it. + string last_reported_at = 6; +} diff --git a/sdk/go/docs/src/api/sandboxes.md b/sdk/go/docs/src/api/sandboxes.md index 21ee14a1b..7fb1ed755 100644 --- a/sdk/go/docs/src/api/sandboxes.md +++ b/sdk/go/docs/src/api/sandboxes.md @@ -55,6 +55,22 @@ sb, err := client.Sandboxes().Get(ctx, "default", "my-sandbox") fmt.Println(sb.Status.Phase) // "Ready", "Provisioning", etc. ``` +### Tool server connections + +`sb.Status.EndpointStatuses` shows each configured tool server endpoint and its last accepted network result in one record. A sandbox can be `Ready` while a tool server connection fails; endpoint results do not change lifecycle readiness. OpenShell currently observes endpoints configured for MCP over HTTP. + +```go +for _, endpoint := range sb.Status.EndpointStatuses { + fmt.Printf("%s:%v%s: %s, reported %s\n", + endpoint.Host, endpoint.Ports, endpoint.Path, + endpoint.LastResult, endpoint.LastReportedAt) +} +``` + +`LastResult` is a typed `v1.EndpointResult`. For example, `v1.EndpointTransportFailed` means the transport failed before an HTTP response arrived. `v1.EndpointHTTPResponseReceived` means the server returned an HTTP status below 400; its body can still contain a tool error. Neither result establishes current availability. + +OpenShell observes real traffic passively and does not expire idle observations. Keep `LastReportedAt` visible when displaying a result: it records when the gateway accepted the observation, not when the request happened. Retained evidence can be accepted after a reset. `v1.EndpointNoObservedExchange` has an empty timestamp and retains the configured address, so the endpoint remains identifiable before traffic is observed or after evidence is invalidated. + ## List `List` constructs a lazy pager; `NextPage` fetches one page at a time. diff --git a/sdk/go/openshell/v1/fake/fake_test.go b/sdk/go/openshell/v1/fake/fake_test.go index dcc494d28..cfc144edd 100644 --- a/sdk/go/openshell/v1/fake/fake_test.go +++ b/sdk/go/openshell/v1/fake/fake_test.go @@ -194,6 +194,44 @@ func TestFakeClient_AddSandbox_DeepCopy(t *testing.T) { assert.Equal(t, "test", got.Labels["env"]) } +func TestFakeClient_SandboxEndpointStatuses_DeepCopy(t *testing.T) { + fc := NewClient() + ctx := context.Background() + sb := &types.Sandbox{ + Name: "tool-sandbox", + Status: types.SandboxStatus{EndpointStatuses: []types.EndpointStatus{{ + EndpointID: "endpoint-one", + Host: "tools.example.test", + Ports: []uint32{443, 8443}, + Path: "/mcp", + LastResult: types.EndpointTransportFailed, + LastReportedAt: "2026-09-11T10:00:00Z", + }}}, + } + fc.AddSandbox("default", sb) + + // Neither the seed object nor returned snapshots may mutate stored endpoints. + sb.Status.EndpointStatuses[0].Host = "seed-changed.example.test" + sb.Status.EndpointStatuses[0].Ports[0] = 80 + sb.Status.EndpointStatuses[0].LastResult = types.EndpointHTTPResponseReceived + got, err := fc.Sandboxes().Get(ctx, "default", "tool-sandbox") + require.NoError(t, err) + require.Equal(t, []types.EndpointStatus{{ + EndpointID: "endpoint-one", Host: "tools.example.test", Ports: []uint32{443, 8443}, Path: "/mcp", + LastResult: types.EndpointTransportFailed, LastReportedAt: "2026-09-11T10:00:00Z", + }}, got.Status.EndpointStatuses) + got.Status.EndpointStatuses[0].Path = "/changed" + got.Status.EndpointStatuses[0].Ports[1] = 8080 + got.Status.EndpointStatuses[0].LastReportedAt = "changed" + + listed, err := fc.Sandboxes().ListAll(ctx, "default") + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "/mcp", listed[0].Status.EndpointStatuses[0].Path) + assert.Equal(t, []uint32{443, 8443}, listed[0].Status.EndpointStatuses[0].Ports) + assert.Equal(t, "2026-09-11T10:00:00Z", listed[0].Status.EndpointStatuses[0].LastReportedAt) +} + func TestFakeClient_AddProvider(t *testing.T) { fc := NewClient() ctx := context.Background() diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 9f8e6c228..59c14535b 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -6,6 +6,7 @@ package fake import ( "context" "fmt" + "slices" "sync" "time" @@ -232,6 +233,11 @@ func copySandboxStatus(s types.SandboxStatus) types.SandboxStatus { copy(conds, s.Conditions) s.Conditions = conds } + // Callers may mutate endpoint snapshots without changing the fake's storage. + s.EndpointStatuses = slices.Clone(s.EndpointStatuses) + for i := range s.EndpointStatuses { + s.EndpointStatuses[i].Ports = slices.Clone(s.EndpointStatuses[i].Ports) + } return s } diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 16929f672..5103d4b1f 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -116,12 +116,16 @@ func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) { "sandbox_fd": true, "phase": true, "conditions": true, + "endpoint_statuses": true, "current_policy_version": true, "exit_code": true, } - // The instance ID coordinates internal gateway/supervisor lifecycle - // fencing. It is exposed only through the raw protobuf API. - skipped := fieldSet{"main_process_instance_id": true} + // These fields coordinate internal gateway/supervisor lifecycle fencing + // and idempotent status reconciliation. They remain available only through + // the raw protobuf API. + skipped := fieldSet{ + "main_process_instance_id": true, + } assertAllFieldsCovered(t, (&pb.SandboxStatus{}).ProtoReflect().Descriptor(), handled, skipped) } @@ -138,6 +142,19 @@ func TestConverterCoversAllProtoFields_SandboxCondition(t *testing.T) { assertAllFieldsCovered(t, (&pb.SandboxCondition{}).ProtoReflect().Descriptor(), handled, nil) } +func TestConverterCoversAllProtoFields_EndpointStatus(t *testing.T) { + handled := fieldSet{ + "endpoint_id": true, + "host": true, + "ports": true, + "path": true, + "last_result": true, + "last_reported_at": true, + } + + assertAllFieldsCovered(t, (&pb.EndpointStatus{}).ProtoReflect().Descriptor(), handled, nil) +} + func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) { handled := fieldSet{ "version": true, diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go index bf15f1105..bf59d7a1d 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -5,6 +5,7 @@ package converter import ( "fmt" + "slices" "time" "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" @@ -113,11 +114,43 @@ func sandboxStatusFromProto(status *pb.SandboxStatus) types.SandboxStatus { LastTransitionTime: c.GetLastTransitionTime(), }) } + for _, endpoint := range status.GetEndpointStatuses() { + result.EndpointStatuses = append(result.EndpointStatuses, types.EndpointStatus{ + EndpointID: endpoint.GetEndpointId(), + Host: endpoint.GetHost(), + Ports: slices.Clone(endpoint.GetPorts()), + Path: endpoint.GetPath(), + LastResult: endpointResultFromProto(endpoint.GetLastResult()), + LastReportedAt: endpoint.GetLastReportedAt(), + }) + } result.ExitCode = CopyInt32Ptr(status.ExitCode) return result } +func endpointResultFromProto(result pb.EndpointResult) types.EndpointResult { + switch result { + case pb.EndpointResult_ENDPOINT_RESULT_NO_OBSERVED_EXCHANGE: + return types.EndpointNoObservedExchange + case pb.EndpointResult_ENDPOINT_RESULT_HTTP_RESPONSE_RECEIVED: + return types.EndpointHTTPResponseReceived + case pb.EndpointResult_ENDPOINT_RESULT_POLICY_DENIED: + return types.EndpointPolicyDenied + case pb.EndpointResult_ENDPOINT_RESULT_CREDENTIAL_UNAVAILABLE: + return types.EndpointCredentialUnavailable + case pb.EndpointResult_ENDPOINT_RESULT_TLS_FAILED: + return types.EndpointTLSFailed + case pb.EndpointResult_ENDPOINT_RESULT_TRANSPORT_FAILED: + return types.EndpointTransportFailed + case pb.EndpointResult_ENDPOINT_RESULT_UPSTREAM_REJECTED: + return types.EndpointUpstreamRejected + default: + // Unknown wire values must not imply an observation or a successful call. + return types.EndpointUnspecified + } +} + // SandboxPhaseFromProto converts a proto SandboxPhase to an SDK SandboxPhase. func SandboxPhaseFromProto(phase pb.SandboxPhase) types.SandboxPhase { switch phase { diff --git a/sdk/go/openshell/v1/internal/converter/sandbox_test.go b/sdk/go/openshell/v1/internal/converter/sandbox_test.go index 608793361..ed4575c6f 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox_test.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -173,6 +173,74 @@ func TestSandboxFromProto_TemplateResourcesDeepCopy(t *testing.T) { assert.Equal(t, "kata", s.Spec.Template.DriverConfig["runtime"], "DriverConfig must be deep copied") } +func TestSandboxFromProto_EndpointStatuses(t *testing.T) { + for _, tc := range []struct { + name string + status *pb.SandboxStatus + }{ + {name: "nil status"}, + {name: "nil endpoints", status: &pb.SandboxStatus{}}, + {name: "empty endpoints", status: &pb.SandboxStatus{EndpointStatuses: []*pb.EndpointStatus{}}}, + } { + t.Run(tc.name, func(t *testing.T) { + s := SandboxFromProto(&pb.Sandbox{Status: tc.status}) + require.NotNil(t, s) + assert.Empty(t, s.Status.EndpointStatuses) + }) + } + + input := &pb.Sandbox{Status: &pb.SandboxStatus{ + Phase: pb.SandboxPhase_SANDBOX_PHASE_READY, + Conditions: []*pb.SandboxCondition{{ + Type: "Ready", Status: "True", Reason: "AllGood", Message: "Sandbox is ready", + }}, + EndpointStatuses: []*pb.EndpointStatus{ + {EndpointId: "endpoint-one", Host: "tools.example.test", Ports: []uint32{443, 8443}, Path: "/mcp", LastResult: pb.EndpointResult_ENDPOINT_RESULT_TRANSPORT_FAILED, LastReportedAt: "2026-09-11T10:00:00Z"}, + {EndpointId: "endpoint-two", Host: "tools.example.test", Ports: []uint32{443}, Path: "/other", LastResult: pb.EndpointResult_ENDPOINT_RESULT_NO_OBSERVED_EXCHANGE}, + }, + }} + + s := SandboxFromProto(input) + require.NotNil(t, s) + require.Equal(t, []v1.EndpointStatus{ + {EndpointID: "endpoint-one", Host: "tools.example.test", Ports: []uint32{443, 8443}, Path: "/mcp", LastResult: v1.EndpointTransportFailed, LastReportedAt: "2026-09-11T10:00:00Z"}, + {EndpointID: "endpoint-two", Host: "tools.example.test", Ports: []uint32{443}, Path: "/other", LastResult: v1.EndpointNoObservedExchange}, + }, s.Status.EndpointStatuses) + assert.Equal(t, v1.SandboxReady, s.Status.Phase) + assert.Equal(t, []v1.SandboxCondition{{Type: "Ready", Status: "True", Reason: "AllGood", Message: "Sandbox is ready"}}, s.Status.Conditions) + + // A response and its SDK representation must not share endpoint or port storage. + input.Status.EndpointStatuses[0].Host = "changed.example.test" + input.Status.EndpointStatuses[0].Ports[0] = 80 + assert.Equal(t, "tools.example.test", s.Status.EndpointStatuses[0].Host) + assert.Equal(t, uint32(443), s.Status.EndpointStatuses[0].Ports[0]) + s.Status.EndpointStatuses[1].Path = "/changed" + s.Status.EndpointStatuses[1].Ports[0] = 8080 + assert.Equal(t, "/other", input.Status.EndpointStatuses[1].Path) + assert.Equal(t, uint32(443), input.Status.EndpointStatuses[1].Ports[0]) +} + +func TestEndpointResultFromProto(t *testing.T) { + for _, tc := range []struct { + input pb.EndpointResult + want v1.EndpointResult + }{ + {pb.EndpointResult_ENDPOINT_RESULT_UNSPECIFIED, v1.EndpointUnspecified}, + {pb.EndpointResult_ENDPOINT_RESULT_NO_OBSERVED_EXCHANGE, v1.EndpointNoObservedExchange}, + {pb.EndpointResult_ENDPOINT_RESULT_HTTP_RESPONSE_RECEIVED, v1.EndpointHTTPResponseReceived}, + {pb.EndpointResult_ENDPOINT_RESULT_POLICY_DENIED, v1.EndpointPolicyDenied}, + {pb.EndpointResult_ENDPOINT_RESULT_CREDENTIAL_UNAVAILABLE, v1.EndpointCredentialUnavailable}, + {pb.EndpointResult_ENDPOINT_RESULT_TLS_FAILED, v1.EndpointTLSFailed}, + {pb.EndpointResult_ENDPOINT_RESULT_TRANSPORT_FAILED, v1.EndpointTransportFailed}, + {pb.EndpointResult_ENDPOINT_RESULT_UPSTREAM_REJECTED, v1.EndpointUpstreamRejected}, + {pb.EndpointResult(99), v1.EndpointUnspecified}, + } { + t.Run(tc.input.String(), func(t *testing.T) { + assert.Equal(t, tc.want, endpointResultFromProto(tc.input)) + }) + } +} + func TestSandboxFromProto_NilFields(t *testing.T) { proto := &pb.Sandbox{} diff --git a/sdk/go/openshell/v1/sandbox.go b/sdk/go/openshell/v1/sandbox.go index 1fb166825..062894c08 100644 --- a/sdk/go/openshell/v1/sandbox.go +++ b/sdk/go/openshell/v1/sandbox.go @@ -24,6 +24,24 @@ type SandboxStatus = types.SandboxStatus // SandboxCondition describes an observed condition of a sandbox. type SandboxCondition = types.SandboxCondition +// EndpointStatus holds a configured tool endpoint and its last accepted network result. +type EndpointStatus = types.EndpointStatus + +// EndpointResult classifies the last accepted network result for a tool endpoint. +type EndpointResult = types.EndpointResult + +// EndpointResult values describe passive observations of actual traffic. +const ( + EndpointUnspecified = types.EndpointUnspecified + EndpointNoObservedExchange = types.EndpointNoObservedExchange + EndpointHTTPResponseReceived = types.EndpointHTTPResponseReceived + EndpointPolicyDenied = types.EndpointPolicyDenied + EndpointCredentialUnavailable = types.EndpointCredentialUnavailable + EndpointTLSFailed = types.EndpointTLSFailed + EndpointTransportFailed = types.EndpointTransportFailed + EndpointUpstreamRejected = types.EndpointUpstreamRejected +) + // AttachProviderResult holds the result of attaching a provider to a sandbox. type AttachProviderResult = types.AttachProviderResult diff --git a/sdk/go/openshell/v1/types/sandbox.go b/sdk/go/openshell/v1/types/sandbox.go index 8be13888b..f67f0ef9e 100644 --- a/sdk/go/openshell/v1/types/sandbox.go +++ b/sdk/go/openshell/v1/types/sandbox.go @@ -117,8 +117,52 @@ type SandboxStatus struct { Conditions []SandboxCondition CurrentPolicyVersion uint32 ExitCode *int32 + // EndpointStatuses describes configured external tool endpoints and their + // last accepted network results, independently of sandbox readiness. + EndpointStatuses []EndpointStatus } +// EndpointStatus holds a configured tool endpoint and its last accepted network result. +// Observations aggregate configured callers across the listed ports; the result +// does not establish present availability or successful tool execution. +type EndpointStatus struct { + // EndpointID selects this endpoint without parsing its address or display text. + EndpointID string + Host string + Ports []uint32 + Path string + LastResult EndpointResult + // LastReportedAt is the RFC 3339 UTC time when the gateway accepted the + // observation, not the request time. Retained evidence can be accepted after + // a reset. NoObservedExchange has no report timestamp. + LastReportedAt string +} + +// EndpointResult classifies the last accepted network result for a tool endpoint. +type EndpointResult string + +// EndpointResult values describe passive observations of actual traffic. +const ( + // EndpointUnspecified means the result was absent or was not recognized. + EndpointUnspecified EndpointResult = "Unspecified" + // EndpointNoObservedExchange means the active configuration and supervisor + // session have no applicable observation. + EndpointNoObservedExchange EndpointResult = "NoObservedExchange" + // EndpointHTTPResponseReceived means an HTTP status below 400 was received. + // The response body can still contain a tool error. + EndpointHTTPResponseReceived EndpointResult = "HttpResponseReceived" + // EndpointPolicyDenied means OpenShell policy denied the request locally. + EndpointPolicyDenied EndpointResult = "PolicyDenied" + // EndpointCredentialUnavailable means a required managed credential was unavailable. + EndpointCredentialUnavailable EndpointResult = "CredentialUnavailable" + // EndpointTLSFailed means TLS setup for the upstream connection failed. + EndpointTLSFailed EndpointResult = "TlsFailed" + // EndpointTransportFailed means the transport failed before an HTTP response arrived. + EndpointTransportFailed EndpointResult = "TransportFailed" + // EndpointUpstreamRejected means the server returned an HTTP status of 400 or higher. + EndpointUpstreamRejected EndpointResult = "UpstreamRejected" +) + // SandboxCondition describes an observed condition of a sandbox. type SandboxCondition struct { Type string diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 735c17bc8..fe5f82d6c 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -505,6 +505,80 @@ func (ProviderCredentialRefreshRecoveryAction) EnumDescriptor() ([]byte, []int) return file_openshell_proto_rawDescGZIP(), []int{7} } +// Last observed network result for a configured external tool endpoint. +// Results describe accepted traffic observations, not present availability. +type EndpointResult int32 + +const ( + EndpointResult_ENDPOINT_RESULT_UNSPECIFIED EndpointResult = 0 + // No exchange has been observed under the current configuration and session. + EndpointResult_ENDPOINT_RESULT_NO_OBSERVED_EXCHANGE EndpointResult = 1 + // An upstream HTTP status below 400 was received. Its body can still contain + // an MCP error; this result does not establish tool-call success. + EndpointResult_ENDPOINT_RESULT_HTTP_RESPONSE_RECEIVED EndpointResult = 2 + // OpenShell policy denied the request locally. + EndpointResult_ENDPOINT_RESULT_POLICY_DENIED EndpointResult = 3 + // An applicable OpenShell-managed credential was unavailable. + EndpointResult_ENDPOINT_RESULT_CREDENTIAL_UNAVAILABLE EndpointResult = 4 + // TLS setup for the upstream connection failed. + EndpointResult_ENDPOINT_RESULT_TLS_FAILED EndpointResult = 5 + // The upstream transport failed before an HTTP response arrived. + EndpointResult_ENDPOINT_RESULT_TRANSPORT_FAILED EndpointResult = 6 + // The upstream service returned an HTTP rejection. + EndpointResult_ENDPOINT_RESULT_UPSTREAM_REJECTED EndpointResult = 7 +) + +// Enum value maps for EndpointResult. +var ( + EndpointResult_name = map[int32]string{ + 0: "ENDPOINT_RESULT_UNSPECIFIED", + 1: "ENDPOINT_RESULT_NO_OBSERVED_EXCHANGE", + 2: "ENDPOINT_RESULT_HTTP_RESPONSE_RECEIVED", + 3: "ENDPOINT_RESULT_POLICY_DENIED", + 4: "ENDPOINT_RESULT_CREDENTIAL_UNAVAILABLE", + 5: "ENDPOINT_RESULT_TLS_FAILED", + 6: "ENDPOINT_RESULT_TRANSPORT_FAILED", + 7: "ENDPOINT_RESULT_UPSTREAM_REJECTED", + } + EndpointResult_value = map[string]int32{ + "ENDPOINT_RESULT_UNSPECIFIED": 0, + "ENDPOINT_RESULT_NO_OBSERVED_EXCHANGE": 1, + "ENDPOINT_RESULT_HTTP_RESPONSE_RECEIVED": 2, + "ENDPOINT_RESULT_POLICY_DENIED": 3, + "ENDPOINT_RESULT_CREDENTIAL_UNAVAILABLE": 4, + "ENDPOINT_RESULT_TLS_FAILED": 5, + "ENDPOINT_RESULT_TRANSPORT_FAILED": 6, + "ENDPOINT_RESULT_UPSTREAM_REJECTED": 7, + } +) + +func (x EndpointResult) Enum() *EndpointResult { + p := new(EndpointResult) + *p = x + return p +} + +func (x EndpointResult) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EndpointResult) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[8].Descriptor() +} + +func (EndpointResult) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[8] +} + +func (x EndpointResult) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EndpointResult.Descriptor instead. +func (EndpointResult) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{8} +} + // IssueSandboxToken request. Empty body; identity is established by the // authentication credentials carried in the request headers (a projected // Kubernetes ServiceAccount JWT in the K8s driver path). @@ -2196,9 +2270,13 @@ type SandboxStatus struct { // Normalized main process result. Signal exits use 128 + signal number. // Presence indicates that the canonical main process exited. Exit code 0 // produces Completed; nonzero and signal-normalized exits produce Error. - ExitCode *int32 `protobuf:"varint,9,opt,name=exit_code,json=exitCode,proto3,oneof" json:"exit_code,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ExitCode *int32 `protobuf:"varint,9,opt,name=exit_code,json=exitCode,proto3,oneof" json:"exit_code,omitempty"` + // Last accepted network result for each configured tool server endpoint. + // Currently populated for MCP-over-HTTP endpoints. These passive results + // remain separate from sandbox lifecycle conditions and readiness. + EndpointStatuses []*EndpointStatus `protobuf:"bytes,10,rep,name=endpoint_statuses,json=endpointStatuses,proto3" json:"endpoint_statuses,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxStatus) Reset() { @@ -2294,7 +2372,14 @@ func (x *SandboxStatus) GetExitCode() int32 { return 0 } -// User-facing sandbox condition derived from driver-native conditions. +func (x *SandboxStatus) GetEndpointStatuses() []*EndpointStatus { + if x != nil { + return x.EndpointStatuses + } + return nil +} + +// User-facing sandbox condition derived from platform or gateway observations. type SandboxCondition struct { state protoimpl.MessageState `protogen:"open.v1"` // Condition class, typically mirroring the underlying platform condition type. @@ -2305,7 +2390,7 @@ type SandboxCondition struct { Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` // Human-readable condition message. Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` - // Timestamp reported by the underlying platform for the last transition. + // RFC 3339 UTC timestamp supplied by the condition owner for the last transition. LastTransitionTime string `protobuf:"bytes,5,opt,name=last_transition_time,json=lastTransitionTime,proto3" json:"last_transition_time,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -14312,6 +14397,297 @@ func (x *ExtensionServiceCredential) GetExpiresAtMs() int64 { return 0 } +// One redacted endpoint result in a supervisor's complete status report. +type EndpointObservation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable identifier derived from the configured host, ports, and path. + EndpointId string `protobuf:"bytes,1,opt,name=endpoint_id,json=endpointId,proto3" json:"endpoint_id,omitempty"` + // Latest result under the reported configuration and supervisor session. + Result EndpointResult `protobuf:"varint,2,opt,name=result,proto3,enum=openshell.v1.EndpointResult" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EndpointObservation) Reset() { + *x = EndpointObservation{} + mi := &file_openshell_proto_msgTypes[207] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EndpointObservation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EndpointObservation) ProtoMessage() {} + +func (x *EndpointObservation) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[207] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EndpointObservation.ProtoReflect.Descriptor instead. +func (*EndpointObservation) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{207} +} + +func (x *EndpointObservation) GetEndpointId() string { + if x != nil { + return x.EndpointId + } + return "" +} + +func (x *EndpointObservation) GetResult() EndpointResult { + if x != nil { + return x.Result + } + return EndpointResult_ENDPOINT_RESULT_UNSPECIFIED +} + +// Complete endpoint status report for the caller's current configuration. +type ReportEndpointStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. Must match the authenticated sandbox principal. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Hash from the active effective policy delivered by the gateway. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + // Provider environment revision delivered with the active configuration. + ProviderEnvRevision uint64 `protobuf:"varint,3,opt,name=provider_env_revision,json=providerEnvRevision,proto3" json:"provider_env_revision,omitempty"` + // Exactly one result for every distinct observed endpoint in the policy. + Observations []*EndpointObservation `protobuf:"bytes,4,rep,name=observations,proto3" json:"observations,omitempty"` + // Endpoints with a new observation in this batch. Omitted endpoints retain + // their prior report time; an identical retry never advances report time. + ObservedEndpointIds []string `protobuf:"bytes,5,rep,name=observed_endpoint_ids,json=observedEndpointIds,proto3" json:"observed_endpoint_ids,omitempty"` + // Active ConnectSupervisor session that owns these observations. + SupervisorSessionId string `protobuf:"bytes,6,opt,name=supervisor_session_id,json=supervisorSessionId,proto3" json:"supervisor_session_id,omitempty"` + // Monotonically increasing sequence within the authenticated session. Gaps + // are allowed when an inventory reset supersedes a frozen snapshot. Retrying + // a report preserves its complete body and sequence for idempotent acknowledgement. + ReportSequence uint64 `protobuf:"varint,7,opt,name=report_sequence,json=reportSequence,proto3" json:"report_sequence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportEndpointStatusRequest) Reset() { + *x = ReportEndpointStatusRequest{} + mi := &file_openshell_proto_msgTypes[208] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportEndpointStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportEndpointStatusRequest) ProtoMessage() {} + +func (x *ReportEndpointStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[208] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportEndpointStatusRequest.ProtoReflect.Descriptor instead. +func (*ReportEndpointStatusRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{208} +} + +func (x *ReportEndpointStatusRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ReportEndpointStatusRequest) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *ReportEndpointStatusRequest) GetProviderEnvRevision() uint64 { + if x != nil { + return x.ProviderEnvRevision + } + return 0 +} + +func (x *ReportEndpointStatusRequest) GetObservations() []*EndpointObservation { + if x != nil { + return x.Observations + } + return nil +} + +func (x *ReportEndpointStatusRequest) GetObservedEndpointIds() []string { + if x != nil { + return x.ObservedEndpointIds + } + return nil +} + +func (x *ReportEndpointStatusRequest) GetSupervisorSessionId() string { + if x != nil { + return x.SupervisorSessionId + } + return "" +} + +func (x *ReportEndpointStatusRequest) GetReportSequence() uint64 { + if x != nil { + return x.ReportSequence + } + return 0 +} + +// Empty acknowledgement for a persisted endpoint status report. +type ReportEndpointStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportEndpointStatusResponse) Reset() { + *x = ReportEndpointStatusResponse{} + mi := &file_openshell_proto_msgTypes[209] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportEndpointStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportEndpointStatusResponse) ProtoMessage() {} + +func (x *ReportEndpointStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[209] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportEndpointStatusResponse.ProtoReflect.Descriptor instead. +func (*ReportEndpointStatusResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{209} +} + +// A configured endpoint and its last accepted network result in one record. +// Address fields contain policy selectors, never request URLs or credentials. +type EndpointStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable identifier for selecting this endpoint without parsing display text. + EndpointId string `protobuf:"bytes,1,opt,name=endpoint_id,json=endpointId,proto3" json:"endpoint_id,omitempty"` + // Lowercase configured endpoint host. + Host string `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"` + // Sorted, deduplicated effective endpoint ports. Validated endpoints have at + // least one port. + Ports []uint32 `protobuf:"varint,3,rep,packed,name=ports,proto3" json:"ports,omitempty"` + // Canonical configured path selector; an unrestricted path is /**. + Path string `protobuf:"bytes,4,opt,name=path,proto3" json:"path,omitempty"` + // Last accepted result, aggregated across configured callers and ports. + // NoObservedExchange retains the address and has no report timestamp. + LastResult EndpointResult `protobuf:"varint,5,opt,name=last_result,json=lastResult,proto3,enum=openshell.v1.EndpointResult" json:"last_result,omitempty"` + // RFC 3339 UTC time when the gateway accepted the observation. This is not + // the request time: still-valid evidence can be reaccepted after a reset. + // Identical same-sequence retries do not advance it. + LastReportedAt string `protobuf:"bytes,6,opt,name=last_reported_at,json=lastReportedAt,proto3" json:"last_reported_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EndpointStatus) Reset() { + *x = EndpointStatus{} + mi := &file_openshell_proto_msgTypes[210] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EndpointStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EndpointStatus) ProtoMessage() {} + +func (x *EndpointStatus) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[210] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EndpointStatus.ProtoReflect.Descriptor instead. +func (*EndpointStatus) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{210} +} + +func (x *EndpointStatus) GetEndpointId() string { + if x != nil { + return x.EndpointId + } + return "" +} + +func (x *EndpointStatus) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *EndpointStatus) GetPorts() []uint32 { + if x != nil { + return x.Ports + } + return nil +} + +func (x *EndpointStatus) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *EndpointStatus) GetLastResult() EndpointResult { + if x != nil { + return x.LastResult + } + return EndpointResult_ENDPOINT_RESULT_UNSPECIFIED +} + +func (x *EndpointStatus) GetLastReportedAt() string { + if x != nil { + return x.LastReportedAt + } + return "" +} + var File_openshell_proto protoreflect.FileDescriptor const file_openshell_proto_rawDesc = "" + @@ -14433,7 +14809,7 @@ const file_openshell_proto_rawDesc = "" + "\tmax_burst\x18\x02 \x01(\rR\bmaxBurst\"b\n" + "!SandboxWorkloadTemplateProvenance\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12)\n" + - "\x10resource_version\x18\x02 \x01(\tR\x0fresourceVersion\"\x9a\x03\n" + + "\x10resource_version\x18\x02 \x01(\tR\x0fresourceVersion\"\xe5\x03\n" + "\rSandboxStatus\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12\x1b\n" + "\tagent_pod\x18\x02 \x01(\tR\bagentPod\x12\x19\n" + @@ -14446,7 +14822,9 @@ const file_openshell_proto_rawDesc = "" + "\x05phase\x18\x06 \x01(\x0e2\x1a.openshell.v1.SandboxPhaseR\x05phase\x124\n" + "\x16current_policy_version\x18\a \x01(\rR\x14currentPolicyVersion\x127\n" + "\x18main_process_instance_id\x18\b \x01(\tR\x15mainProcessInstanceId\x12 \n" + - "\texit_code\x18\t \x01(\x05H\x00R\bexitCode\x88\x01\x01B\f\n" + + "\texit_code\x18\t \x01(\x05H\x00R\bexitCode\x88\x01\x01\x12I\n" + + "\x11endpoint_statuses\x18\n" + + " \x03(\v2\x1c.openshell.v1.EndpointStatusR\x10endpointStatusesB\f\n" + "\n" + "_exit_code\"\xa2\x01\n" + "\x10SandboxCondition\x12\x12\n" + @@ -15347,7 +15725,31 @@ const file_openshell_proto_rawDesc = "" + "\x1aExtensionServiceCredential\x12!\n" + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1a\n" + "\x05token\x18\x02 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x03 \x01(\x03R\vexpiresAtMs*\xa6\x02\n" + + "\rexpires_at_ms\x18\x03 \x01(\x03R\vexpiresAtMs\"l\n" + + "\x13EndpointObservation\x12\x1f\n" + + "\vendpoint_id\x18\x01 \x01(\tR\n" + + "endpointId\x124\n" + + "\x06result\x18\x02 \x01(\x0e2\x1c.openshell.v1.EndpointResultR\x06result\"\xe9\x02\n" + + "\x1bReportEndpointStatusRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\x122\n" + + "\x15provider_env_revision\x18\x03 \x01(\x04R\x13providerEnvRevision\x12E\n" + + "\fobservations\x18\x04 \x03(\v2!.openshell.v1.EndpointObservationR\fobservations\x122\n" + + "\x15observed_endpoint_ids\x18\x05 \x03(\tR\x13observedEndpointIds\x122\n" + + "\x15supervisor_session_id\x18\x06 \x01(\tR\x13supervisorSessionId\x12'\n" + + "\x0freport_sequence\x18\a \x01(\x04R\x0ereportSequence\"\x1e\n" + + "\x1cReportEndpointStatusResponse\"\xd8\x01\n" + + "\x0eEndpointStatus\x12\x1f\n" + + "\vendpoint_id\x18\x01 \x01(\tR\n" + + "endpointId\x12\x12\n" + + "\x04host\x18\x02 \x01(\tR\x04host\x12\x14\n" + + "\x05ports\x18\x03 \x03(\rR\x05ports\x12\x12\n" + + "\x04path\x18\x04 \x01(\tR\x04path\x12=\n" + + "\vlast_result\x18\x05 \x01(\x0e2\x1c.openshell.v1.EndpointResultR\n" + + "lastResult\x12(\n" + + "\x10last_reported_at\x18\x06 \x01(\tR\x0elastReportedAt*\xa6\x02\n" + "\fSandboxPhase\x12\x1d\n" + "\x19SANDBOX_PHASE_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aSANDBOX_PHASE_PROVISIONING\x10\x01\x12\x17\n" + @@ -15400,7 +15802,16 @@ const file_openshell_proto_rawDesc = "" + "1PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY\x10\x01\x12;\n" + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE\x10\x02\x12A\n" + "=PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION\x10\x03\x12;\n" + - "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\x8dM\n" + + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x04*\xc3\x02\n" + + "\x0eEndpointResult\x12\x1f\n" + + "\x1bENDPOINT_RESULT_UNSPECIFIED\x10\x00\x12(\n" + + "$ENDPOINT_RESULT_NO_OBSERVED_EXCHANGE\x10\x01\x12*\n" + + "&ENDPOINT_RESULT_HTTP_RESPONSE_RECEIVED\x10\x02\x12!\n" + + "\x1dENDPOINT_RESULT_POLICY_DENIED\x10\x03\x12*\n" + + "&ENDPOINT_RESULT_CREDENTIAL_UNAVAILABLE\x10\x04\x12\x1e\n" + + "\x1aENDPOINT_RESULT_TLS_FAILED\x10\x05\x12$\n" + + " ENDPOINT_RESULT_TRANSPORT_FAILED\x10\x06\x12%\n" + + "!ENDPOINT_RESULT_UPSTREAM_REJECTED\x10\a2\x8bN\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -15498,6 +15909,8 @@ const file_openshell_proto_rawDesc = "" + "\x13ListSandboxPolicies\x12(.openshell.v1.ListSandboxPoliciesRequest\x1a).openshell.v1.ListSandboxPoliciesResponse\" \x82\xb5\x18\x1c\n" + "\x06bearer\x12\x04user\"\fsandbox:read\x12v\n" + "\x12ReportPolicyStatus\x12'.openshell.v1.ReportPolicyStatusRequest\x1a(.openshell.v1.ReportPolicyStatusResponse\"\r\x82\xb5\x18\t\n" + + "\asandbox\x12|\n" + + "\x14ReportEndpointStatus\x12).openshell.v1.ReportEndpointStatusRequest\x1a*.openshell.v1.ReportEndpointStatusResponse\"\r\x82\xb5\x18\t\n" + "\asandbox\x12\x97\x01\n" + "\x1dGetSandboxProviderEnvironment\x122.openshell.v1.GetSandboxProviderEnvironmentRequest\x1a3.openshell.v1.GetSandboxProviderEnvironmentResponse\"\r\x82\xb5\x18\t\n" + "\asandbox\x12\x94\x01\n" + @@ -15566,8 +15979,8 @@ func file_openshell_proto_rawDescGZIP() []byte { return file_openshell_proto_rawDescData } -var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 228) +var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 9) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 232) var file_openshell_proto_goTypes = []any{ (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase (ProviderCredentialTokenGrantType)(0), // 1: openshell.v1.ProviderCredentialTokenGrantType @@ -15577,610 +15990,621 @@ var file_openshell_proto_goTypes = []any{ (ServiceStatus)(0), // 5: openshell.v1.ServiceStatus (WorkspaceRole)(0), // 6: openshell.v1.WorkspaceRole (ProviderCredentialRefreshRecoveryAction)(0), // 7: openshell.v1.ProviderCredentialRefreshRecoveryAction - (*IssueSandboxTokenRequest)(nil), // 8: openshell.v1.IssueSandboxTokenRequest - (*IssueSandboxTokenResponse)(nil), // 9: openshell.v1.IssueSandboxTokenResponse - (*RefreshSandboxTokenRequest)(nil), // 10: openshell.v1.RefreshSandboxTokenRequest - (*RefreshSandboxTokenResponse)(nil), // 11: openshell.v1.RefreshSandboxTokenResponse - (*HealthRequest)(nil), // 12: openshell.v1.HealthRequest - (*HealthResponse)(nil), // 13: openshell.v1.HealthResponse - (*GetCurrentUserRequest)(nil), // 14: openshell.v1.GetCurrentUserRequest - (*GetCurrentUserResponse)(nil), // 15: openshell.v1.GetCurrentUserResponse - (*GetGatewayInfoRequest)(nil), // 16: openshell.v1.GetGatewayInfoRequest - (*GetGatewayInfoResponse)(nil), // 17: openshell.v1.GetGatewayInfoResponse - (*ComputeDriverInfo)(nil), // 18: openshell.v1.ComputeDriverInfo - (*ComputeDriverCapabilities)(nil), // 19: openshell.v1.ComputeDriverCapabilities - (*ResourceCapabilities)(nil), // 20: openshell.v1.ResourceCapabilities - (*CpuResourceCapabilities)(nil), // 21: openshell.v1.CpuResourceCapabilities - (*MemoryResourceCapabilities)(nil), // 22: openshell.v1.MemoryResourceCapabilities - (*GpuResourceCapabilities)(nil), // 23: openshell.v1.GpuResourceCapabilities - (*Sandbox)(nil), // 24: openshell.v1.Sandbox - (*SandboxSpec)(nil), // 25: openshell.v1.SandboxSpec - (*ResourceRequirements)(nil), // 26: openshell.v1.ResourceRequirements - (*GpuResourceRequirements)(nil), // 27: openshell.v1.GpuResourceRequirements - (*SandboxTemplate)(nil), // 28: openshell.v1.SandboxTemplate - (*SandboxWorkloadTemplate)(nil), // 29: openshell.v1.SandboxWorkloadTemplate - (*SandboxWorkloadTemplateSpec)(nil), // 30: openshell.v1.SandboxWorkloadTemplateSpec - (*SandboxWorkloadConfig)(nil), // 31: openshell.v1.SandboxWorkloadConfig - (*SandboxResources)(nil), // 32: openshell.v1.SandboxResources - (*SandboxServiceLevel)(nil), // 33: openshell.v1.SandboxServiceLevel - (*SandboxStartup)(nil), // 34: openshell.v1.SandboxStartup - (*SandboxWorkloadTemplateProvenance)(nil), // 35: openshell.v1.SandboxWorkloadTemplateProvenance - (*SandboxStatus)(nil), // 36: openshell.v1.SandboxStatus - (*SandboxCondition)(nil), // 37: openshell.v1.SandboxCondition - (*PlatformEvent)(nil), // 38: openshell.v1.PlatformEvent - (*CreateSandboxRequest)(nil), // 39: openshell.v1.CreateSandboxRequest - (*CreateSandboxTemplateRequest)(nil), // 40: openshell.v1.CreateSandboxTemplateRequest - (*GetSandboxTemplateRequest)(nil), // 41: openshell.v1.GetSandboxTemplateRequest - (*ListSandboxTemplatesRequest)(nil), // 42: openshell.v1.ListSandboxTemplatesRequest - (*DeleteSandboxTemplateRequest)(nil), // 43: openshell.v1.DeleteSandboxTemplateRequest - (*SandboxTemplateResponse)(nil), // 44: openshell.v1.SandboxTemplateResponse - (*ListSandboxTemplatesResponse)(nil), // 45: openshell.v1.ListSandboxTemplatesResponse - (*DeleteSandboxTemplateResponse)(nil), // 46: openshell.v1.DeleteSandboxTemplateResponse - (*BeginRootfsTarStagingRequest)(nil), // 47: openshell.v1.BeginRootfsTarStagingRequest - (*BeginRootfsTarStagingResponse)(nil), // 48: openshell.v1.BeginRootfsTarStagingResponse - (*GetSandboxRequest)(nil), // 49: openshell.v1.GetSandboxRequest - (*ListSandboxesRequest)(nil), // 50: openshell.v1.ListSandboxesRequest - (*ListSandboxProvidersRequest)(nil), // 51: openshell.v1.ListSandboxProvidersRequest - (*AttachSandboxProviderRequest)(nil), // 52: openshell.v1.AttachSandboxProviderRequest - (*DetachSandboxProviderRequest)(nil), // 53: openshell.v1.DetachSandboxProviderRequest - (*DeleteSandboxRequest)(nil), // 54: openshell.v1.DeleteSandboxRequest - (*StopSandboxRequest)(nil), // 55: openshell.v1.StopSandboxRequest - (*StartSandboxRequest)(nil), // 56: openshell.v1.StartSandboxRequest - (*SandboxResponse)(nil), // 57: openshell.v1.SandboxResponse - (*ListSandboxesResponse)(nil), // 58: openshell.v1.ListSandboxesResponse - (*ListSandboxProvidersResponse)(nil), // 59: openshell.v1.ListSandboxProvidersResponse - (*AttachSandboxProviderResponse)(nil), // 60: openshell.v1.AttachSandboxProviderResponse - (*DetachSandboxProviderResponse)(nil), // 61: openshell.v1.DetachSandboxProviderResponse - (*DeleteSandboxResponse)(nil), // 62: openshell.v1.DeleteSandboxResponse - (*CreateSshSessionRequest)(nil), // 63: openshell.v1.CreateSshSessionRequest - (*CreateSshSessionResponse)(nil), // 64: openshell.v1.CreateSshSessionResponse - (*ExposeServiceRequest)(nil), // 65: openshell.v1.ExposeServiceRequest - (*GetServiceRequest)(nil), // 66: openshell.v1.GetServiceRequest - (*ListServicesRequest)(nil), // 67: openshell.v1.ListServicesRequest - (*ListServicesResponse)(nil), // 68: openshell.v1.ListServicesResponse - (*DeleteServiceRequest)(nil), // 69: openshell.v1.DeleteServiceRequest - (*DeleteServiceResponse)(nil), // 70: openshell.v1.DeleteServiceResponse - (*ServiceEndpoint)(nil), // 71: openshell.v1.ServiceEndpoint - (*ServiceEndpointResponse)(nil), // 72: openshell.v1.ServiceEndpointResponse - (*RevokeSshSessionRequest)(nil), // 73: openshell.v1.RevokeSshSessionRequest - (*RevokeSshSessionResponse)(nil), // 74: openshell.v1.RevokeSshSessionResponse - (*ExecSandboxRequest)(nil), // 75: openshell.v1.ExecSandboxRequest - (*ExecSandboxStdout)(nil), // 76: openshell.v1.ExecSandboxStdout - (*ExecSandboxStderr)(nil), // 77: openshell.v1.ExecSandboxStderr - (*ExecSandboxExit)(nil), // 78: openshell.v1.ExecSandboxExit - (*ExecSandboxEvent)(nil), // 79: openshell.v1.ExecSandboxEvent - (*TcpForwardInit)(nil), // 80: openshell.v1.TcpForwardInit - (*TcpForwardFrame)(nil), // 81: openshell.v1.TcpForwardFrame - (*ExecSandboxInput)(nil), // 82: openshell.v1.ExecSandboxInput - (*ExecSandboxWindowResize)(nil), // 83: openshell.v1.ExecSandboxWindowResize - (*SshSession)(nil), // 84: openshell.v1.SshSession - (*WatchSandboxRequest)(nil), // 85: openshell.v1.WatchSandboxRequest - (*SandboxStreamEvent)(nil), // 86: openshell.v1.SandboxStreamEvent - (*SandboxLogLine)(nil), // 87: openshell.v1.SandboxLogLine - (*SandboxStreamWarning)(nil), // 88: openshell.v1.SandboxStreamWarning - (*CreateProviderRequest)(nil), // 89: openshell.v1.CreateProviderRequest - (*GetProviderRequest)(nil), // 90: openshell.v1.GetProviderRequest - (*ListProvidersRequest)(nil), // 91: openshell.v1.ListProvidersRequest - (*UpdateProviderRequest)(nil), // 92: openshell.v1.UpdateProviderRequest - (*DeleteProviderRequest)(nil), // 93: openshell.v1.DeleteProviderRequest - (*ProviderResponse)(nil), // 94: openshell.v1.ProviderResponse - (*ListProvidersResponse)(nil), // 95: openshell.v1.ListProvidersResponse - (*ListProviderProfilesRequest)(nil), // 96: openshell.v1.ListProviderProfilesRequest - (*GetProviderProfileRequest)(nil), // 97: openshell.v1.GetProviderProfileRequest - (*ProviderProfileImportItem)(nil), // 98: openshell.v1.ProviderProfileImportItem - (*ProviderProfileDiagnostic)(nil), // 99: openshell.v1.ProviderProfileDiagnostic - (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 100: openshell.v1.ProviderCredentialTokenGrantAudienceOverride - (*ProviderCredentialTokenGrantSubjectToken)(nil), // 101: openshell.v1.ProviderCredentialTokenGrantSubjectToken - (*ProviderCredentialTokenGrant)(nil), // 102: openshell.v1.ProviderCredentialTokenGrant - (*ProviderProfileCredential)(nil), // 103: openshell.v1.ProviderProfileCredential - (*ProviderCredentialRefreshMaterial)(nil), // 104: openshell.v1.ProviderCredentialRefreshMaterial - (*ProviderCredentialRefreshOutput)(nil), // 105: openshell.v1.ProviderCredentialRefreshOutput - (*ProviderCredentialRefresh)(nil), // 106: openshell.v1.ProviderCredentialRefresh - (*ProviderCredentialRefreshStatus)(nil), // 107: openshell.v1.ProviderCredentialRefreshStatus - (*ProviderProfileDiscovery)(nil), // 108: openshell.v1.ProviderProfileDiscovery - (*GetProviderRefreshStatusRequest)(nil), // 109: openshell.v1.GetProviderRefreshStatusRequest - (*GetProviderRefreshStatusResponse)(nil), // 110: openshell.v1.GetProviderRefreshStatusResponse - (*ConfigureProviderRefreshRequest)(nil), // 111: openshell.v1.ConfigureProviderRefreshRequest - (*ConfigureProviderRefreshResponse)(nil), // 112: openshell.v1.ConfigureProviderRefreshResponse - (*RotateProviderCredentialRequest)(nil), // 113: openshell.v1.RotateProviderCredentialRequest - (*RotateProviderCredentialResponse)(nil), // 114: openshell.v1.RotateProviderCredentialResponse - (*DeleteProviderRefreshRequest)(nil), // 115: openshell.v1.DeleteProviderRefreshRequest - (*DeleteProviderRefreshResponse)(nil), // 116: openshell.v1.DeleteProviderRefreshResponse - (*ProviderProfile)(nil), // 117: openshell.v1.ProviderProfile - (*ProviderProfileResponse)(nil), // 118: openshell.v1.ProviderProfileResponse - (*ListProviderProfilesResponse)(nil), // 119: openshell.v1.ListProviderProfilesResponse - (*ImportProviderProfilesRequest)(nil), // 120: openshell.v1.ImportProviderProfilesRequest - (*ImportProviderProfilesResponse)(nil), // 121: openshell.v1.ImportProviderProfilesResponse - (*UpdateProviderProfilesRequest)(nil), // 122: openshell.v1.UpdateProviderProfilesRequest - (*UpdateProviderProfilesResponse)(nil), // 123: openshell.v1.UpdateProviderProfilesResponse - (*LintProviderProfilesRequest)(nil), // 124: openshell.v1.LintProviderProfilesRequest - (*LintProviderProfilesResponse)(nil), // 125: openshell.v1.LintProviderProfilesResponse - (*DeleteProviderResponse)(nil), // 126: openshell.v1.DeleteProviderResponse - (*DeleteProviderProfileRequest)(nil), // 127: openshell.v1.DeleteProviderProfileRequest - (*DeleteProviderProfileResponse)(nil), // 128: openshell.v1.DeleteProviderProfileResponse - (*GetSandboxProviderEnvironmentRequest)(nil), // 129: openshell.v1.GetSandboxProviderEnvironmentRequest - (*StaticCredentialEndpointBinding)(nil), // 130: openshell.v1.StaticCredentialEndpointBinding - (*StaticCredentialBinding)(nil), // 131: openshell.v1.StaticCredentialBinding - (*GetSandboxProviderEnvironmentResponse)(nil), // 132: openshell.v1.GetSandboxProviderEnvironmentResponse - (*ExchangeProviderSubjectTokenRequest)(nil), // 133: openshell.v1.ExchangeProviderSubjectTokenRequest - (*ExchangeProviderSubjectTokenResponse)(nil), // 134: openshell.v1.ExchangeProviderSubjectTokenResponse - (*UpdateConfigRequest)(nil), // 135: openshell.v1.UpdateConfigRequest - (*PolicyMergeOperation)(nil), // 136: openshell.v1.PolicyMergeOperation - (*AddNetworkRule)(nil), // 137: openshell.v1.AddNetworkRule - (*RemoveNetworkEndpoint)(nil), // 138: openshell.v1.RemoveNetworkEndpoint - (*RemoveNetworkRule)(nil), // 139: openshell.v1.RemoveNetworkRule - (*AddDenyRules)(nil), // 140: openshell.v1.AddDenyRules - (*AddAllowRules)(nil), // 141: openshell.v1.AddAllowRules - (*RemoveNetworkBinary)(nil), // 142: openshell.v1.RemoveNetworkBinary - (*UpdateConfigResponse)(nil), // 143: openshell.v1.UpdateConfigResponse - (*GetSandboxPolicyStatusRequest)(nil), // 144: openshell.v1.GetSandboxPolicyStatusRequest - (*GetSandboxPolicyStatusResponse)(nil), // 145: openshell.v1.GetSandboxPolicyStatusResponse - (*ListSandboxPoliciesRequest)(nil), // 146: openshell.v1.ListSandboxPoliciesRequest - (*ListSandboxPoliciesResponse)(nil), // 147: openshell.v1.ListSandboxPoliciesResponse - (*ReportPolicyStatusRequest)(nil), // 148: openshell.v1.ReportPolicyStatusRequest - (*ReportPolicyStatusResponse)(nil), // 149: openshell.v1.ReportPolicyStatusResponse - (*SandboxPolicyRevision)(nil), // 150: openshell.v1.SandboxPolicyRevision - (*GetSandboxLogsRequest)(nil), // 151: openshell.v1.GetSandboxLogsRequest - (*PushSandboxLogsRequest)(nil), // 152: openshell.v1.PushSandboxLogsRequest - (*PushSandboxLogsResponse)(nil), // 153: openshell.v1.PushSandboxLogsResponse - (*GetSandboxLogsResponse)(nil), // 154: openshell.v1.GetSandboxLogsResponse - (*SupervisorMessage)(nil), // 155: openshell.v1.SupervisorMessage - (*GatewayMessage)(nil), // 156: openshell.v1.GatewayMessage - (*SupervisorHello)(nil), // 157: openshell.v1.SupervisorHello - (*SessionAccepted)(nil), // 158: openshell.v1.SessionAccepted - (*SessionRejected)(nil), // 159: openshell.v1.SessionRejected - (*SupervisorHeartbeat)(nil), // 160: openshell.v1.SupervisorHeartbeat - (*GatewayHeartbeat)(nil), // 161: openshell.v1.GatewayHeartbeat - (*ReportMainProcessExitRequest)(nil), // 162: openshell.v1.ReportMainProcessExitRequest - (*ReportMainProcessExitResponse)(nil), // 163: openshell.v1.ReportMainProcessExitResponse - (*FinalizeMainProcessExitRequest)(nil), // 164: openshell.v1.FinalizeMainProcessExitRequest - (*FinalizeMainProcessExitResponse)(nil), // 165: openshell.v1.FinalizeMainProcessExitResponse - (*RelayOpen)(nil), // 166: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 167: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 168: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 169: openshell.v1.RelayInit - (*RelayFrame)(nil), // 170: openshell.v1.RelayFrame - (*RelayOpenResult)(nil), // 171: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 172: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 173: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 174: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 175: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 176: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 177: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 178: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 179: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 180: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 181: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 182: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 183: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 184: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 185: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 186: openshell.v1.RejectDraftChunkResponse - (*DraftChunkApproval)(nil), // 187: openshell.v1.DraftChunkApproval - (*ApproveAllDraftChunksRequest)(nil), // 188: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 189: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 190: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 191: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 192: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 193: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 194: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 195: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 196: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 197: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 198: openshell.v1.GetDraftHistoryResponse - (*CreateWorkspaceRequest)(nil), // 199: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 200: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 201: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 202: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 203: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 204: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 205: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 206: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 207: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 208: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 209: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 210: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 211: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 212: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 213: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 214: openshell.v1.ExtensionServiceCredential - nil, // 215: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 216: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 217: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 218: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 219: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - nil, // 220: openshell.v1.PlatformEvent.MetadataEntry - nil, // 221: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 222: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 223: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 224: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 225: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - nil, // 226: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 227: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 228: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 229: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - nil, // 230: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 231: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 232: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 233: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 234: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 235: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 236: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 237: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 238: google.protobuf.Struct - (*durationpb.Duration)(nil), // 239: google.protobuf.Duration - (*datamodelv1.WorkspaceSelector)(nil), // 240: openshell.datamodel.v1.WorkspaceSelector - (*datamodelv1.Provider)(nil), // 241: openshell.datamodel.v1.Provider - (*sandboxv1.NetworkEndpoint)(nil), // 242: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 243: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 244: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 245: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 246: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 247: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 248: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 249: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 250: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 251: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 252: openshell.sandbox.v1.GetGatewayConfigResponse + (EndpointResult)(0), // 8: openshell.v1.EndpointResult + (*IssueSandboxTokenRequest)(nil), // 9: openshell.v1.IssueSandboxTokenRequest + (*IssueSandboxTokenResponse)(nil), // 10: openshell.v1.IssueSandboxTokenResponse + (*RefreshSandboxTokenRequest)(nil), // 11: openshell.v1.RefreshSandboxTokenRequest + (*RefreshSandboxTokenResponse)(nil), // 12: openshell.v1.RefreshSandboxTokenResponse + (*HealthRequest)(nil), // 13: openshell.v1.HealthRequest + (*HealthResponse)(nil), // 14: openshell.v1.HealthResponse + (*GetCurrentUserRequest)(nil), // 15: openshell.v1.GetCurrentUserRequest + (*GetCurrentUserResponse)(nil), // 16: openshell.v1.GetCurrentUserResponse + (*GetGatewayInfoRequest)(nil), // 17: openshell.v1.GetGatewayInfoRequest + (*GetGatewayInfoResponse)(nil), // 18: openshell.v1.GetGatewayInfoResponse + (*ComputeDriverInfo)(nil), // 19: openshell.v1.ComputeDriverInfo + (*ComputeDriverCapabilities)(nil), // 20: openshell.v1.ComputeDriverCapabilities + (*ResourceCapabilities)(nil), // 21: openshell.v1.ResourceCapabilities + (*CpuResourceCapabilities)(nil), // 22: openshell.v1.CpuResourceCapabilities + (*MemoryResourceCapabilities)(nil), // 23: openshell.v1.MemoryResourceCapabilities + (*GpuResourceCapabilities)(nil), // 24: openshell.v1.GpuResourceCapabilities + (*Sandbox)(nil), // 25: openshell.v1.Sandbox + (*SandboxSpec)(nil), // 26: openshell.v1.SandboxSpec + (*ResourceRequirements)(nil), // 27: openshell.v1.ResourceRequirements + (*GpuResourceRequirements)(nil), // 28: openshell.v1.GpuResourceRequirements + (*SandboxTemplate)(nil), // 29: openshell.v1.SandboxTemplate + (*SandboxWorkloadTemplate)(nil), // 30: openshell.v1.SandboxWorkloadTemplate + (*SandboxWorkloadTemplateSpec)(nil), // 31: openshell.v1.SandboxWorkloadTemplateSpec + (*SandboxWorkloadConfig)(nil), // 32: openshell.v1.SandboxWorkloadConfig + (*SandboxResources)(nil), // 33: openshell.v1.SandboxResources + (*SandboxServiceLevel)(nil), // 34: openshell.v1.SandboxServiceLevel + (*SandboxStartup)(nil), // 35: openshell.v1.SandboxStartup + (*SandboxWorkloadTemplateProvenance)(nil), // 36: openshell.v1.SandboxWorkloadTemplateProvenance + (*SandboxStatus)(nil), // 37: openshell.v1.SandboxStatus + (*SandboxCondition)(nil), // 38: openshell.v1.SandboxCondition + (*PlatformEvent)(nil), // 39: openshell.v1.PlatformEvent + (*CreateSandboxRequest)(nil), // 40: openshell.v1.CreateSandboxRequest + (*CreateSandboxTemplateRequest)(nil), // 41: openshell.v1.CreateSandboxTemplateRequest + (*GetSandboxTemplateRequest)(nil), // 42: openshell.v1.GetSandboxTemplateRequest + (*ListSandboxTemplatesRequest)(nil), // 43: openshell.v1.ListSandboxTemplatesRequest + (*DeleteSandboxTemplateRequest)(nil), // 44: openshell.v1.DeleteSandboxTemplateRequest + (*SandboxTemplateResponse)(nil), // 45: openshell.v1.SandboxTemplateResponse + (*ListSandboxTemplatesResponse)(nil), // 46: openshell.v1.ListSandboxTemplatesResponse + (*DeleteSandboxTemplateResponse)(nil), // 47: openshell.v1.DeleteSandboxTemplateResponse + (*BeginRootfsTarStagingRequest)(nil), // 48: openshell.v1.BeginRootfsTarStagingRequest + (*BeginRootfsTarStagingResponse)(nil), // 49: openshell.v1.BeginRootfsTarStagingResponse + (*GetSandboxRequest)(nil), // 50: openshell.v1.GetSandboxRequest + (*ListSandboxesRequest)(nil), // 51: openshell.v1.ListSandboxesRequest + (*ListSandboxProvidersRequest)(nil), // 52: openshell.v1.ListSandboxProvidersRequest + (*AttachSandboxProviderRequest)(nil), // 53: openshell.v1.AttachSandboxProviderRequest + (*DetachSandboxProviderRequest)(nil), // 54: openshell.v1.DetachSandboxProviderRequest + (*DeleteSandboxRequest)(nil), // 55: openshell.v1.DeleteSandboxRequest + (*StopSandboxRequest)(nil), // 56: openshell.v1.StopSandboxRequest + (*StartSandboxRequest)(nil), // 57: openshell.v1.StartSandboxRequest + (*SandboxResponse)(nil), // 58: openshell.v1.SandboxResponse + (*ListSandboxesResponse)(nil), // 59: openshell.v1.ListSandboxesResponse + (*ListSandboxProvidersResponse)(nil), // 60: openshell.v1.ListSandboxProvidersResponse + (*AttachSandboxProviderResponse)(nil), // 61: openshell.v1.AttachSandboxProviderResponse + (*DetachSandboxProviderResponse)(nil), // 62: openshell.v1.DetachSandboxProviderResponse + (*DeleteSandboxResponse)(nil), // 63: openshell.v1.DeleteSandboxResponse + (*CreateSshSessionRequest)(nil), // 64: openshell.v1.CreateSshSessionRequest + (*CreateSshSessionResponse)(nil), // 65: openshell.v1.CreateSshSessionResponse + (*ExposeServiceRequest)(nil), // 66: openshell.v1.ExposeServiceRequest + (*GetServiceRequest)(nil), // 67: openshell.v1.GetServiceRequest + (*ListServicesRequest)(nil), // 68: openshell.v1.ListServicesRequest + (*ListServicesResponse)(nil), // 69: openshell.v1.ListServicesResponse + (*DeleteServiceRequest)(nil), // 70: openshell.v1.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 71: openshell.v1.DeleteServiceResponse + (*ServiceEndpoint)(nil), // 72: openshell.v1.ServiceEndpoint + (*ServiceEndpointResponse)(nil), // 73: openshell.v1.ServiceEndpointResponse + (*RevokeSshSessionRequest)(nil), // 74: openshell.v1.RevokeSshSessionRequest + (*RevokeSshSessionResponse)(nil), // 75: openshell.v1.RevokeSshSessionResponse + (*ExecSandboxRequest)(nil), // 76: openshell.v1.ExecSandboxRequest + (*ExecSandboxStdout)(nil), // 77: openshell.v1.ExecSandboxStdout + (*ExecSandboxStderr)(nil), // 78: openshell.v1.ExecSandboxStderr + (*ExecSandboxExit)(nil), // 79: openshell.v1.ExecSandboxExit + (*ExecSandboxEvent)(nil), // 80: openshell.v1.ExecSandboxEvent + (*TcpForwardInit)(nil), // 81: openshell.v1.TcpForwardInit + (*TcpForwardFrame)(nil), // 82: openshell.v1.TcpForwardFrame + (*ExecSandboxInput)(nil), // 83: openshell.v1.ExecSandboxInput + (*ExecSandboxWindowResize)(nil), // 84: openshell.v1.ExecSandboxWindowResize + (*SshSession)(nil), // 85: openshell.v1.SshSession + (*WatchSandboxRequest)(nil), // 86: openshell.v1.WatchSandboxRequest + (*SandboxStreamEvent)(nil), // 87: openshell.v1.SandboxStreamEvent + (*SandboxLogLine)(nil), // 88: openshell.v1.SandboxLogLine + (*SandboxStreamWarning)(nil), // 89: openshell.v1.SandboxStreamWarning + (*CreateProviderRequest)(nil), // 90: openshell.v1.CreateProviderRequest + (*GetProviderRequest)(nil), // 91: openshell.v1.GetProviderRequest + (*ListProvidersRequest)(nil), // 92: openshell.v1.ListProvidersRequest + (*UpdateProviderRequest)(nil), // 93: openshell.v1.UpdateProviderRequest + (*DeleteProviderRequest)(nil), // 94: openshell.v1.DeleteProviderRequest + (*ProviderResponse)(nil), // 95: openshell.v1.ProviderResponse + (*ListProvidersResponse)(nil), // 96: openshell.v1.ListProvidersResponse + (*ListProviderProfilesRequest)(nil), // 97: openshell.v1.ListProviderProfilesRequest + (*GetProviderProfileRequest)(nil), // 98: openshell.v1.GetProviderProfileRequest + (*ProviderProfileImportItem)(nil), // 99: openshell.v1.ProviderProfileImportItem + (*ProviderProfileDiagnostic)(nil), // 100: openshell.v1.ProviderProfileDiagnostic + (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 101: openshell.v1.ProviderCredentialTokenGrantAudienceOverride + (*ProviderCredentialTokenGrantSubjectToken)(nil), // 102: openshell.v1.ProviderCredentialTokenGrantSubjectToken + (*ProviderCredentialTokenGrant)(nil), // 103: openshell.v1.ProviderCredentialTokenGrant + (*ProviderProfileCredential)(nil), // 104: openshell.v1.ProviderProfileCredential + (*ProviderCredentialRefreshMaterial)(nil), // 105: openshell.v1.ProviderCredentialRefreshMaterial + (*ProviderCredentialRefreshOutput)(nil), // 106: openshell.v1.ProviderCredentialRefreshOutput + (*ProviderCredentialRefresh)(nil), // 107: openshell.v1.ProviderCredentialRefresh + (*ProviderCredentialRefreshStatus)(nil), // 108: openshell.v1.ProviderCredentialRefreshStatus + (*ProviderProfileDiscovery)(nil), // 109: openshell.v1.ProviderProfileDiscovery + (*GetProviderRefreshStatusRequest)(nil), // 110: openshell.v1.GetProviderRefreshStatusRequest + (*GetProviderRefreshStatusResponse)(nil), // 111: openshell.v1.GetProviderRefreshStatusResponse + (*ConfigureProviderRefreshRequest)(nil), // 112: openshell.v1.ConfigureProviderRefreshRequest + (*ConfigureProviderRefreshResponse)(nil), // 113: openshell.v1.ConfigureProviderRefreshResponse + (*RotateProviderCredentialRequest)(nil), // 114: openshell.v1.RotateProviderCredentialRequest + (*RotateProviderCredentialResponse)(nil), // 115: openshell.v1.RotateProviderCredentialResponse + (*DeleteProviderRefreshRequest)(nil), // 116: openshell.v1.DeleteProviderRefreshRequest + (*DeleteProviderRefreshResponse)(nil), // 117: openshell.v1.DeleteProviderRefreshResponse + (*ProviderProfile)(nil), // 118: openshell.v1.ProviderProfile + (*ProviderProfileResponse)(nil), // 119: openshell.v1.ProviderProfileResponse + (*ListProviderProfilesResponse)(nil), // 120: openshell.v1.ListProviderProfilesResponse + (*ImportProviderProfilesRequest)(nil), // 121: openshell.v1.ImportProviderProfilesRequest + (*ImportProviderProfilesResponse)(nil), // 122: openshell.v1.ImportProviderProfilesResponse + (*UpdateProviderProfilesRequest)(nil), // 123: openshell.v1.UpdateProviderProfilesRequest + (*UpdateProviderProfilesResponse)(nil), // 124: openshell.v1.UpdateProviderProfilesResponse + (*LintProviderProfilesRequest)(nil), // 125: openshell.v1.LintProviderProfilesRequest + (*LintProviderProfilesResponse)(nil), // 126: openshell.v1.LintProviderProfilesResponse + (*DeleteProviderResponse)(nil), // 127: openshell.v1.DeleteProviderResponse + (*DeleteProviderProfileRequest)(nil), // 128: openshell.v1.DeleteProviderProfileRequest + (*DeleteProviderProfileResponse)(nil), // 129: openshell.v1.DeleteProviderProfileResponse + (*GetSandboxProviderEnvironmentRequest)(nil), // 130: openshell.v1.GetSandboxProviderEnvironmentRequest + (*StaticCredentialEndpointBinding)(nil), // 131: openshell.v1.StaticCredentialEndpointBinding + (*StaticCredentialBinding)(nil), // 132: openshell.v1.StaticCredentialBinding + (*GetSandboxProviderEnvironmentResponse)(nil), // 133: openshell.v1.GetSandboxProviderEnvironmentResponse + (*ExchangeProviderSubjectTokenRequest)(nil), // 134: openshell.v1.ExchangeProviderSubjectTokenRequest + (*ExchangeProviderSubjectTokenResponse)(nil), // 135: openshell.v1.ExchangeProviderSubjectTokenResponse + (*UpdateConfigRequest)(nil), // 136: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 137: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 138: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 139: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 140: openshell.v1.RemoveNetworkRule + (*AddDenyRules)(nil), // 141: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 142: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 143: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 144: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 145: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 146: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 147: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 148: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 149: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 150: openshell.v1.ReportPolicyStatusResponse + (*SandboxPolicyRevision)(nil), // 151: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 152: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 153: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 154: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 155: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 156: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 157: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 158: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 159: openshell.v1.SessionAccepted + (*SessionRejected)(nil), // 160: openshell.v1.SessionRejected + (*SupervisorHeartbeat)(nil), // 161: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 162: openshell.v1.GatewayHeartbeat + (*ReportMainProcessExitRequest)(nil), // 163: openshell.v1.ReportMainProcessExitRequest + (*ReportMainProcessExitResponse)(nil), // 164: openshell.v1.ReportMainProcessExitResponse + (*FinalizeMainProcessExitRequest)(nil), // 165: openshell.v1.FinalizeMainProcessExitRequest + (*FinalizeMainProcessExitResponse)(nil), // 166: openshell.v1.FinalizeMainProcessExitResponse + (*RelayOpen)(nil), // 167: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 168: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 169: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 170: openshell.v1.RelayInit + (*RelayFrame)(nil), // 171: openshell.v1.RelayFrame + (*RelayOpenResult)(nil), // 172: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 173: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 174: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 175: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 176: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 177: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 178: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 179: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 180: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 181: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 182: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 183: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 184: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 185: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 186: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 187: openshell.v1.RejectDraftChunkResponse + (*DraftChunkApproval)(nil), // 188: openshell.v1.DraftChunkApproval + (*ApproveAllDraftChunksRequest)(nil), // 189: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 190: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 191: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 192: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 193: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 194: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 195: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 196: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 197: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 198: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 199: openshell.v1.GetDraftHistoryResponse + (*CreateWorkspaceRequest)(nil), // 200: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 201: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 202: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 203: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 204: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 205: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 206: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 207: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 208: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 209: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 210: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 211: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 212: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 213: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 214: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 215: openshell.v1.ExtensionServiceCredential + (*EndpointObservation)(nil), // 216: openshell.v1.EndpointObservation + (*ReportEndpointStatusRequest)(nil), // 217: openshell.v1.ReportEndpointStatusRequest + (*ReportEndpointStatusResponse)(nil), // 218: openshell.v1.ReportEndpointStatusResponse + (*EndpointStatus)(nil), // 219: openshell.v1.EndpointStatus + nil, // 220: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 221: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 222: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 223: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 224: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + nil, // 225: openshell.v1.PlatformEvent.MetadataEntry + nil, // 226: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 227: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 228: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 229: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 230: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 231: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 232: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 233: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 234: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 235: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 236: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 237: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 238: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 239: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 240: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*datamodelv1.ObjectMeta)(nil), // 241: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 242: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 243: google.protobuf.Struct + (*durationpb.Duration)(nil), // 244: google.protobuf.Duration + (*datamodelv1.WorkspaceSelector)(nil), // 245: openshell.datamodel.v1.WorkspaceSelector + (*datamodelv1.Provider)(nil), // 246: openshell.datamodel.v1.Provider + (*sandboxv1.NetworkEndpoint)(nil), // 247: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 248: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 249: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 250: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 251: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 252: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 253: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 254: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 255: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 256: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 257: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 214, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 215, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential 5, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus 5, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus - 18, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo - 19, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 20, // 5: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities - 21, // 6: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities - 22, // 7: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities - 23, // 8: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities - 236, // 9: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 25, // 10: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec - 36, // 11: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 35, // 12: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance - 215, // 13: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry - 28, // 14: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 237, // 15: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 26, // 16: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements - 27, // 17: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 216, // 18: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 217, // 19: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 218, // 20: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 238, // 21: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 238, // 22: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 236, // 23: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 30, // 24: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec - 31, // 25: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig - 238, // 26: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct - 33, // 27: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel - 219, // 28: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - 32, // 29: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources - 27, // 30: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements - 34, // 31: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup - 239, // 32: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration - 37, // 33: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 19, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo + 20, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities + 21, // 5: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities + 22, // 6: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities + 23, // 7: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities + 24, // 8: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities + 241, // 9: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 26, // 10: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 37, // 11: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 36, // 12: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance + 220, // 13: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 29, // 14: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 242, // 15: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 27, // 16: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 28, // 17: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 221, // 18: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 222, // 19: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 223, // 20: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 243, // 21: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 243, // 22: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 241, // 23: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 31, // 24: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec + 32, // 25: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig + 243, // 26: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct + 34, // 27: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel + 224, // 28: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + 33, // 29: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources + 28, // 30: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements + 35, // 31: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup + 244, // 32: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration + 38, // 33: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition 0, // 34: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 220, // 35: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 25, // 36: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 221, // 37: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 222, // 38: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 240, // 39: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 29, // 40: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 240, // 41: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 42: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 43: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 44: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 29, // 45: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 29, // 46: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate - 240, // 47: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 48: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 49: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 50: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 51: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 52: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 53: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 54: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 55: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 24, // 56: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 24, // 57: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 241, // 58: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 24, // 59: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 24, // 60: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 240, // 61: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 62: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 63: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 72, // 64: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 240, // 65: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 236, // 66: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 71, // 67: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 223, // 68: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 76, // 69: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 77, // 70: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 78, // 71: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 167, // 72: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 168, // 73: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 80, // 74: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 75, // 75: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 83, // 76: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 236, // 77: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 24, // 78: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 87, // 79: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 38, // 80: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 88, // 81: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 178, // 82: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 224, // 83: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 241, // 84: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 240, // 85: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 86: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 87: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 241, // 88: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 225, // 89: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 240, // 90: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 91: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 241, // 92: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 241, // 93: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 117, // 94: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 100, // 95: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride - 1, // 96: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType - 101, // 97: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken - 106, // 98: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 102, // 99: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant - 2, // 100: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 104, // 101: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 105, // 102: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput - 2, // 103: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 7, // 104: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 240, // 105: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 107, // 106: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 2, // 107: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 226, // 108: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 240, // 109: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 107, // 110: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 240, // 111: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 107, // 112: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 240, // 113: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 3, // 114: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 103, // 115: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 242, // 116: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 243, // 117: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 108, // 118: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 227, // 119: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 117, // 120: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 117, // 121: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 98, // 122: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 99, // 123: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 117, // 124: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 98, // 125: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 99, // 126: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 117, // 127: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 98, // 128: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 99, // 129: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 130, // 130: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 228, // 131: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 229, // 132: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 230, // 133: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 231, // 134: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 237, // 135: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 244, // 136: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 136, // 137: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 232, // 138: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 240, // 139: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 137, // 140: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 138, // 141: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 139, // 142: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 140, // 143: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 141, // 144: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 142, // 145: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 245, // 146: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 246, // 147: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 247, // 148: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 233, // 149: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 240, // 150: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 150, // 151: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 240, // 152: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 150, // 153: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 4, // 154: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 4, // 155: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 237, // 156: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 234, // 157: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 240, // 158: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 87, // 159: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 87, // 160: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 157, // 161: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 160, // 162: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 171, // 163: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 172, // 164: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 158, // 165: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 159, // 166: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 161, // 167: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 166, // 168: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 172, // 169: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 167, // 170: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 168, // 171: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 169, // 172: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 173, // 173: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 175, // 174: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 245, // 175: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 237, // 176: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 237, // 177: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 174, // 178: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 177, // 179: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 176, // 180: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 240, // 181: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 177, // 182: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 240, // 183: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 184: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 187, // 185: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 240, // 186: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 187: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 240, // 188: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 189: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 190: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 240, // 191: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 197, // 192: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 235, // 193: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 248, // 194: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 248, // 195: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 248, // 196: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 236, // 197: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 6, // 198: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 6, // 199: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 207, // 200: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 207, // 201: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 103, // 202: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 131, // 203: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 12, // 204: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 14, // 205: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 16, // 206: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 39, // 207: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 47, // 208: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest - 49, // 209: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 50, // 210: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 40, // 211: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest - 41, // 212: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest - 42, // 213: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest - 43, // 214: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest - 51, // 215: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 52, // 216: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 53, // 217: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 54, // 218: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 55, // 219: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 56, // 220: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 63, // 221: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 65, // 222: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 66, // 223: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 67, // 224: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 69, // 225: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 73, // 226: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 75, // 227: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 81, // 228: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 82, // 229: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 89, // 230: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 90, // 231: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 91, // 232: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 96, // 233: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 97, // 234: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 120, // 235: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 122, // 236: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 124, // 237: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 92, // 238: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 109, // 239: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 111, // 240: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 113, // 241: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 115, // 242: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 93, // 243: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 127, // 244: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 249, // 245: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 250, // 246: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 135, // 247: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 144, // 248: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 146, // 249: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 148, // 250: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 129, // 251: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 133, // 252: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 151, // 253: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 152, // 254: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 155, // 255: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 162, // 256: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 164, // 257: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest - 170, // 258: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 85, // 259: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 179, // 260: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 181, // 261: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 183, // 262: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 185, // 263: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 188, // 264: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 190, // 265: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 192, // 266: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 194, // 267: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 196, // 268: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 8, // 269: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 10, // 270: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 199, // 271: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 201, // 272: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 203, // 273: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 205, // 274: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 208, // 275: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 210, // 276: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 212, // 277: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 13, // 278: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 15, // 279: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 17, // 280: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 57, // 281: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 48, // 282: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse - 57, // 283: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 58, // 284: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 44, // 285: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 44, // 286: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 45, // 287: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse - 46, // 288: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse - 59, // 289: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 60, // 290: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 61, // 291: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 62, // 292: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 57, // 293: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 57, // 294: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 64, // 295: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 72, // 296: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 72, // 297: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 68, // 298: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 70, // 299: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 74, // 300: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 79, // 301: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 81, // 302: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 79, // 303: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 94, // 304: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 94, // 305: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 95, // 306: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 119, // 307: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 118, // 308: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 121, // 309: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 123, // 310: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 125, // 311: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 94, // 312: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 110, // 313: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 112, // 314: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 114, // 315: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 116, // 316: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 126, // 317: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 128, // 318: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 251, // 319: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 252, // 320: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 143, // 321: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 145, // 322: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 147, // 323: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 149, // 324: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 132, // 325: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 134, // 326: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 154, // 327: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 153, // 328: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 156, // 329: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 163, // 330: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 165, // 331: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse - 170, // 332: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 86, // 333: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 180, // 334: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 182, // 335: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 184, // 336: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 186, // 337: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 189, // 338: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 191, // 339: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 193, // 340: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 195, // 341: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 198, // 342: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 9, // 343: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 11, // 344: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 200, // 345: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 202, // 346: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 204, // 347: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 206, // 348: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 209, // 349: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 211, // 350: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 213, // 351: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 278, // [278:352] is the sub-list for method output_type - 204, // [204:278] is the sub-list for method input_type - 204, // [204:204] is the sub-list for extension type_name - 204, // [204:204] is the sub-list for extension extendee - 0, // [0:204] is the sub-list for field type_name + 219, // 35: openshell.v1.SandboxStatus.endpoint_statuses:type_name -> openshell.v1.EndpointStatus + 225, // 36: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 26, // 37: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 226, // 38: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 227, // 39: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 245, // 40: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 30, // 41: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 245, // 42: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 43: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 44: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 45: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 30, // 46: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 30, // 47: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate + 245, // 48: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 49: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 50: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 51: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 52: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 53: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 54: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 55: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 56: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 25, // 57: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 25, // 58: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 246, // 59: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 25, // 60: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 25, // 61: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 245, // 62: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 63: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 64: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 73, // 65: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 245, // 66: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 241, // 67: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 72, // 68: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 228, // 69: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 77, // 70: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 78, // 71: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 79, // 72: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 168, // 73: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 169, // 74: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 81, // 75: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 76, // 76: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 84, // 77: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 241, // 78: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 25, // 79: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 88, // 80: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 39, // 81: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 89, // 82: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 179, // 83: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 229, // 84: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 246, // 85: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 245, // 86: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 87: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 88: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 89: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 230, // 90: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 245, // 91: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 92: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 93: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 246, // 94: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 118, // 95: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 101, // 96: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 1, // 97: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType + 102, // 98: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken + 107, // 99: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 103, // 100: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 2, // 101: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 105, // 102: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 106, // 103: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 2, // 104: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 7, // 105: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 245, // 106: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 108, // 107: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 108: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 231, // 109: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 245, // 110: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 108, // 111: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 245, // 112: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 108, // 113: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 245, // 114: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 3, // 115: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 104, // 116: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 247, // 117: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 248, // 118: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 109, // 119: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 232, // 120: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 118, // 121: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 118, // 122: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 99, // 123: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 100, // 124: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 118, // 125: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 99, // 126: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 100, // 127: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 118, // 128: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 99, // 129: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 100, // 130: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 131, // 131: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding + 233, // 132: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 234, // 133: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 235, // 134: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 236, // 135: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 242, // 136: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 249, // 137: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 137, // 138: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 237, // 139: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 245, // 140: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 138, // 141: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 139, // 142: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 140, // 143: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 141, // 144: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 142, // 145: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 143, // 146: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 250, // 147: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 251, // 148: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 252, // 149: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 238, // 150: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 245, // 151: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 151, // 152: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 245, // 153: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 151, // 154: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 4, // 155: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 4, // 156: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 242, // 157: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 239, // 158: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 245, // 159: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 88, // 160: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 88, // 161: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 158, // 162: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 161, // 163: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 172, // 164: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 173, // 165: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 159, // 166: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 160, // 167: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 162, // 168: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 167, // 169: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 173, // 170: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 168, // 171: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 169, // 172: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 170, // 173: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 174, // 174: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 176, // 175: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 250, // 176: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 242, // 177: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 242, // 178: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 175, // 179: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 178, // 180: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 177, // 181: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 245, // 182: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 178, // 183: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 245, // 184: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 185: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 188, // 186: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 245, // 187: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 250, // 188: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 245, // 189: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 190: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 191: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 245, // 192: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 198, // 193: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 240, // 194: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 253, // 195: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 253, // 196: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 253, // 197: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 241, // 198: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 6, // 199: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 6, // 200: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 208, // 201: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 208, // 202: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 8, // 203: openshell.v1.EndpointObservation.result:type_name -> openshell.v1.EndpointResult + 216, // 204: openshell.v1.ReportEndpointStatusRequest.observations:type_name -> openshell.v1.EndpointObservation + 8, // 205: openshell.v1.EndpointStatus.last_result:type_name -> openshell.v1.EndpointResult + 104, // 206: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 132, // 207: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 13, // 208: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 15, // 209: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 17, // 210: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 40, // 211: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 48, // 212: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest + 50, // 213: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 51, // 214: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 41, // 215: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest + 42, // 216: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest + 43, // 217: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest + 44, // 218: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest + 52, // 219: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 53, // 220: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 54, // 221: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 55, // 222: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 56, // 223: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 57, // 224: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 64, // 225: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 66, // 226: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 67, // 227: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 68, // 228: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 70, // 229: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 74, // 230: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 76, // 231: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 82, // 232: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 83, // 233: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 90, // 234: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 91, // 235: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 92, // 236: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 97, // 237: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 98, // 238: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 121, // 239: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 123, // 240: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 125, // 241: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 93, // 242: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 110, // 243: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 112, // 244: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 114, // 245: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 116, // 246: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 94, // 247: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 128, // 248: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 254, // 249: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 255, // 250: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 136, // 251: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 145, // 252: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 147, // 253: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 149, // 254: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 217, // 255: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest + 130, // 256: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 134, // 257: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 152, // 258: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 153, // 259: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 156, // 260: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 163, // 261: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 165, // 262: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest + 171, // 263: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 86, // 264: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 180, // 265: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 182, // 266: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 184, // 267: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 186, // 268: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 189, // 269: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 191, // 270: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 193, // 271: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 195, // 272: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 197, // 273: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 9, // 274: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 11, // 275: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 200, // 276: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 202, // 277: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 204, // 278: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 206, // 279: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 209, // 280: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 211, // 281: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 213, // 282: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 14, // 283: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 16, // 284: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 18, // 285: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 58, // 286: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 49, // 287: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse + 58, // 288: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 59, // 289: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 45, // 290: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 45, // 291: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 46, // 292: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse + 47, // 293: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse + 60, // 294: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 61, // 295: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 62, // 296: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 63, // 297: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 58, // 298: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 58, // 299: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 65, // 300: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 73, // 301: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 73, // 302: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 69, // 303: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 71, // 304: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 75, // 305: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 80, // 306: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 82, // 307: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 80, // 308: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 95, // 309: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 95, // 310: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 96, // 311: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 120, // 312: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 119, // 313: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 122, // 314: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 124, // 315: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 126, // 316: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 95, // 317: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 111, // 318: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 113, // 319: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 115, // 320: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 117, // 321: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 127, // 322: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 129, // 323: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 256, // 324: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 257, // 325: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 144, // 326: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 146, // 327: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 148, // 328: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 150, // 329: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 218, // 330: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse + 133, // 331: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 135, // 332: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 155, // 333: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 154, // 334: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 157, // 335: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 164, // 336: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 166, // 337: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse + 171, // 338: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 87, // 339: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 181, // 340: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 183, // 341: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 185, // 342: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 187, // 343: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 190, // 344: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 192, // 345: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 194, // 346: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 196, // 347: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 199, // 348: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 10, // 349: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 12, // 350: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 201, // 351: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 203, // 352: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 205, // 353: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 207, // 354: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 210, // 355: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 212, // 356: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 214, // 357: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 283, // [283:358] is the sub-list for method output_type + 208, // [208:283] is the sub-list for method input_type + 208, // [208:208] is the sub-list for extension type_name + 208, // [208:208] is the sub-list for extension extendee + 0, // [0:208] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -16251,8 +16675,8 @@ func file_openshell_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), - NumEnums: 8, - NumMessages: 228, + NumEnums: 9, + NumMessages: 232, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index d8f3c9100..42587e10e 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -70,6 +70,7 @@ const ( OpenShell_GetSandboxPolicyStatus_FullMethodName = "/openshell.v1.OpenShell/GetSandboxPolicyStatus" OpenShell_ListSandboxPolicies_FullMethodName = "/openshell.v1.OpenShell/ListSandboxPolicies" OpenShell_ReportPolicyStatus_FullMethodName = "/openshell.v1.OpenShell/ReportPolicyStatus" + OpenShell_ReportEndpointStatus_FullMethodName = "/openshell.v1.OpenShell/ReportEndpointStatus" OpenShell_GetSandboxProviderEnvironment_FullMethodName = "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" OpenShell_ExchangeProviderSubjectToken_FullMethodName = "/openshell.v1.OpenShell/ExchangeProviderSubjectToken" OpenShell_GetSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/GetSandboxLogs" @@ -221,6 +222,8 @@ type OpenShellClient interface { ListSandboxPolicies(ctx context.Context, in *ListSandboxPoliciesRequest, opts ...grpc.CallOption) (*ListSandboxPoliciesResponse, error) // Report policy load result (called by sandbox after reload attempt). ReportPolicyStatus(ctx context.Context, in *ReportPolicyStatusRequest, opts ...grpc.CallOption) (*ReportPolicyStatusResponse, error) + // Replace the gateway's observed tool server endpoint status for one sandbox. + ReportEndpointStatus(ctx context.Context, in *ReportEndpointStatusRequest, opts ...grpc.CallOption) (*ReportEndpointStatusResponse, error) // Get provider environment for a sandbox (called by sandbox supervisor at startup). GetSandboxProviderEnvironment(ctx context.Context, in *GetSandboxProviderEnvironmentRequest, opts ...grpc.CallOption) (*GetSandboxProviderEnvironmentResponse, error) // Exchange a stored provider subject token for an intermediate token scoped @@ -801,6 +804,16 @@ func (c *openShellClient) ReportPolicyStatus(ctx context.Context, in *ReportPoli return out, nil } +func (c *openShellClient) ReportEndpointStatus(ctx context.Context, in *ReportEndpointStatusRequest, opts ...grpc.CallOption) (*ReportEndpointStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReportEndpointStatusResponse) + err := c.cc.Invoke(ctx, OpenShell_ReportEndpointStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *openShellClient) GetSandboxProviderEnvironment(ctx context.Context, in *GetSandboxProviderEnvironmentRequest, opts ...grpc.CallOption) (*GetSandboxProviderEnvironmentResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetSandboxProviderEnvironmentResponse) @@ -1211,6 +1224,8 @@ type OpenShellServer interface { ListSandboxPolicies(context.Context, *ListSandboxPoliciesRequest) (*ListSandboxPoliciesResponse, error) // Report policy load result (called by sandbox after reload attempt). ReportPolicyStatus(context.Context, *ReportPolicyStatusRequest) (*ReportPolicyStatusResponse, error) + // Replace the gateway's observed tool server endpoint status for one sandbox. + ReportEndpointStatus(context.Context, *ReportEndpointStatusRequest) (*ReportEndpointStatusResponse, error) // Get provider environment for a sandbox (called by sandbox supervisor at startup). GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) // Exchange a stored provider subject token for an intermediate token scoped @@ -1447,6 +1462,9 @@ func (UnimplementedOpenShellServer) ListSandboxPolicies(context.Context, *ListSa func (UnimplementedOpenShellServer) ReportPolicyStatus(context.Context, *ReportPolicyStatusRequest) (*ReportPolicyStatusResponse, error) { return nil, status.Error(codes.Unimplemented, "method ReportPolicyStatus not implemented") } +func (UnimplementedOpenShellServer) ReportEndpointStatus(context.Context, *ReportEndpointStatusRequest) (*ReportEndpointStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReportEndpointStatus not implemented") +} func (UnimplementedOpenShellServer) GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetSandboxProviderEnvironment not implemented") } @@ -2366,6 +2384,24 @@ func _OpenShell_ReportPolicyStatus_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _OpenShell_ReportEndpointStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReportEndpointStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ReportEndpointStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ReportEndpointStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ReportEndpointStatus(ctx, req.(*ReportEndpointStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OpenShell_GetSandboxProviderEnvironment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetSandboxProviderEnvironmentRequest) if err := dec(in); err != nil { @@ -2995,6 +3031,10 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ MethodName: "ReportPolicyStatus", Handler: _OpenShell_ReportPolicyStatus_Handler, }, + { + MethodName: "ReportEndpointStatus", + Handler: _OpenShell_ReportEndpointStatus_Handler, + }, { MethodName: "GetSandboxProviderEnvironment", Handler: _OpenShell_GetSandboxProviderEnvironment_Handler, diff --git a/sdk/typescript/src/raw.test.ts b/sdk/typescript/src/raw.test.ts new file mode 100644 index 000000000..e8ece7711 --- /dev/null +++ b/sdk/typescript/src/raw.test.ts @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessageInitShape } from '@bufbuild/protobuf'; +import { createRouterTransport } from '@connectrpc/connect'; +import { describe, expect, it } from 'vitest'; +import { SandboxClient } from './index.js'; +import { EndpointResult, type EndpointStatusSchema, OpenShell, SandboxPhase } from './raw.js'; + +describe('raw sandbox endpoint status', () => { + it.each([true, false])( + 'keeps the address and result together through a reset (binary: %s)', + async (useBinaryFormat) => { + const endpoints: MessageInitShape[] = [ + { endpointId: 'endpoint-a', host: 'tools.example.test', ports: [443, 8443], path: '/first' }, + { endpointId: 'endpoint-b', host: 'tools.example.test', ports: [443], path: '/second' }, + ]; + let lastResult = EndpointResult.TRANSPORT_FAILED; + const reportedAt = '2026-09-11T10:00:01Z'; + const readyCondition = { type: 'Ready', status: 'True', reason: 'Ready', message: 'Sandbox is ready' }; + const sandbox = new SandboxClient( + createRouterTransport( + (router) => { + router.service(OpenShell, { + getSandbox: (request) => { + expect(request.name).toBe('tool-sandbox'); + expect(request.workspaceScope?.selection).toEqual({ case: 'workspace', value: 'tool-workspace' }); + return { + sandbox: { + metadata: { id: 'sandbox-id', name: request.name, workspace: 'tool-workspace' }, + status: { + phase: SandboxPhase.READY, + endpointStatuses: endpoints.map((endpoint) => ({ + ...endpoint, + lastResult, + lastReportedAt: lastResult === EndpointResult.NO_OBSERVED_EXCHANGE ? '' : reportedAt, + })), + conditions: [readyCondition], + }, + }, + }; + }, + }); + }, + { transport: { useBinaryFormat } }, + ), + ); + + for (const result of [ + EndpointResult.TRANSPORT_FAILED, + EndpointResult.HTTP_RESPONSE_RECEIVED, + EndpointResult.NO_OBSERVED_EXCHANGE, + ]) { + lastResult = result; + const response = await sandbox.raw.getSandbox({ + name: 'tool-sandbox', + workspaceScope: { selection: { case: 'workspace', value: 'tool-workspace' } }, + }); + const status = response.sandbox?.status; + expect(status?.phase).toBe(SandboxPhase.READY); + expect(status?.conditions).toMatchObject([readyCondition]); + expect(status?.conditions).toHaveLength(1); + expect(status?.endpointStatuses).toHaveLength(2); + expect(status?.endpointStatuses).toMatchObject( + endpoints.map((endpoint) => ({ + ...endpoint, + lastResult: result, + lastReportedAt: result === EndpointResult.NO_OBSERVED_EXCHANGE ? '' : reportedAt, + })), + ); + } + }, + ); +}); diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 7530fc5c7..aa8bd9e4e 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -215,6 +215,7 @@ Common findings: - A workdir rejected as a special filesystem or OpenShell control-path collision cannot be made valid with permissions. Move the image workdir away from kernel-backed mounts and the concrete supervisor, TLS, token, runtime, and socket paths named in the error. - Docker driver cannot initialize because it cannot find `openshell-sandbox`: verify `OPENSHELL_DOCKER_SUPERVISOR_BIN`, the sibling binary next to `openshell-gateway`, or the configured supervisor image contains `/openshell-sandbox`. - Sandbox never registers: check gateway logs and supervisor callback endpoint. +- Calls to an external tool server fail while the sandbox is Ready: inspect `Tool server connections` in `openshell sandbox get `. For configured MCP-over-HTTP endpoints, JSON output exposes each address together with `last_result` and `last_reported_at` in `endpoint_statuses`. Select the endpoint by host, path, and ports, then check the reported failure boundary. `last_reported_at` records gateway acceptance time and can advance when retained evidence is accepted after a reset. Results do not expire or prove current availability; `HttpResponseReceived` can still contain a tool error. If several paths share a host and port, a failure before the path is known remains in logs. Verify the actual operation when current tool availability matters. - On macOS, repeated `Policy fetch failed after 5 attempts` messages with a Homebrew gateway bound to `[::1]:17670` indicate that the Docker `host-gateway` IPv4 route has no matching callback listener. Current releases