diff --git a/.audit/3388-public-policy-proto.tsv b/.audit/3388-public-policy-proto.tsv index 4b2df693a..0a1dd7488 100644 --- a/.audit/3388-public-policy-proto.tsv +++ b/.audit/3388-public-policy-proto.tsv @@ -31,3 +31,16 @@ ts phase decision why evidence result 2026-09-18T05:54:27Z limits Apply the 256 KiB canonical policy limit to every mutation path Create already enforced the limit, but full replacement, global replacement, and incremental merge could persist oversized policies crates/openshell-server/src/grpc/policy.rs; crates/openshell-server/src/grpc/validation.rs all replacement and merge paths reject oversized canonical policies with INVALID_ARGUMENT and a request-specific field path 2026-09-18T05:54:27Z startup Keep an invalid baked image policy fail-closed and repairable through the gateway Silently substituting a fallback would weaken image restrictions, while terminating immediately would prevent an operator repair before first activation crates/openshell-supervisor/src/lib.rs the supervisor reports rejected admission, does not launch, and accepts a later valid gateway policy 2026-09-19T03:55:57Z main-merge Merged current main while preserving public policy types at API boundaries Main changed sandbox reference fields and endpoint enums in the same code, so conflict resolution kept its naming and lifecycle behavior while converting only policy payloads merge origin/main; cargo check targeted crates; storage schema inventory test merge compiles; reviewed schema fingerprints updated and focused test passes +2026-09-19T04:59:15Z contract Reserve public endpoint tag 2 and the removed port name while retaining repeated ports at tag 9 Review builds already exposed the repeated field at tag 9; reserving the removed scalar slot prevents accidental source or wire reuse without preserving a legacy YAML shorthand proto/policy.proto; generated SDK descriptors; API review The authored contract exposes only a nonempty unique ports list and tag 2 cannot be reused +2026-09-19T04:59:15Z validation Use centrally vendored buf.validate annotations with official Go TypeScript and Python runtimes and exact-pinned community Rust reflection validation All generators need one reproducible schema source and the gateway must enforce the same portable rules even though Buf has no official Rust runtime proto/buf/validate/validate.proto; openshell-policy-schema; SDK manifests and tests Portable rule IDs and constraints are validated at each supported language boundary; contextual semantics remain in openshell-policy +2026-09-19T04:59:15Z semantics Treat omitted and empty authored binaries as matching no executable while retaining L7RuleTarget.any_binary only as an incremental-update scope selector Runtime enforcement already denies empty binary scopes and the public complete policy has no arbitrary-binary authority state authored boundary regression test; policy docs; generate-sandbox-policy skill Public conversion and guidance agree that empty binaries grant no access; internal merge containment behavior is not redesigned in this PR +2026-09-19T04:59:16Z persistence Keep PolicyDocument exclusively public and lower it to the existing internal SandboxPolicy before composition or storage The public contract must not expose derived authority and existing durable rows must remain decodable without migration architecture/gateway.md; storage schema inventory fingerprint; gateway conversion tests Public and durable descriptor closures are reviewed independently and stored policy wire data is unchanged +2026-09-19T06:10:37Z e2e-fixture Derive a test-only community image whose embedded policy uses the new public ports list The currently published community image predates this breaking authored contract; accepting its singular port in production would reintroduce the forbidden compatibility shim e2e/docker/Dockerfile.policy-document-community-base; e2e/with-docker-gateway.sh Docker E2E exercises the new contract while the production parser remains strict +2026-09-19T06:10:37Z persistence Decode every durable sandbox row through the private storage adapter StoredSandbox intentionally embeds the internal policy schema and cannot be decoded as the public API Sandbox even where outer tags happen to overlap crates/openshell-server/src/compute/provisioning_deadline.rs; crates/openshell-server/src/storage_proto.rs Provisioning-deadline scans preserve effective policy fields and a regression test rejects raw public decoding +2026-09-19T06:10:37Z sdk Project supervisor policies explicitly at the Go SDK response boundary Internal enum wire values matcher normalization and MCP body-limit storage are not wire-compatible with the authored string and message fields; marshal-unmarshal casting could silently erase policy semantics sdk/go/openshell/v1/internal/converter/policy.go; converter regression tests The curated SDK receives canonical public ports modes matchers and MCP config while runtime authority markers remain internal +2026-09-19T06:17:35Z sdk Project GetSandboxConfig policies onto PolicyDocument in the curated TypeScript client The shared supervisor RPC necessarily transports internal effective policy but the curated public SDK must not return that message or its derived authority markers sdk/typescript/src/client.ts; config projection test TypeScript now matches the Go curated boundary including modes ports MCP tool parameters and body limits +2026-09-19T06:17:35Z strict-yaml Use protobuf descriptors rather than field spelling to identify google.protobuf.Struct null data A structural check for any key named config could misclassify an ordinary policy map entry and let typed null silently become field absence crates/openshell-policy-schema/src/generated.rs; deceptive map-key regression test Null remains valid only below the actual middleware Struct field and is rejected everywhere else in the typed contract +2026-09-19T06:24:06Z python-sdk Ship the generated validation descriptors and constrain Protobuf to the supported 6.x runtime The clean wheel otherwise omitted an explicit verification guard and Protovalidate 1.0 fails against Protobuf 7 descriptor API changes tasks/scripts/verify-python-wheel.py; isolated Python 3.11 wheel install smoke test The wheel contains PolicyDocument and buf.validate modules and validates with its resolver-selected Protobuf 6 runtime +2026-09-19T06:36:44Z follow-up Defer the contradictory internal any-binary merge semantics to issue #3476 Changing merge, advisor, CLI, and runtime authorization together is a separate behavioral decision; this PR only defines the new authored contract and documents its observed deny behavior https://github.com/NVIDIA/OpenShell/issues/3476 The public policy stays fail-closed while the existing internal inconsistency has an explicit cross-component remediation issue +2026-09-19T06:48:30Z validation Validate standalone incremental L7 fragments with the same portable annotations as complete PolicyDocument payloads Incremental update messages reuse public L7 rules and binary selectors but do not contain a complete policy root, so root-only validation would let callers bypass required oneofs and string bounds openshell-policy-schema fragment validators; openshell-policy lowering; server ingress; Go SDK converter tests Direct API fragments now fail the same Protovalidate constraints before contextual lowering and merge validation +2026-09-19T07:14:05Z boundary-hardening Validate complete provider and merged-policy documents before persistence, safely project JavaScript prototype-shaped parameter keys, and canonicalize legacy set-valued storage fields Final independent review found that per-fragment validation missed aggregate limits, TypeScript object inheritance could erase untrusted map keys, and stricter public uniqueness rules could reject status-only rewrites of older equivalent records provider mixed-batch and 1025-rule regressions; TypeScript __proto__/constructor regression; sandbox and provider durable rewrite regressions All write paths now fail before partial persistence, SDK projection retains every own key without prototype mutation, and legacy duplicate ports or matcher alternatives preserve their enforcement meaning diff --git a/.gitignore b/.gitignore index b4e8cd163..c3e89e986 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,8 @@ cython_debug/ # Generated Python protobuf stubs (keep package marker) python/openshell/_proto/* !python/openshell/_proto/__init__.py +python/buf/validate/* +!python/buf/validate/__init__.py # ============================================================================= # IDE / Editor diff --git a/Cargo.lock b/Cargo.lock index aaafadfb0..816f2c887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -870,6 +870,12 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.12.1" @@ -1999,6 +2005,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.12.0" @@ -4461,6 +4478,7 @@ dependencies = [ "miette", "noyalib", "prost", + "prost-protovalidate", "prost-reflect", "prost-types", "protoc-bin-vendored", @@ -5546,6 +5564,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-protovalidate" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd223ccf88bbaf6d15f5b93eefa4c955da02f0f1da58896e146ef8d837539405" +dependencies = [ + "fluent-uri", + "prost", + "prost-protovalidate-types", + "prost-reflect", + "prost-types", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "prost-protovalidate-types" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4406a3b6ef7227ab6d5d264b9ae93fef5264a624954c41db12b0a91b7d098d8f" +dependencies = [ + "prost", + "prost-build", + "prost-reflect", + "prost-reflect-build", + "prost-types", + "thiserror 2.0.18", +] + [[package]] name = "prost-reflect" version = "0.16.5" @@ -5554,11 +5602,33 @@ checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" dependencies = [ "base64", "prost", + "prost-reflect-derive", "prost-types", "serde", "serde-value", ] +[[package]] +name = "prost-reflect-build" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a9e8261adf6617d5dc2a5a9e75cce5ab9d546a007f6f870f809a1ad25386b6" +dependencies = [ + "prost-build", + "prost-reflect", +] + +[[package]] +name = "prost-reflect-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30320eb03b43b7dfcaf9b361f808a4f1adad1e718ad219df1d7e4283e34e73f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-types" version = "0.14.3" @@ -5900,6 +5970,26 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "regex" version = "1.12.3" diff --git a/Cargo.toml b/Cargo.toml index f57b22c39..622765b59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ tonic-prost-build = "0.14" prost = "0.14" prost-types = "0.14" prost-reflect = { version = "0.16.5", features = ["serde"] } +prost-protovalidate = { version = "=0.6.0", default-features = false, features = ["reflect"] } # HTTP server axum = { version = "0.8", features = ["ws"] } diff --git a/architecture/gateway.md b/architecture/gateway.md index 45e934d61..4b7e9c871 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -397,6 +397,16 @@ The storage schema is intentionally narrow: 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. +Authored policy is an explicit conversion boundary. Public RPCs use +`openshell.policy.v1.PolicyDocument`; persistence, composition, and supervisor +configuration use `openshell.sandbox.v1.SandboxPolicy`. The gateway validates +and lowers a `PolicyDocument` before storing the internal message, and projects +the internal message for authored API responses. The shared +`GetSandboxConfig` supervisor RPC is the exception: its wire response carries +the effective internal policy required for enforcement, and curated SDKs +project that field to `PolicyDocument` before exposing it. Existing policy rows +therefore keep their wire format and require no data migration. + `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. Allow and deny append requests carry `L7RuleTarget` to declare the rule, endpoint, and complete affected scope. The removed `host` and `port` fields remain reserved by number and name, and requests without a target are rejected. These mutation requests are not persisted formats. @@ -464,11 +474,12 @@ Stored settings JSON also carries per-key change IDs and commit timestamps, including deletion tombstones. Legacy values acquire stable source identities on read; a subsequent write preserves them. These clocks distinguish effective edits from no-op writes without treating status updates as configuration edits. -With timestamp types, deletion outcomes, and optional mutation request IDs, the -admission contract brings the public closure to 298 messages and 21 enums, the -durable closure to 92 messages and 16 enums, and their overlap to 80 messages -and 16 enums. Mutation request IDs extend public request fields without adding -messages to these closures or changing the durable protobuf schema. +With the authored policy contract, timestamp types, deletion outcomes, and +optional mutation request IDs, the public closure contains 327 messages and 24 +enums, the durable closure contains 92 messages and 19 enums, and their overlap +contains 75 messages and 19 enums. Mutation request IDs extend public request +fields without adding messages to these closures or changing the durable +protobuf schema. | Dual-purpose encoded root | Current decision | |---|---| @@ -479,20 +490,21 @@ messages to these closures or changing the durable protobuf schema. | `WorkspaceMember` | Defer a storage twin; govern its complete dependency closure as durable. | | `SshSession` | Defer a storage twin; govern its complete dependency closure as durable. | | `ServiceEndpoint` | Defer a storage twin; govern its complete dependency closure as durable. | -| `SandboxPolicy` | Defer a storage twin; govern its complete dependency closure as durable. | | `ConfigUpdateOperation` | Persist the common historical outcome within `StoredConfigUpdateOperation`; govern its complete dependency closure as durable. | | `ProviderMutationReceipt` | Persist the immutable provider projection within `StoredConfigUpdateOperation`; govern its complete dependency closure as durable. | | `ProviderReadinessReason` | Persist only the closed snapshot failure category within the operation; govern its enum values as durable. | -The public/storage overlap is deliberate for the current format. Storage twins -for the public roots are deferred: introducing them would require a broad +The public/storage overlap is deliberate for the remaining roots. Storage twins +for those public roots are deferred: introducing them would require a broad conversion boundary, and Prost does not retain unknown fields through a -decode-and-reencode conversion. Each root therefore carries a reviewed decision -to remain dual-purpose, and its complete transitive dependency closure is also -a durable format. Important embedded dependencies include `ObjectMeta`, -`ProviderProfile`, `CredentialHandle`, `SandboxPolicy`, and -`NetworkPolicyRule`. Global and sandbox settings additionally store an encoded -`SandboxPolicy` inside their JSON envelope. +decode-and-reencode conversion. Each root in the table therefore carries a +reviewed decision to remain dual-purpose, and its complete transitive dependency +closure is also a durable format. Important shared dependencies include +`ObjectMeta`, `ProviderProfile`, and `CredentialHandle`. Policy is the exception: +`PolicyDocument` is public-only, while the separately owned internal +`SandboxPolicy` and `NetworkPolicyRule` remain durable. Global and sandbox +settings additionally store an encoded internal `SandboxPolicy` inside their +JSON envelope. Public API compatibility and storage compatibility are reviewed independently: diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 12c2de14d..3c1778a3c 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -42,13 +42,17 @@ profile: unsupported policy fields always invalidate the document. Middleware `c parameter names are open user-data maps rather than schema extensions. The generated `openshell.policy.v1` package is the language-neutral authored -contract used by the public gateway API and raw SDKs. The gateway lowers that -message at ingress into the internal `openshell.sandbox.v1` runtime policy and -projects stored or effective runtime policy back to the public message at -egress. Compute drivers, the supervisor, policy history payloads, and merge -execution continue to use the internal representation. Runtime-derived fields -such as advisor and provider provenance have no public field and their internal -wire numbers are reserved in the authored endpoint message. +contract used by policy inputs and curated SDK policy surfaces. The gateway +lowers that message at ingress into the internal `openshell.sandbox.v1` runtime +policy and projects stored or effective runtime policy back to the public +message at authored API boundaries. Compute drivers, the supervisor, policy +history payloads, and merge execution continue to use the internal +representation. `GetSandboxConfig` is also the authenticated supervisor's +configuration RPC, so its wire response necessarily carries the effective +internal policy; curated SDKs project that field to `PolicyDocument` before +returning it to callers. Runtime-derived fields such as advisor and provider +provenance have no public authored field and their internal wire numbers are +reserved in the authored endpoint message. Existing policy revision rows, sandbox records, and provider-profile records remain internal and retain their current wire encoding. The gateway uses @@ -68,6 +72,24 @@ rules such as endpoint protocol, credential binding, and provider composition depend on more than one message; generated field validation is not an enforcement substitute. +The portable field constraints use the vendored +`proto/buf/validate/validate.proto` schema. Go, Python, and TypeScript execute +those annotations with the official Protovalidate runtimes. Rust uses the +exact-pinned `prost-protovalidate` reflection runtime because Buf does not +publish an official Rust implementation; the dependency remains isolated in +`openshell-policy-schema`, and the policy rules deliberately avoid CEL. Tests +assert the same rule IDs in all four languages so runtime drift is visible. +Cross-field policy semantics still run after portable field validation. + +Typed YAML fields reject explicit `null`; omission expresses absence. Null is +accepted only as user data below middleware `config`, whose protobuf type is +`google.protobuf.Struct`. Public endpoints expose only the non-empty `ports` +list. Field number 2 and the name `port` stay reserved in the public message so +the legacy scalar cannot reappear accidentally. The internal runtime policy +keeps its scalar field for stored-data and rollout compatibility; lowering +promotes a one-item public list into that scalar, and public projection always +emits the effective repeated list. + Before applying Landlock, the supervisor enriches baseline filesystem paths that the runtime needs. Missing baseline paths are skipped so one absent runtime path does not weaken the whole ruleset. When GPU devices are present, GPU baseline diff --git a/buf.yaml b/buf.yaml index 77fde3656..63ddcaddb 100644 --- a/buf.yaml +++ b/buf.yaml @@ -13,6 +13,9 @@ modules: lint: use: - STANDARD + # This file is a vendored upstream dependency, not an OpenShell API. + ignore: + - proto/buf/validate/validate.proto except: # Flat proto/ layout: all files live in one directory with nested # packages (openshell.v1, openshell.sandbox.v1, ...). Adopting these @@ -30,3 +33,5 @@ lint: breaking: use: - FILE + ignore: + - proto/buf/validate/validate.proto diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 139271ed0..196b4a614 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1968,7 +1968,10 @@ enum PolicyCommands { #[arg(long = "rule-name")] rule_name: Option, - /// Explicitly declare that the target rule for L7 appends allows any binary. + /// Select the incremental update API's internal any-binary target state. + /// + /// This is not an authored policy-file state; omitted or empty public + /// binary lists match no executable. #[arg(long, conflicts_with = "binaries")] any_binary: bool, diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index c7b6b9319..184aae1c7 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -43,7 +43,7 @@ use openshell_bootstrap::{ GatewayMetadata, clear_last_sandbox_if_matches, get_gateway_metadata, save_last_sandbox, }; use openshell_core::net::set_tcp_nodelay_best_effort; -use openshell_core::proto::policy::SandboxPolicy as AuthoredSandboxPolicy; +use openshell_core::proto::policy::PolicyDocument; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, BeginRootfsTarStagingRequest, ClearDraftChunksRequest, CreateSandboxRequest, CreateSandboxTemplateRequest, @@ -1476,7 +1476,7 @@ fn merge_rootfs_tar_driver_config( /// Resolution order: `--policy` flag > `OPENSHELL_SANDBOX_POLICY` env var. /// Returns `None` when no policy source is configured, allowing the server /// to apply its own default. -fn load_sandbox_policy(cli_path: Option<&str>) -> Result> { +fn load_sandbox_policy(cli_path: Option<&str>) -> Result> { openshell_policy::load_authored_policy(cli_path) } @@ -5603,9 +5603,9 @@ fn policy_for_view(policy: &SandboxPolicy, view: PolicyGetView) -> Cow<'_, Sandb } fn authored_policy_for_view( - policy: &AuthoredSandboxPolicy, + policy: &PolicyDocument, view: PolicyGetView, -) -> Cow<'_, AuthoredSandboxPolicy> { +) -> Cow<'_, PolicyDocument> { if view != PolicyGetView::Base { return Cow::Borrowed(policy); } @@ -6370,7 +6370,7 @@ mod tests { load_error: load_error.to_string(), created_time: openshell_core::time::timestamp_from_millis(100).ok(), loaded_time: openshell_core::time::timestamp_from_millis(200).ok(), - policy: Some(openshell_core::proto::policy::SandboxPolicy::default()), + policy: Some(openshell_core::proto::policy::PolicyDocument::default()), provenance: std::collections::HashMap::from([( "source".to_string(), "provider-composition".to_string(), diff --git a/crates/openshell-cli/tests/policy_parse_errors_integration.rs b/crates/openshell-cli/tests/policy_parse_errors_integration.rs index 17cc53043..c82ad7bed 100644 --- a/crates/openshell-cli/tests/policy_parse_errors_integration.rs +++ b/crates/openshell-cli/tests/policy_parse_errors_integration.rs @@ -18,7 +18,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp rules: - allow: diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 4a26334c0..949512de6 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -3919,7 +3919,7 @@ discovery: credentials: [api_key] endpoints: - host: api.custom.example - port: 443 + ports: [443] binaries: [/usr/bin/custom] ", ) @@ -4475,7 +4475,7 @@ display_name: Custom YAML category: other endpoints: - host: api.yaml.example - port: 443 + ports: [443] binaries: [/usr/bin/yaml-client] ", ) @@ -4488,7 +4488,7 @@ binaries: [/usr/bin/yaml-client] "description": "", "category": "other", "credentials": [], - "endpoints": [{"host": "api.json.example", "port": 443}], + "endpoints": [{"host": "api.json.example", "ports": [443]}], "binaries": ["/usr/bin/json-client"], "inference_capable": false }"#, @@ -4580,7 +4580,7 @@ display_name: Custom Good category: other endpoints: - host: api.good.example - port: 443 + ports: [443] ", ) .unwrap(); @@ -4612,7 +4612,7 @@ display_name: Custom Good category: other endpoints: - host: api.good.example - port: 443 + ports: [443] ", ) .unwrap(); diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 054dd8ca9..cfe7f821d 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -8,7 +8,7 @@ use openshell_bootstrap::{load_last_sandbox, save_last_sandbox}; use openshell_cli::run; use openshell_cli::tls::TlsOptions; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; -use openshell_core::proto::policy::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; +use openshell_core::proto::policy::{NetworkEndpoint, NetworkPolicyRule, PolicyDocument}; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, @@ -487,7 +487,7 @@ impl OpenShell for TestOpenShell { assert_eq!(req.version, 3); assert!(!req.global); - let policy = SandboxPolicy { + let policy = PolicyDocument { version: 1, network_policies: std::iter::once(( "api".to_string(), @@ -495,7 +495,7 @@ impl OpenShell for TestOpenShell { name: "api".to_string(), endpoints: vec![NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], protocol: "rest".to_string(), enforcement: "enforce".to_string(), access: "read-only".to_string(), diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 9de203fbe..cd1070d29 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -37,7 +37,7 @@ use crate::sandbox_env; use crate::time::{duration_to_std, timestamp_to_millis}; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_extension_core::{BearerTokenSlot, ExtensionCredentialStore}; -use openshell_policy_schema::AuthoredSandboxPolicy; +use openshell_policy_schema::proto::PolicyDocument; use tonic::Status; use tonic::metadata::AsciiMetadataValue; use tonic::service::interceptor::InterceptedService; @@ -965,7 +965,7 @@ async fn fetch_policy_with_client( async fn sync_policy_with_client( client: &mut OpenShellClient, sandbox: &str, - policy: &AuthoredSandboxPolicy, + policy: &PolicyDocument, workspace: &str, ) -> Result<()> { client @@ -989,7 +989,7 @@ async fn sync_policy_with_client( pub async fn discover_and_sync_policy( endpoint: &str, sandbox: &str, - discovered_policy: &AuthoredSandboxPolicy, + discovered_policy: &PolicyDocument, workspace: &str, ) -> Result { debug!( @@ -1019,7 +1019,7 @@ pub async fn discover_and_sync_policy( pub async fn sync_policy( endpoint: &str, sandbox: &str, - policy: &AuthoredSandboxPolicy, + policy: &PolicyDocument, workspace: &str, ) -> Result<()> { debug!(endpoint = %endpoint, sandbox = %sandbox, "Syncing enriched policy to gateway"); @@ -1031,7 +1031,7 @@ pub async fn sync_policy( pub async fn sync_policy_and_fetch_snapshot( endpoint: &str, sandbox: &str, - policy: &AuthoredSandboxPolicy, + policy: &PolicyDocument, workspace: &str, ) -> Result { let mut client = connect(endpoint).await?; diff --git a/crates/openshell-core/src/mcp.rs b/crates/openshell-core/src/mcp.rs index f96b0667d..6deb38e20 100644 --- a/crates/openshell-core/src/mcp.rs +++ b/crates/openshell-core/src/mcp.rs @@ -115,7 +115,7 @@ mod tests { description: "source-owned description".to_string(), endpoints: vec![crate::proto::policy::NetworkEndpoint { host: "mcp.example.com".to_string(), - port: 443, + ports: vec![443], protocol: protocol.to_string(), mcp: options, ..crate::proto::policy::NetworkEndpoint::default() @@ -183,7 +183,7 @@ mod tests { assert_eq!(profile.display_name, original.display_name); assert_eq!(profile.description, original.description); assert_eq!(profile.endpoints[0].host, original.endpoints[0].host); - assert_eq!(profile.endpoints[0].port, original.endpoints[0].port); + assert_eq!(profile.endpoints[0].ports, original.endpoints[0].ports); assert_eq!( profile.endpoints[0].protocol, original.endpoints[0].protocol diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml index e5529eaeb..a474573d9 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml @@ -23,7 +23,7 @@ network_policies: name: test-network-reject endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only diff --git a/crates/openshell-gateway-interceptors/src/profile_source.rs b/crates/openshell-gateway-interceptors/src/profile_source.rs index fc0f3d152..e1d7fc697 100644 --- a/crates/openshell-gateway-interceptors/src/profile_source.rs +++ b/crates/openshell-gateway-interceptors/src/profile_source.rs @@ -130,7 +130,7 @@ mod tests { id: "governed-mcp".to_string(), endpoints: vec![NetworkEndpoint { host: "mcp.example.com".to_string(), - port: 443, + ports: vec![443], protocol: "mcp".to_string(), mcp: versions.map(|versions| McpConfig { versions: versions diff --git a/crates/openshell-policy-schema/Cargo.toml b/crates/openshell-policy-schema/Cargo.toml index e5d6f7eb9..ea9d422c5 100644 --- a/crates/openshell-policy-schema/Cargo.toml +++ b/crates/openshell-policy-schema/Cargo.toml @@ -14,6 +14,7 @@ repository.workspace = true miette = { workspace = true } prost = { workspace = true } prost-reflect = { workspace = true } +prost-protovalidate = { workspace = true } prost-types = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/openshell-policy-schema/src/generated.rs b/crates/openshell-policy-schema/src/generated.rs index 09d81a1a6..a046df980 100644 --- a/crates/openshell-policy-schema/src/generated.rs +++ b/crates/openshell-policy-schema/src/generated.rs @@ -8,7 +8,9 @@ use std::path::Path; use std::sync::OnceLock; use miette::{IntoDiagnostic, Result, WrapErr}; -use prost_reflect::{DescriptorPool, DynamicMessage, SerializeOptions}; +use prost::Message; +use prost_protovalidate::Validator; +use prost_reflect::{DescriptorPool, DynamicMessage, Kind, MessageDescriptor, SerializeOptions}; use prost_types::{ListValue, Struct, Value, value}; use serde::Serialize; @@ -20,9 +22,12 @@ use crate::{ ParameterMatcher, ParseLimits, PolicyDocument, ProcessPolicy, QueryMatcher, }; -const POLICY_MESSAGE_NAME: &str = "openshell.policy.v1.SandboxPolicy"; +const POLICY_MESSAGE_NAME: &str = "openshell.policy.v1.PolicyDocument"; +const L7_RULE_MESSAGE_NAME: &str = "openshell.policy.v1.L7Rule"; +const L7_DENY_RULE_MESSAGE_NAME: &str = "openshell.policy.v1.L7DenyRule"; +const NETWORK_BINARY_MESSAGE_NAME: &str = "openshell.policy.v1.NetworkBinary"; -fn policy_descriptor() -> prost_reflect::MessageDescriptor { +fn policy_descriptor_pool() -> &'static DescriptorPool { static POOL: OnceLock = OnceLock::new(); POOL.get_or_init(|| { DescriptorPool::decode( @@ -30,8 +35,41 @@ fn policy_descriptor() -> prost_reflect::MessageDescriptor { ) .expect("compiled policy descriptor set must decode") }) - .get_message_by_name(POLICY_MESSAGE_NAME) - .expect("compiled descriptor set must contain SandboxPolicy") +} + +fn message_descriptor(full_name: &str) -> MessageDescriptor { + policy_descriptor_pool() + .get_message_by_name(full_name) + .unwrap_or_else(|| panic!("compiled descriptor set must contain {full_name}")) +} + +fn policy_descriptor() -> MessageDescriptor { + message_descriptor(POLICY_MESSAGE_NAME) +} + +fn policy_validator() -> &'static Validator { + static VALIDATOR: OnceLock = OnceLock::new(); + VALIDATOR.get_or_init(Validator::new) +} + +fn dynamic_message(message: &T, full_name: &str) -> Result { + let mut dynamic = DynamicMessage::new(message_descriptor(full_name)); + dynamic + .transcode_from(message) + .into_diagnostic() + .wrap_err_with(|| format!("failed to encode generated {full_name}"))?; + Ok(dynamic) +} + +fn validate_proto_contract(message: &T, full_name: &str, context: &str) -> Result<()> { + let dynamic = dynamic_message(message, full_name)?; + if let Err(error) = policy_validator().validate(&dynamic) { + let summary = error.to_string(); + return Err(error).into_diagnostic().wrap_err_with(|| { + format!("{context} violates its protobuf validation contract: {summary}") + }); + } + Ok(()) } fn validate_proto_json_yaml(value: &serde_yml::Value) -> Result<()> { @@ -66,6 +104,90 @@ fn validate_proto_json_yaml(value: &serde_yml::Value) -> Result<()> { Ok(()) } +fn reject_typed_null() -> Result<()> { + miette::bail!( + "policy YAML null is not allowed for typed protobuf fields; omit the field instead" + ) +} + +fn validate_untyped_nulls(value: &serde_yml::Value) -> Result<()> { + match value { + serde_yml::Value::Null => reject_typed_null(), + serde_yml::Value::Sequence(values) => { + for value in values { + validate_untyped_nulls(value)?; + } + Ok(()) + } + serde_yml::Value::Mapping(entries) => { + for value in entries.values() { + validate_untyped_nulls(value)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn validate_kind_nulls(value: &serde_yml::Value, kind: Kind) -> Result<()> { + if matches!(value, serde_yml::Value::Null) { + return reject_typed_null(); + } + match kind { + // Null is valid JSON data only below a google.protobuf.Struct value. + Kind::Message(message) if message.full_name() == "google.protobuf.Struct" => Ok(()), + Kind::Message(message) => validate_typed_nulls(value, &message), + _ => validate_untyped_nulls(value), + } +} + +fn validate_typed_nulls(value: &serde_yml::Value, message: &MessageDescriptor) -> Result<()> { + let serde_yml::Value::Mapping(entries) = value else { + return validate_untyped_nulls(value); + }; + for (key, value) in entries { + // Mapping keys were already checked against the protobuf JSON model. + let name = key.as_str(); + let Some(field) = message + .get_field_by_name(name) + .or_else(|| message.get_field_by_json_name(name)) + else { + validate_untyped_nulls(value)?; + continue; + }; + if matches!(value, serde_yml::Value::Null) { + return reject_typed_null(); + } + if field.is_map() { + let serde_yml::Value::Mapping(map) = value else { + validate_untyped_nulls(value)?; + continue; + }; + let Kind::Message(entry) = field.kind() else { + unreachable!("protobuf map fields use an entry message") + }; + let value_kind = entry + .get_field_by_name("value") + .expect("protobuf map entry has a value field") + .kind(); + for map_value in map.values() { + validate_kind_nulls(map_value, value_kind.clone())?; + } + } else if field.is_list() { + let serde_yml::Value::Sequence(items) = value else { + validate_untyped_nulls(value)?; + continue; + }; + for item in items { + validate_kind_nulls(item, field.kind())?; + } + } else { + validate_kind_nulls(value, field.kind())?; + } + } + Ok(()) +} + fn validate_plain_mapping_key(source: &str) -> Result<()> { let value: serde_yml::Value = serde_yml::from_str(source) .into_diagnostic() @@ -209,12 +331,13 @@ fn validate_proto_json_mapping_keys(source: &str) -> Result<()> { } /// Parse strict proto-shaped policy YAML into the generated public message. -pub fn parse_policy_proto(source: &str) -> Result { +pub fn parse_policy_proto(source: &str) -> Result { let config = crate::parser_config(ParseLimits::default()); let value: serde_yml::Value = serde_yml::from_str_with_config(source, &config) .into_diagnostic() .wrap_err("failed to parse sandbox policy YAML")?; validate_proto_json_mapping_keys(source)?; + validate_typed_nulls(&value, &policy_descriptor())?; validate_proto_json_yaml(&value)?; let json_value = serde_json::to_value(value) .into_diagnostic() @@ -223,7 +346,7 @@ pub fn parse_policy_proto(source: &str) -> Result { .into_diagnostic() .wrap_err("failed to decode proto-shaped sandbox policy YAML")?; let policy = dynamic - .transcode_to::() + .transcode_to::() .into_diagnostic() .wrap_err("failed to decode generated sandbox policy")?; validate_authored_policy(&policy)?; @@ -231,7 +354,7 @@ pub fn parse_policy_proto(source: &str) -> Result { } /// Parse a strict proto-shaped policy YAML file into the generated message. -pub fn parse_policy_proto_file(path: &Path, limits: ParseLimits) -> Result { +pub fn parse_policy_proto_file(path: &Path, limits: ParseLimits) -> Result { let metadata = path .metadata() .into_diagnostic() @@ -268,6 +391,7 @@ pub fn parse_policy_proto_file(path: &Path, limits: ParseLimits) -> Result Result() + .transcode_to::() .into_diagnostic() .wrap_err("failed to decode generated sandbox policy")?; validate_authored_policy(&policy)?; @@ -284,11 +408,27 @@ pub fn parse_policy_proto_file(path: &Path, limits: ParseLimits) -> Result Result<()> { +pub fn validate_authored_policy(policy: &proto::PolicyDocument) -> Result<()> { + validate_proto_contract(policy, POLICY_MESSAGE_NAME, "public policy")?; let document = PolicyDocument::try_from(policy.clone())?; crate::validate_policy(&document) } +/// Validate a standalone public allow rule used by incremental policy APIs. +pub fn validate_authored_l7_rule(rule: &proto::L7Rule) -> Result<()> { + validate_proto_contract(rule, L7_RULE_MESSAGE_NAME, "public L7 allow rule") +} + +/// Validate a standalone public deny rule used by incremental policy APIs. +pub fn validate_authored_l7_deny_rule(rule: &proto::L7DenyRule) -> Result<()> { + validate_proto_contract(rule, L7_DENY_RULE_MESSAGE_NAME, "public L7 deny rule") +} + +/// Validate a standalone public binary selector used by incremental policy APIs. +pub fn validate_authored_network_binary(binary: &proto::NetworkBinary) -> Result<()> { + validate_proto_contract(binary, NETWORK_BINARY_MESSAGE_NAME, "public network binary") +} + struct ProtoYaml<'a> { message: &'a DynamicMessage, } @@ -305,17 +445,12 @@ impl Serialize for ProtoYaml<'_> { } } -fn dynamic_policy(policy: &proto::SandboxPolicy) -> Result { - let mut dynamic = DynamicMessage::new(policy_descriptor()); - dynamic - .transcode_from(policy) - .into_diagnostic() - .wrap_err("failed to encode generated sandbox policy")?; - Ok(dynamic) +fn dynamic_policy(policy: &proto::PolicyDocument) -> Result { + dynamic_message(policy, POLICY_MESSAGE_NAME) } /// Serialize a generated public policy using canonical proto-shaped YAML. -pub fn serialize_policy_proto(policy: &proto::SandboxPolicy) -> Result { +pub fn serialize_policy_proto(policy: &proto::PolicyDocument) -> Result { validate_authored_policy(policy)?; let dynamic = dynamic_policy(policy)?; serde_yml::to_string(&ProtoYaml { message: &dynamic }) @@ -324,7 +459,7 @@ pub fn serialize_policy_proto(policy: &proto::SandboxPolicy) -> Result { } /// Convert a generated public policy to canonical proto-shaped JSON. -pub fn policy_proto_to_json_value(policy: &proto::SandboxPolicy) -> Result { +pub fn policy_proto_to_json_value(policy: &proto::PolicyDocument) -> Result { validate_authored_policy(policy)?; let dynamic = dynamic_policy(policy)?; serde_json::to_value(ProtoYaml { message: &dynamic }) @@ -332,7 +467,7 @@ pub fn policy_proto_to_json_value(policy: &proto::SandboxPolicy) -> Result for proto::SandboxPolicy { +impl TryFrom for proto::PolicyDocument { type Error = miette::Report; fn try_from(document: PolicyDocument) -> Result { @@ -356,10 +491,10 @@ impl TryFrom for proto::SandboxPolicy { } } -impl TryFrom for PolicyDocument { +impl TryFrom for PolicyDocument { type Error = miette::Report; - fn try_from(policy: proto::SandboxPolicy) -> Result { + fn try_from(policy: proto::PolicyDocument) -> Result { let document = Self { version: policy.version, filesystem_policy: policy.filesystem_policy.map(Into::into), @@ -485,7 +620,6 @@ impl TryFrom for proto::NetworkEndpoint { Ok(Self { host: endpoint.host, path: endpoint.path, - port: u32::from(endpoint.port), ports: endpoint.ports.into_iter().map(u32::from).collect(), protocol: endpoint.protocol, tls: endpoint.tls, @@ -530,9 +664,6 @@ impl TryFrom for NetworkEndpoint { Ok(Self { host: endpoint.host, path: endpoint.path, - port: u16::try_from(endpoint.port) - .into_diagnostic() - .wrap_err("endpoint.port must be in 0..=65535")?, ports: endpoint .ports .into_iter() @@ -1008,7 +1139,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ["2025-11-25"] @@ -1052,7 +1183,7 @@ network_middlewares: #[test] fn generated_policy_uses_protobuf_empty_list_semantics_for_mcp_versions() { let absent = parse_policy_proto( - "version: 1\nnetwork_policies:\n mcp:\n endpoints:\n - { host: x, port: 443, protocol: mcp, mcp: {} }\n", + "version: 1\nnetwork_policies:\n mcp:\n endpoints:\n - { host: x, ports: [443], protocol: mcp, mcp: {} }\n", ) .unwrap(); assert!( @@ -1096,7 +1227,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp rules: - allow: @@ -1117,7 +1248,7 @@ network_policies: api: endpoints: - host: api.example.com - port: 443 + ports: [443] rules: - allow: query: @@ -1132,7 +1263,7 @@ network_policies: api: endpoints: - host: api.example.com - port: 443 + ports: [443] rules: - allow: query: @@ -1148,7 +1279,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp rules: - allow: @@ -1165,7 +1296,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp rules: - allow: @@ -1208,7 +1339,7 @@ network_policies: api: endpoints: - host: api.example.com - port: 443 + ports: [443] rules: - allow: query: @@ -1223,7 +1354,7 @@ network_policies: api: endpoints: - host: api.example.com - port: 443 + ports: [443] rules: - allow: tool: @@ -1240,7 +1371,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp rules: - allow: @@ -1260,8 +1391,89 @@ network_policies: } #[test] - fn generated_policy_follows_protobuf_null_semantics() { - let policy = parse_policy_proto( + fn generated_policy_reports_stable_protovalidate_rule_ids() { + let cases = [ + ("version", "version: 0\n", "uint32.const"), + ( + "missing ports", + "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: api.example.com }\n", + "repeated.min_items", + ), + ( + "duplicate ports", + "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: api.example.com, ports: [443, 443] }\n", + "repeated.unique", + ), + ( + "port range", + "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: api.example.com, ports: [65536] }\n", + "uint32.gte_lte", + ), + ( + "binary path", + "version: 1\nnetwork_policies:\n api:\n binaries:\n - { path: \"\" }\n", + "string.min_len", + ), + ( + "matcher choice", + "version: 1\nnetwork_policies:\n api:\n endpoints:\n - host: api.example.com\n ports: [443]\n rules:\n - allow:\n query:\n owner: {}\n", + "required", + ), + ]; + + for (name, source, rule_id) in cases { + let value: serde_yml::Value = serde_yml::from_str(source).expect(name); + let dynamic = DynamicMessage::deserialize( + policy_descriptor(), + serde_json::to_value(value).expect(name), + ) + .expect(name); + let error = policy_validator().validate(&dynamic).expect_err(name); + let prost_protovalidate::Error::Validation(error) = error else { + panic!("{name} returned a validator runtime error: {error}"); + }; + assert!( + error + .violations() + .iter() + .any(|violation| violation.rule_id() == rule_id), + "{name} did not report {rule_id}: {error}" + ); + } + } + + #[test] + fn standalone_incremental_fragments_run_the_same_portable_rules() { + let missing_allow = proto::L7Rule::default(); + let error = validate_authored_l7_rule(&missing_allow).expect_err("allow is required"); + assert!( + error.to_string().contains("required"), + "unexpected validation error: {error:?}" + ); + + let missing_matcher = proto::L7DenyRule { + query: HashMap::from([("owner".to_string(), proto::Matcher::default())]), + ..Default::default() + }; + let error = validate_authored_l7_deny_rule(&missing_matcher) + .expect_err("matcher choice is required"); + assert!( + error.to_string().contains("required"), + "unexpected validation error: {error:?}" + ); + + let empty_binary = proto::NetworkBinary::default(); + let error = + validate_authored_network_binary(&empty_binary).expect_err("binary path is required"); + assert!( + error.to_string().contains("at least 1 characters"), + "unexpected validation error: {error:?}" + ); + } + + #[test] + fn generated_policy_rejects_typed_nulls_but_preserves_struct_null_data() { + let typed_null = parse_policy_proto( r" version: 1 filesystem_policy: null @@ -1269,7 +1481,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: null @@ -1279,16 +1491,36 @@ network_policies: tool: null ", ) - .expect("protobuf JSON null must leave fields unset"); + .expect_err("typed nulls must not silently become absent fields"); + assert!(typed_null.to_string().contains("null is not allowed")); - assert!(policy.filesystem_policy.is_none()); - let endpoint = &policy.network_policies["mcp"].endpoints[0]; - assert!(endpoint.mcp.as_ref().unwrap().versions.is_empty()); - assert!(endpoint.rules[0].allow.as_ref().unwrap().tool.is_none()); + let missing_version = + parse_policy_proto("version:\n").expect_err("a null scalar field must be rejected"); + assert!(missing_version.to_string().contains("null is not allowed")); - let missing_version = parse_policy_proto("version:\n") - .expect_err("null version must become the unsupported scalar default"); - assert!(missing_version.to_string().contains("expected version 1")); + let deceptive_map_key = parse_policy_proto( + "version: 1\nnetwork_middlewares:\n config:\n endpoints: null\n", + ) + .expect_err("a map key named config must not turn its typed value into Struct data"); + assert!( + deceptive_map_key + .to_string() + .contains("null is not allowed") + ); + + let struct_null = parse_policy_proto( + "version: 1\nnetwork_middlewares:\n audit:\n config:\n optional: null\n", + ) + .expect("null remains valid data inside google.protobuf.Struct"); + assert!( + struct_null.network_middlewares["audit"] + .config + .as_ref() + .unwrap() + .fields["optional"] + .kind + .is_some() + ); } #[test] @@ -1300,12 +1532,12 @@ network_policies: api: endpoints: - host: api.example.com - port: "443" + ports: ["443"] "#, ) .expect("protobuf JSON permits quoted integer scalars"); assert_eq!(policy.version, 1); - assert_eq!(policy.network_policies["api"].endpoints[0].port, 443); + assert_eq!(policy.network_policies["api"].endpoints[0].ports, [443]); assert_eq!( parse_policy_proto("version: 1.0\n") .expect("an exact integral JSON number must parse") @@ -1361,7 +1593,7 @@ network_policies: api: endpoints: - host: api.example.com - port: 443 + ports: [443] advisor_proposed: true ", ) diff --git a/crates/openshell-policy-schema/src/lib.rs b/crates/openshell-policy-schema/src/lib.rs index 683000129..f19402fce 100644 --- a/crates/openshell-policy-schema/src/lib.rs +++ b/crates/openshell-policy-schema/src/lib.rs @@ -26,9 +26,9 @@ mod generated; pub use generated::{ parse_policy_proto, parse_policy_proto_file, policy_proto_to_json_value, - serialize_policy_proto, validate_authored_policy, + serialize_policy_proto, validate_authored_l7_deny_rule, validate_authored_l7_rule, + validate_authored_network_binary, validate_authored_policy, }; -pub use proto::SandboxPolicy as AuthoredSandboxPolicy; use std::collections::BTreeMap; use std::fmt; @@ -241,11 +241,7 @@ pub struct NetworkEndpoint { pub host: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub path: String, - /// Single port (backwards compat). Mutually exclusive with `ports`. - /// Uses `u16` to reject invalid values >65535 at parse time. - #[serde(default, skip_serializing_if = "is_zero")] - pub port: u16, - /// Multiple ports. When non-empty, this endpoint covers all listed ports. + /// Destination ports. Every authored endpoint has at least one unique port. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ports: Vec, #[serde(default, skip_serializing_if = "String::is_empty")] @@ -480,12 +476,6 @@ pub struct MiddlewareEndpointSelector { pub exclude: Vec, } -// Signature dictated by serde's `skip_serializing_if`. -#[allow(clippy::trivially_copy_pass_by_ref)] -fn is_zero(value: &u16) -> bool { - *value == 0 -} - // Signature dictated by serde's `skip_serializing_if`. #[allow(clippy::trivially_copy_pass_by_ref)] fn is_zero_u32(value: &u32) -> bool { @@ -751,7 +741,6 @@ fn inspect_endpoint(value: &serde_yml::Value, path: &str) -> InspectionResult { &[ "host", "path", - "port", "ports", "protocol", "tls", @@ -1043,14 +1032,10 @@ impl NetworkPolicyRule { } impl NetworkEndpoint { - /// Effective authored ports. A non-empty `ports` list takes precedence. + /// Effective authored ports. #[must_use] pub fn effective_ports(&self) -> Vec { - if self.ports.is_empty() { - (self.port != 0).then_some(self.port).into_iter().collect() - } else { - self.ports.clone() - } + self.ports.clone() } /// Whether this endpoint is uninspected L4 traffic. @@ -1153,7 +1138,7 @@ mod tests { #[test] fn rejects_oversized_port() { assert!(parse_policy( - "version: 1\nnetwork_policies:\n x:\n endpoints:\n - host: x\n port: 65536\n", + "version: 1\nnetwork_policies:\n x:\n endpoints:\n - host: x\n ports: [65536]\n", ) .is_err()); } @@ -1164,7 +1149,7 @@ mod tests { "version: 1\nfilesystem_policy: null\n", "version: 1\nprocess: null\n", "version: 1\nmetadata: null\n", - "version: 1\nnetwork_policies:\n x:\n endpoints:\n - host: x\n port: 443\n mcp: null\n", + "version: 1\nnetwork_policies:\n x:\n endpoints:\n - host: x\n ports: [443]\n mcp: null\n", ] { assert!( parse_policy(source).is_err(), @@ -1204,7 +1189,7 @@ mod tests { "network_policies.api.future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, future: true }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], future: true }] } }\n", "network_policies.api.endpoints[0].future", ), ( @@ -1220,39 +1205,39 @@ mod tests { "network_middlewares.audit.endpoints.future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, credential_binding: { provider: p, future: true } }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], credential_binding: { provider: p, future: true } }] } }\n", "network_policies.api.endpoints[0].credential_binding.future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, json_rpc: { future: true } }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], json_rpc: { future: true } }] } }\n", "network_policies.api.endpoints[0].json_rpc.future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, protocol: mcp, mcp: { future: true } }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], protocol: mcp, mcp: { future: true } }] } }\n", "network_policies.api.endpoints[0].mcp.future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, graphql_persisted_queries: { op: { future: true } } }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], graphql_persisted_queries: { op: { future: true } } }] } }\n", "network_policies.api.endpoints[0].graphql_persisted_queries.op.future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, rules: [{ future: true, allow: {} }] }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], rules: [{ future: true, allow: {} }] }] } }\n", "network_policies.api.endpoints[0].rules[0].future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, rules: [{ allow: { future: true } }] }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], rules: [{ allow: { future: true } }] }] } }\n", "network_policies.api.endpoints[0].rules[0].allow.future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, deny_rules: [{ future: true }] }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], deny_rules: [{ future: true }] }] } }\n", "network_policies.api.endpoints[0].deny_rules[0].future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, rules: [{ allow: { query: { q: { any: [one], future: true } } } }] }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], rules: [{ allow: { query: { q: { any: [one], future: true } } } }] }] } }\n", "network_policies.api.endpoints[0].rules[0].allow.query.q.future", ), ( - "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, port: 443, rules: [{ allow: { tool: { any: [one], future: true } } }] }] } }\n", + "version: 1\nnetwork_policies: { api: { endpoints: [{ host: example.com, ports: [443], rules: [{ allow: { tool: { any: [one], future: true } } }] }] } }\n", "network_policies.api.endpoints[0].rules[0].allow.tool.future", ), ]; @@ -1279,7 +1264,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {} rules: @@ -1303,7 +1288,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {} rules: @@ -1363,7 +1348,7 @@ network_policies: api: endpoints: - host: example.com - port: 443 + ports: [443] review: { required: true, reason: human approval } rules: - allow: diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 45c12010a..9346c2fdb 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -159,10 +159,15 @@ fn matcher_def_to_proto(matcher: QueryMatcherDef) -> L7QueryMatcher { } } -fn matcher_proto_to_def(matcher: L7QueryMatcher) -> QueryMatcherDef { +fn matcher_proto_to_def(mut matcher: L7QueryMatcher) -> QueryMatcherDef { if matcher.any.is_empty() { QueryMatcherDef::Glob(matcher.glob) } else { + // The runtime historically treated alternatives as a set without + // requiring unique wire values. Preserve that meaning when projecting + // old records into the stricter public authored contract. + let mut seen = BTreeSet::new(); + matcher.any.retain(|value| seen.insert(value.clone())); QueryMatcherDef::Any(QueryAnyDef { any: matcher.any }) } } @@ -522,15 +527,8 @@ fn to_proto(raw: PolicyFile) -> Result { let protocol = e.protocol; let allow_rules = e.rules; let deny_rules = e.deny_rules; - // Normalize port/ports: ports takes precedence, else - // single port is promoted to ports array. - let normalized_ports: Vec = if !e.ports.is_empty() { - e.ports.into_iter().map(u32::from).collect() - } else if e.port > 0 { - vec![u32::from(e.port)] - } else { - vec![] - }; + let normalized_ports: Vec = + e.ports.into_iter().map(u32::from).collect(); NetworkEndpoint { host: e.host, path: e.path, @@ -676,10 +674,9 @@ fn from_proto(policy: &SandboxPolicy) -> Result { .endpoints .iter() .map(|e| -> Result<_> { - // Use compact form: if ports has exactly 1 element, - // emit port (scalar). If >1, emit ports (array). - // Proto uses u32; authored ports are u16. Reject an - // invalid protobuf value instead of silently clamping. + // The public contract always emits a ports list. The + // internal message may still carry an older scalar + // port, so projection promotes it before validation. let checked = |value: u32| { u16::try_from(value).map_err(|_| { miette::miette!( @@ -689,17 +686,14 @@ fn from_proto(policy: &SandboxPolicy) -> Result { ) }) }; - let (port, ports) = if e.ports.len() > 1 { - ( - 0, - e.ports - .iter() - .copied() - .map(checked) - .collect::>>()?, - ) + let ports = if e.ports.is_empty() { + vec![checked(e.port)?] } else { - (checked(e.ports.first().copied().unwrap_or(e.port))?, vec![]) + e.ports + .iter() + .copied() + .map(checked) + .collect::>>()? }; let protocol = e.protocol.clone(); let mcp_allow_all_known_mcp_methods = !is_mcp_protocol(&protocol) @@ -736,7 +730,6 @@ fn from_proto(policy: &SandboxPolicy) -> Result { Ok(NetworkEndpointDef { host: e.host.clone(), path: e.path.clone(), - port, ports, protocol, tls: network_tls_mode_to_str(e.tls) @@ -868,7 +861,8 @@ pub fn is_valid_sandbox_identity(value: &str) -> bool { /// /// Runtime authority fields do not exist in the public message. Lowering /// therefore always produces a base policy with those fields cleared. -pub fn lower_authored_policy(policy: authored::SandboxPolicy) -> Result { +pub fn lower_authored_policy(policy: authored::PolicyDocument) -> Result { + openshell_policy_schema::validate_authored_policy(&policy)?; let document = PolicyFile::try_from(policy)?; to_proto(document) } @@ -876,19 +870,33 @@ pub fn lower_authored_policy(policy: authored::SandboxPolicy) -> Result Result { +pub fn project_base_policy(policy: &SandboxPolicy) -> Result { validate_proto_version_for_authored_serialization(policy)?; validate_policy_enum_values(policy)?; - let canonical = validate_and_canonicalize_mcp_policy_schema(policy.clone()) + let mut canonical = validate_and_canonicalize_mcp_policy_schema(policy.clone()) .map_err(|error| miette::miette!("cannot project invalid sandbox policy: {error}"))?; - authored::SandboxPolicy::try_from(from_proto(&canonical)?) + deduplicate_projected_endpoint_ports(&mut canonical); + authored::PolicyDocument::try_from(from_proto(&canonical)?) +} + +/// Preserve the historical internal port-list set semantics at the public +/// projection boundary. Older durable records could contain duplicate ports; +/// retaining the first occurrence keeps enforcement unchanged while producing +/// a document accepted by the stricter authored protobuf contract. +fn deduplicate_projected_endpoint_ports(policy: &mut SandboxPolicy) { + for rule in policy.network_policies.values_mut() { + for endpoint in &mut rule.endpoints { + let mut seen = BTreeSet::new(); + endpoint.ports.retain(|port| seen.insert(*port)); + } + } } /// Project an internal effective policy into a read-only public view. /// /// Provider-derived rules may remain visible, but internal authority markers /// are omitted. Callers must not treat this view as an authorable base policy. -pub fn project_effective_policy(policy: &SandboxPolicy) -> Result { +pub fn project_effective_policy(policy: &SandboxPolicy) -> Result { project_base_policy(policy) } @@ -897,7 +905,7 @@ pub fn lower_authored_rule( rule_name: &str, rule: authored::NetworkPolicyRule, ) -> Result { - let policy = authored::SandboxPolicy { + let policy = authored::PolicyDocument { version: 1, filesystem_policy: None, landlock: None, @@ -929,6 +937,7 @@ pub fn project_authored_rule( /// Lower one public L7 allow rule for an incremental merge operation. pub fn lower_authored_l7_rule(rule: authored::L7Rule) -> Result { + openshell_policy_schema::validate_authored_l7_rule(&rule)?; let definition = L7RuleDef::try_from(rule)?; Ok(L7Rule { allow: Some(allow_def_to_proto("", definition.allow)), @@ -937,9 +946,15 @@ pub fn lower_authored_l7_rule(rule: authored::L7Rule) -> Result { /// Lower one public L7 deny rule for an incremental merge operation. pub fn lower_authored_l7_deny_rule(rule: authored::L7DenyRule) -> Result { + openshell_policy_schema::validate_authored_l7_deny_rule(&rule)?; Ok(deny_def_to_proto("", L7DenyRuleDef::try_from(rule)?)) } +/// Validate one public binary selector used by an incremental merge target. +pub fn validate_authored_network_binary(binary: &authored::NetworkBinary) -> Result<()> { + openshell_policy_schema::validate_authored_network_binary(binary) +} + /// Project one normalized L7 allow rule for an incremental merge operation. pub fn project_authored_l7_rule(rule: &L7Rule) -> Result { let allow = rule @@ -958,12 +973,12 @@ pub fn project_authored_l7_deny_rule(rule: &L7DenyRule) -> Result Result { +pub fn parse_authored_policy(yaml: &str) -> Result { openshell_policy_schema::parse_policy_proto(yaml) } /// Parse a compatible policy YAML file into the public generated message. -pub fn parse_authored_policy_file(path: &Path) -> Result { +pub fn parse_authored_policy_file(path: &Path) -> Result { openshell_policy_schema::parse_policy_proto_file( path, openshell_policy_schema::ParseLimits::default(), @@ -971,13 +986,13 @@ pub fn parse_authored_policy_file(path: &Path) -> Result Result { +pub fn serialize_authored_policy(policy: &authored::PolicyDocument) -> Result { openshell_policy_schema::serialize_policy_proto(policy) } /// Convert a public generated policy to canonical authored JSON. pub fn authored_policy_to_json_value( - policy: &authored::SandboxPolicy, + policy: &authored::PolicyDocument, ) -> Result { openshell_policy_schema::policy_proto_to_json_value(policy) } @@ -1068,7 +1083,7 @@ pub fn load_sandbox_policy(cli_path: Option<&str>) -> Result) -> Result> { +pub fn load_authored_policy(cli_path: Option<&str>) -> Result> { let policy = if let Some(path) = cli_path { parse_authored_policy_file(Path::new(path))? } else if let Ok(policy_path) = std::env::var("OPENSHELL_SANDBOX_POLICY") { @@ -2121,7 +2136,7 @@ network_policies: github: endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: https binaries: - path: /usr/bin/curl @@ -2145,7 +2160,7 @@ network_policies: name: internal endpoints: - host: db.internal.corp - port: 5432 + ports: [5432] allowed_ips: - "10.0.5.0/24" - "10.0.6.0/24" @@ -2172,7 +2187,7 @@ network_policies: name: my-custom-api-name endpoints: - host: api.example.com - port: 443 + ports: [443] binaries: - path: /usr/bin/curl "; @@ -2208,7 +2223,7 @@ network_policies: name: api endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest binaries: - path: /usr/bin/curl @@ -2329,7 +2344,7 @@ network_policies: github_api: endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest tls: skp enforcement: enforc @@ -2464,7 +2479,7 @@ network_policies: test: name: test_policy endpoints: - - { host: example.com, port: 443 } + - { host: example.com, ports: [443] } binaries: - { path: /usr/bin/curl } "; @@ -2488,7 +2503,7 @@ network_policies: name: query_test endpoints: - host: api.example.com - port: 8080 + ports: [8080] protocol: rest rules: - allow: @@ -2539,7 +2554,7 @@ network_policies: middleware: [redact] endpoints: - host: api.example.com - port: 443 + ports: [443] "; assert!(parse_sandbox_policy(policy_attachment).is_err()); @@ -2549,7 +2564,7 @@ network_policies: api: endpoints: - host: api.example.com - port: 443 + ports: [443] middleware: [redact] "; assert!(parse_sandbox_policy(endpoint_attachment).is_err()); @@ -2676,7 +2691,7 @@ network_policies: fn mcp_version_endpoint_yaml(protocol: &str, mcp_body: Option<&str>) -> String { let mcp = mcp_body.map_or_else(String::new, |body| format!(" mcp:\n{body}")); format!( - "version: 1\nnetwork_policies:\n versioned:\n endpoints:\n - host: mcp.example.com\n port: 443\n protocol: {protocol}\n{mcp}" + "version: 1\nnetwork_policies:\n versioned:\n endpoints:\n - host: mcp.example.com\n ports: [443]\n protocol: {protocol}\n{mcp}" ) } @@ -2790,35 +2805,35 @@ network_policies: assert!(parse_sandbox_policy(&yaml).is_err(), "{case} must fail"); } - for body in [ - Some(" versions: null\n"), + let policy = parse_sandbox_policy(&mcp_version_endpoint_yaml( + "mcp", Some(" versions: []\n"), - ] { - let policy = parse_sandbox_policy(&mcp_version_endpoint_yaml("mcp", body)) - .expect("null and empty repeated fields use protobuf omission semantics"); - assert_eq!( - policy.network_policies["versioned"].endpoints[0] - .mcp - .as_ref() - .expect("MCP defaults") - .versions, - default_mcp_versions() - ); - } - - let mut null_mcp = mcp_version_endpoint_yaml("mcp", None); - null_mcp.push_str(" mcp: null\n"); + )) + .expect("an empty repeated field uses protobuf omission semantics"); assert_eq!( - parse_sandbox_policy(&null_mcp) - .expect("null message uses protobuf omission semantics") - .network_policies["versioned"] - .endpoints[0] + policy.network_policies["versioned"].endpoints[0] .mcp .as_ref() .expect("MCP defaults") .versions, default_mcp_versions() ); + + assert!( + parse_sandbox_policy(&mcp_version_endpoint_yaml( + "mcp", + Some(" versions: null\n"), + )) + .is_err(), + "typed nulls must not collapse to protobuf omission" + ); + + let mut null_mcp = mcp_version_endpoint_yaml("mcp", None); + null_mcp.push_str(" mcp: null\n"); + assert!( + parse_sandbox_policy(&null_mcp).is_err(), + "typed null messages must not collapse to protobuf omission" + ); } #[test] @@ -3620,8 +3635,81 @@ network_policies: } #[test] - fn validate_rejects_yaml_tcp_endpoint_without_host_or_port() { - let policy = parse_sandbox_policy( + fn authored_projection_deduplicates_legacy_internal_ports() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + port: 443, + ports: vec![443, 443, 8443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let authored = project_base_policy(&policy).expect("legacy policy should project"); + assert_eq!( + authored.network_policies["api"].endpoints[0].ports, + [443, 8443] + ); + let lowered = lower_authored_policy(authored).expect("projected policy should lower"); + assert_eq!( + lowered.network_policies["api"].endpoints[0].ports, + [443, 8443] + ); + } + + #[test] + fn authored_projection_deduplicates_legacy_matcher_alternatives() { + let mut policy = parse_sandbox_policy( + r" +version: 1 +network_policies: + mcp: + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + mcp: {} + rules: + - allow: + method: tools/call + tool: + any: + values: [read_status] +", + ) + .unwrap(); + let allow = policy.network_policies.get_mut("mcp").unwrap().endpoints[0].rules[0] + .allow + .as_mut() + .unwrap(); + allow.params.get_mut("name").unwrap().any = vec!["read_status".into(); 2]; + allow.query.insert( + "state".to_string(), + L7QueryMatcher { + any: vec!["open".into(), "open".into()], + ..Default::default() + }, + ); + + let authored = project_base_policy(&policy).expect("legacy matchers should project"); + let lowered = lower_authored_policy(authored).expect("projected matchers should lower"); + let allow = lowered.network_policies["mcp"].endpoints[0].rules[0] + .allow + .as_ref() + .unwrap(); + assert_eq!(allow.params["name"].any, ["read_status"]); + assert_eq!(allow.query["state"].any, ["open"]); + } + + #[test] + fn validate_rejects_yaml_tcp_endpoint_without_host_or_ports() { + let error = parse_sandbox_policy( r#" version: 1 network_policies: @@ -3631,9 +3719,23 @@ network_policies: protocol: tcp "#, ) - .expect("policy syntax should parse before semantic validation"); + .expect_err("the portable ports constraint must reject the endpoint first"); + assert!(format!("{error:?}").contains("ports")); - let violations = validate_sandbox_policy(&policy).expect_err("endpoint is incomplete"); + let policy = parse_sandbox_policy( + r#" +version: 1 +network_policies: + invalid: + endpoints: + - host: "" + ports: [443] + protocol: tcp +"#, + ) + .expect("portable validation should accept the structurally complete endpoint"); + + let violations = validate_sandbox_policy(&policy).expect_err("the host is incomplete"); assert!(violations.iter().any(|violation| matches!( violation, PolicyViolation::MissingTcpEndpointHost { policy_name } if policy_name == "invalid" @@ -3643,10 +3745,6 @@ network_policies: "protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy", ) })); - assert!(violations.iter().any(|violation| matches!( - violation, - PolicyViolation::MissingEndpointPort { policy_name, .. } if policy_name == "invalid" - ))); } #[test] @@ -3657,7 +3755,7 @@ version: 1 network_policies: legacy-proxy: endpoints: - - port: 9443 + - ports: [9443] allowed_ips: - 10.0.5.0/24 ", @@ -3676,7 +3774,7 @@ version: 1 network_policies: native-tcp: endpoints: - - port: 6379 + - ports: [6379] protocol: tcp allowed_ips: - 10.0.5.0/24 @@ -4423,7 +4521,7 @@ network_policies: test: name: test endpoints: - - { host: api.example.com, port: 443 } + - { host: api.example.com, ports: [443] } binaries: - { path: /usr/bin/curl } "; @@ -4442,7 +4540,7 @@ network_policies: name: test endpoints: - host: api.example.com - port: 443 + ports: [443] path: "/graphql" protocol: graphql rules: @@ -4469,7 +4567,7 @@ network_policies: gcp_storage: endpoints: - host: storage.googleapis.com - port: 443 + ports: [443] protocol: rest credential_binding: provider: work-gcp @@ -4518,28 +4616,24 @@ network_policies: } #[test] - fn serialize_single_port_uses_compact_form() { + fn serialize_single_port_uses_public_ports_list() { let yaml = r" version: 1 network_policies: test: name: test endpoints: - - { host: api.example.com, port: 443 } + - { host: api.example.com, ports: [443] } binaries: - { path: /usr/bin/curl } "; let proto = parse_sandbox_policy(yaml).expect("parse failed"); let yaml_out = serialize_sandbox_policy(&proto).expect("serialize failed"); - // Should use compact `port: 443` form, not `ports: [443]` assert!( - yaml_out.contains("port: 443"), - "Single port should serialize as compact form, got:\n{yaml_out}" - ); - assert!( - !yaml_out.contains("ports:"), - "Single port should not produce ports array, got:\n{yaml_out}" + yaml_out.contains("ports:\n - 443"), + "single ports must serialize as a list, got:\n{yaml_out}" ); + assert!(!yaml_out.contains("\n port:")); } #[test] @@ -4550,7 +4644,7 @@ network_policies: test: name: test endpoints: - - { host: "*.example.com", port: 443 } + - { host: "*.example.com", ports: [443] } binaries: - { path: /usr/bin/curl } "#; @@ -4568,7 +4662,7 @@ network_policies: name: test endpoints: - host: "*.example.com" - port: 443 + ports: [443] binaries: - { path: /usr/bin/curl } "#; @@ -4590,7 +4684,7 @@ network_policies: name: github endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest access: read-write deny_rules: @@ -4619,7 +4713,7 @@ network_policies: name: github endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest access: full deny_rules: @@ -4655,7 +4749,7 @@ network_policies: name: test endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: full deny_rules: @@ -4682,7 +4776,7 @@ network_policies: name: github_graphql endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: graphql enforcement: enforce persisted_queries: allow_registered @@ -4733,7 +4827,7 @@ network_policies: name: jsonrpc_api endpoints: - host: jsonrpc.example.com - port: 443 + ports: [443] protocol: json-rpc enforcement: enforce json_rpc: @@ -4762,7 +4856,7 @@ network_policies: name: mcp endpoints: - host: mcp.example.com - port: 443 + ports: [443] path: /mcp protocol: mcp enforcement: enforce @@ -4818,7 +4912,7 @@ network_policies: name: mcp endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: [2025-03-26] @@ -4862,7 +4956,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {} rules: @@ -4907,7 +5001,7 @@ network_policies: jsonrpc_api: endpoints: - host: jsonrpc.example.com - port: 443 + ports: [443] protocol: json-rpc json_rpc: max_body_bytes: 131072 @@ -4933,7 +5027,7 @@ network_policies: name: discord_gateway endpoints: - host: gateway.example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: full @@ -4960,7 +5054,7 @@ network_policies: name: slack_api endpoints: - host: slack.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-write @@ -4986,7 +5080,7 @@ network_policies: vendor_api: endpoints: - host: api.vendor.example - port: 443 + ports: [443] tls: skip allow_uninspected_credentials: true "; @@ -5012,7 +5106,7 @@ network_policies: gateway: endpoints: - host: gateway.example.com - port: 443 + ports: [443] protocol: rest access: full binaries: @@ -5034,7 +5128,7 @@ network_policies: test: endpoints: - host: example.com - port: 443 + ports: [443] deny_rules: - method: POST path: /foo @@ -5051,7 +5145,7 @@ network_policies: legacy: endpoints: - host: example.com - port: 443 + ports: [443] binaries: - path: /usr/bin/curl harness: true @@ -5073,7 +5167,7 @@ network_policies: test: endpoints: - host: example.com - port: 70000 + ports: [70000] "; assert!( parse_sandbox_policy(yaml).is_err(), diff --git a/crates/openshell-policy/testdata/mcp-version-profiles.yaml b/crates/openshell-policy/testdata/mcp-version-profiles.yaml index 1d6106029..46fdbac70 100644 --- a/crates/openshell-policy/testdata/mcp-version-profiles.yaml +++ b/crates/openshell-policy/testdata/mcp-version-profiles.yaml @@ -8,7 +8,7 @@ network_policies: name: versioned_mcp endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp enforcement: enforce mcp: diff --git a/crates/openshell-policy/tests/authored_boundary.rs b/crates/openshell-policy/tests/authored_boundary.rs index 45873c2c2..016941950 100644 --- a/crates/openshell-policy/tests/authored_boundary.rs +++ b/crates/openshell-policy/tests/authored_boundary.rs @@ -16,7 +16,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {} rules: @@ -78,3 +78,19 @@ fn projection_removes_internal_authority_without_mutating_internal_policy() { assert!(internal.network_policies["advisor"].endpoints[0].advisor_proposed); assert!(internal.network_policies["advisor"].endpoints[0].provider_credentialed); } + +#[test] +fn omitted_and_empty_public_binary_lists_lower_to_no_binary_matches() { + for binaries in ["", " binaries: []\n"] { + let source = format!( + "version: 1\nnetwork_policies:\n api:\n endpoints:\n - host: api.example.com\n ports: [443]\n{binaries}" + ); + let authored = parse_authored_policy(&source).expect("public policy must parse"); + let lowered = lower_authored_policy(authored).expect("public policy must lower"); + + assert!( + lowered.network_policies["api"].binaries.is_empty(), + "omitted and explicit-empty public lists must remain the same no-match scope" + ); + } +} diff --git a/crates/openshell-prover-cli/tests/cli.rs b/crates/openshell-prover-cli/tests/cli.rs index 414164c85..17d157486 100644 --- a/crates/openshell-prover-cli/tests/cli.rs +++ b/crates/openshell-prover-cli/tests/cli.rs @@ -233,7 +233,7 @@ fn unsupported_network_surfaces_fail_closed_at_the_cli_boundary() { fs::write( &path, format!( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n{endpoint_fields} binaries: [{{ path: /usr/bin/curl }}]\n" + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n{endpoint_fields} binaries: [{{ path: /usr/bin/curl }}]\n" ), ) .expect("write unsupported surface policy"); @@ -274,7 +274,7 @@ fn canonical_schema_errors_fail_closed_in_both_inputs() { let cases = [ "version: 1\nmetadata: { policy_id: boundary }\n", "version: 1\nfilesystem_policy: null\n", - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, port: 443, review: { required: true } }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, ports: [443], review: { required: true } }]\n", "version: 1\nnetwork_policies:\n n:\n endpoints: [{ rules: [{ allow: { method: GET, review: {} } }] }]\n", "version: 1\nnetwork_policies:\n n:\n endpoints: [{ credential_binding: { provider: demo, future: true } }]\n", "version: 1\nnetwork_policies:\n n:\n endpoints: [{ protocol: rest, mcp: {} }]\n", @@ -378,7 +378,7 @@ fn embedded_nul_network_literal_is_unsupported_without_panicking() { )); fs::write( &path, - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: \"G\\0ET\", path: '/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: \"G\\0ET\", path: '/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", ) .expect("write NUL selector policy"); let output = run(&[ @@ -405,14 +405,15 @@ fn over_limit_mixed_protocol_policy_is_rejected_before_shape_validation() { "openshell-prover-resource-limit-{}.yaml", std::process::id() )); - let mut source = String::from("version: 1\nnetwork_policies:\n mixed:\n endpoints:\n"); + let mut source = String::from("version: 1\nnetwork_policies:\n l4:\n endpoints:\n"); for index in 0..2_500 { writeln!( source, - " - {{ host: l4-{index}.example.com, port: 443 }}" + " - {{ host: l4-{index}.example.com, ports: [443] }}" ) .unwrap(); } + source.push_str(" rest:\n endpoints:\n"); for index in 0..2_500 { let host = if index == 2_499 { "l4-0.example.com".to_owned() @@ -421,7 +422,7 @@ fn over_limit_mixed_protocol_policy_is_rejected_before_shape_validation() { }; writeln!( source, - " - {{ host: {host}, port: 443, protocol: rest, enforcement: enforce, access: read-only }}" + " - {{ host: {host}, ports: [443], protocol: rest, enforcement: enforce, access: read-only }}" ) .unwrap(); } @@ -591,7 +592,7 @@ fn sigint_interrupts_the_check_with_exit_130() { "version": 1, "network_policies": {"many": { "binaries": [{"path": "/usr/bin/curl"}], - "endpoints": [{"host": "api.example.com", "port": 443, + "endpoints": [{"host": "api.example.com", "ports": [443], "protocol": "rest", "enforcement": "enforce", "rules": paths.into_iter().map(|path| serde_json::json!({ "allow": {"method": "GET", "path": path} diff --git a/crates/openshell-prover-cli/tests/fixtures/candidate-ipv6.yaml b/crates/openshell-prover-cli/tests/fixtures/candidate-ipv6.yaml index ce116525f..83017116e 100644 --- a/crates/openshell-prover-cli/tests/fixtures/candidate-ipv6.yaml +++ b/crates/openshell-prover-cli/tests/fixtures/candidate-ipv6.yaml @@ -6,6 +6,6 @@ network_policies: api: endpoints: - host: api.example.com - port: 443 + ports: [443] allowed_ips: - 2001:db8::/32 diff --git a/crates/openshell-prover-cli/tests/fixtures/candidate-underscore-host.yaml b/crates/openshell-prover-cli/tests/fixtures/candidate-underscore-host.yaml index d2f2d37a8..06fd54aba 100644 --- a/crates/openshell-prover-cli/tests/fixtures/candidate-underscore-host.yaml +++ b/crates/openshell-prover-cli/tests/fixtures/candidate-underscore-host.yaml @@ -6,6 +6,6 @@ network_policies: underscore-host: endpoints: - host: api_internal.example.com - port: 443 + ports: [443] binaries: - path: /usr/bin/curl diff --git a/crates/openshell-prover-cli/tests/fixtures/candidate-unicode-network-selector.yaml b/crates/openshell-prover-cli/tests/fixtures/candidate-unicode-network-selector.yaml index bba84da6e..a77ab6906 100644 --- a/crates/openshell-prover-cli/tests/fixtures/candidate-unicode-network-selector.yaml +++ b/crates/openshell-prover-cli/tests/fixtures/candidate-unicode-network-selector.yaml @@ -6,6 +6,6 @@ network_policies: unicode-binary: endpoints: - host: api.example.com - port: 443 + ports: [443] binaries: - path: /usr/bin/é* diff --git a/crates/openshell-prover/src/containment.rs b/crates/openshell-prover/src/containment.rs index 86397f754..c6c7f31ee 100644 --- a/crates/openshell-prover/src/containment.rs +++ b/crates/openshell-prover/src/containment.rs @@ -81,7 +81,9 @@ impl ContainmentEndpoint for Endpoint { /// Parse one captured YAML or JSON input using the canonical authored schema. pub fn parse_policy_str(source: &str) -> Result { - let document = openshell_policy_schema::parse_policy(source) + let generated = openshell_policy_schema::parse_policy_proto(source) + .map_err(|error| ParsePolicyError(format!("invalid policy: {error:#}")))?; + let document = PolicyDocument::try_from(generated) .map_err(|error| ParsePolicyError(format!("invalid policy: {error:#}")))?; let mut filesystem_policy = document.effective_filesystem_policy(); normalize_filesystem_paths(&mut filesystem_policy)?; @@ -1559,10 +1561,7 @@ fn validate_shared_endpoint(context: &str, endpoint: &Endpoint) -> Result<(), Un "{context} endpoint path {reason}" ))); } - if endpoint.host.is_empty() - || endpoint.effective_ports().is_empty() - || (endpoint.port != 0 && !endpoint.ports.is_empty()) - { + if endpoint.host.is_empty() || endpoint.effective_ports().is_empty() { return Err(UnsupportedFeature::policy_shape(format!( "{context} has no unambiguous host and port" ))); @@ -1962,9 +1961,7 @@ fn resource_limit_reason( } } for endpoint in &rule.endpoints { - let endpoint_port_entries = - usize::from(endpoint.ports.is_empty() && endpoint.port != 0) - .max(endpoint.ports.len()); + let endpoint_port_entries = endpoint.ports.len(); port_entry_count = port_entry_count.saturating_add(endpoint_port_entries); if port_entry_count > MAX_PORT_ENTRIES { return Some(resource_limit_detail( @@ -2281,10 +2278,10 @@ mod tests { #[test] fn l4_contains_rest_but_not_the_reverse() { let l4 = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let rest = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: /usr/bin/curl }]\n", ); assert!(matches!( check_within_boundary(&l4, &rest, options()), @@ -2300,7 +2297,7 @@ mod tests { fn network_containment_covers_disabled_binary_identity() { let boundary = parse("version: 1\n"); let candidate = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: []\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: []\n", ); let result = check_within_boundary(&boundary, &candidate, options()); assert!(matches!( @@ -2328,12 +2325,12 @@ mod tests { for (host, protocol) in cases { let endpoint = if protocol == "rest" { format!( - "{{ host: {host}, port: 443, protocol: rest, enforcement: enforce, access: read-only }}" + "{{ host: {host}, ports: [443], protocol: rest, enforcement: enforce, access: read-only }}" ) } else if protocol == "tcp" { - format!("{{ host: {host}, port: 443, protocol: tcp }}") + format!("{{ host: {host}, ports: [443], protocol: tcp }}") } else { - format!("{{ host: {host}, port: 443 }}") + format!("{{ host: {host}, ports: [443] }}") }; let candidate = parse(&format!( "version: 1\nnetwork_policies:\n n:\n endpoints: [{endpoint}]\n binaries: []\n" @@ -2356,10 +2353,10 @@ mod tests { #[test] fn underscore_hosts_preserve_exact_and_wildcard_containment() { let boundary = parse( - "version: 1\nnetwork_policies:\n boundary:\n endpoints: [{ host: '*.example.com', port: 443 }]\n binaries: []\n", + "version: 1\nnetwork_policies:\n boundary:\n endpoints: [{ host: '*.example.com', ports: [443] }]\n binaries: []\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n candidate:\n endpoints: [{ host: api_internal.example.com, port: 443 }]\n binaries: []\n", + "version: 1\nnetwork_policies:\n candidate:\n endpoints: [{ host: api_internal.example.com, ports: [443] }]\n binaries: []\n", ); let boundary = fixed_test_ips(boundary); let candidate = fixed_test_ips(candidate); @@ -2369,10 +2366,10 @@ mod tests { )); let exact_boundary = parse( - "version: 1\nnetwork_policies:\n boundary:\n endpoints: [{ host: _service.example.com, port: 443, protocol: tcp }]\n binaries: []\n", + "version: 1\nnetwork_policies:\n boundary:\n endpoints: [{ host: _service.example.com, ports: [443], protocol: tcp }]\n binaries: []\n", ); let exact_candidate = parse( - "version: 1\nnetwork_policies:\n candidate:\n endpoints: [{ host: _service.example.com, port: 443, protocol: tcp }]\n binaries: []\n", + "version: 1\nnetwork_policies:\n candidate:\n endpoints: [{ host: _service.example.com, ports: [443], protocol: tcp }]\n binaries: []\n", ); assert!(matches!( check_within_boundary(&exact_boundary, &exact_candidate, options()), @@ -2438,7 +2435,7 @@ mod tests { assert!(unsupported_host_glob(unsupported)); let candidate = parse(&format!( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{{ host: {unsupported}, port: 443 }}]\n binaries: []\n" + "version: 1\nnetwork_policies:\n n:\n endpoints: [{{ host: {unsupported}, ports: [443] }}]\n binaries: []\n" )); assert!(matches!( check_within_boundary(&parse("version: 1\n"), &candidate, options()), @@ -2452,10 +2449,10 @@ mod tests { #[test] fn differing_binary_selectors_are_checked_when_identity_is_required() { let boundary = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/wget }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/wget }]\n", ); let result = check_within_boundary(&boundary, &candidate, options()); assert!(matches!( @@ -2474,10 +2471,10 @@ mod tests { #[test] fn explicit_deny_removes_authority() { let boundary = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: full\n deny_rules: [{ method: DELETE, path: /** }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n access: full\n deny_rules: [{ method: DELETE, path: /** }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: DELETE, path: /private/resource } }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: DELETE, path: /private/resource } }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let result = check_within_boundary(&boundary, &candidate, options()); assert!(matches!(result, CheckResult::Exceeds(_)), "{result:?}"); @@ -2486,10 +2483,10 @@ mod tests { #[test] fn overlapping_exact_deny_and_wildcard_is_unsupported() { let boundary = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: 'api*.example.com', port: 443, protocol: rest, enforcement: enforce, access: full }\n binaries: [{ path: /usr/bin/curl }]\n deny:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: full\n deny_rules: [{ method: GET, path: '/**' }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: 'api*.example.com', ports: [443], protocol: rest, enforcement: enforce, access: full }\n binaries: [{ path: /usr/bin/curl }]\n deny:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n access: full\n deny_rules: [{ method: GET, path: '/**' }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: 'api*.example.com', port: 443, protocol: rest, enforcement: enforce, access: full }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: 'api*.example.com', ports: [443], protocol: rest, enforcement: enforce, access: full }\n binaries: [{ path: /usr/bin/curl }]\n", ); let result = check_within_boundary(&boundary, &candidate, options()); assert!( @@ -2505,10 +2502,10 @@ mod tests { #[test] fn ancestor_binary_can_supply_a_boundary_deny() { let boundary = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: /usr/bin/curl }]\n deny:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: /usr/bin/python3 }]\n", + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: /usr/bin/curl }]\n deny:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: /usr/bin/python3 }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: /usr/bin/curl }]\n deny:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: /usr/bin/node }]\n", + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: /usr/bin/curl }]\n deny:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: /usr/bin/node }]\n", ); let result = check_within_boundary(&boundary, &candidate, options()); assert!( @@ -2533,11 +2530,11 @@ mod tests { #[test] fn exact_boundary_deny_under_candidate_glob_requires_image_resolution() { let boundary = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: '/**' }]\n deny:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: /venv/bin/python }]\n", + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: '/**' }]\n deny:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: /venv/bin/python }]\n", ); for pattern in ["/venv/bin/*", "/venv/bin/py**", "/venv/bin/**thon"] { let candidate = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: '/**' }]\n deny:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: 'BINARY_GLOB' }]\n".replace("BINARY_GLOB", pattern).as_str(), + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: '/**' }]\n deny:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: 'BINARY_GLOB' }]\n".replace("BINARY_GLOB", pattern).as_str(), ); assert!(matches!( check_within_boundary(&boundary, &candidate, options()), @@ -2550,10 +2547,10 @@ mod tests { #[test] fn definite_network_expansion_precedes_symlink_uncertainty() { let boundary = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: '/**' }]\n deny:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: /venv/bin/python }]\n", + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: '/**' }]\n deny:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: /venv/bin/python }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: '/**' }]\n extra:\n endpoints: [{ host: extra.example.com, port: 443 }]\n binaries: [{ path: '/**' }]\n deny:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: '/venv/bin/*' }]\n", + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: '/**' }]\n extra:\n endpoints: [{ host: extra.example.com, ports: [443] }]\n binaries: [{ path: '/**' }]\n deny:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n access: read-only\n deny_rules: [{ method: '*', path: '/**' }]\n binaries: [{ path: '/venv/bin/*' }]\n", ); assert!(matches!( check_within_boundary(&boundary, &candidate, options()), @@ -2564,10 +2561,10 @@ mod tests { #[test] fn overlapping_l4_and_rest_authority_is_unsupported() { let boundary = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - { host: api.example.com, port: 443 }\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - { host: api.example.com, ports: [443] }\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, access: read-only }\n binaries: [{ path: /usr/bin/curl }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); assert!(matches!( check_within_boundary(&boundary, &candidate, options()), @@ -2580,10 +2577,10 @@ mod tests { fn methods_longer_than_sixty_four_bytes_are_in_the_action_domain() { let method = "X".repeat(65); let boundary = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, rules: [{ allow: { method: GET, path: '/**' } }] }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, rules: [{ allow: { method: GET, path: '/**' } }] }\n binaries: [{ path: /usr/bin/curl }]\n", ); let candidate = parse(&format!( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - {{ host: api.example.com, port: 443, protocol: rest, enforcement: enforce, rules: [{{ allow: {{ method: {method}, path: '/**' }} }}] }}\n binaries: [{{ path: /usr/bin/curl }}]\n" + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - {{ host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, rules: [{{ allow: {{ method: {method}, path: '/**' }} }}] }}\n binaries: [{{ path: /usr/bin/curl }}]\n" )); let result = check_within_boundary(&boundary, &candidate, options()); assert!(matches!( @@ -2599,13 +2596,13 @@ mod tests { #[test] fn rest_methods_and_paths_must_be_narrower() { let boundary = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/repos/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/repos/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let narrower = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/repos/NVIDIA/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/repos/NVIDIA/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let broader_method = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: POST, path: '/repos/NVIDIA/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: POST, path: '/repos/NVIDIA/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let options = CheckOptions::new(Duration::from_secs(30)); let result = check_within_boundary(&boundary, &narrower, options); @@ -2624,10 +2621,10 @@ mod tests { #[test] fn structural_fast_path_does_not_ignore_separate_boundary_denies() { let boundary = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, rules: [{ allow: { method: GET, path: '/repos/**' } }] }\n binaries: [{ path: /usr/bin/curl }]\n deny:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, access: full, deny_rules: [{ method: GET, path: '/repos/private/**' }] }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, rules: [{ allow: { method: GET, path: '/repos/**' } }] }\n binaries: [{ path: /usr/bin/curl }]\n deny:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, access: full, deny_rules: [{ method: GET, path: '/repos/private/**' }] }\n binaries: [{ path: /usr/bin/curl }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, rules: [{ allow: { method: GET, path: '/repos/private/**' } }] }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n allow:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, rules: [{ allow: { method: GET, path: '/repos/private/**' } }] }\n binaries: [{ path: /usr/bin/curl }]\n", ); assert!(!network_is_structurally_contained( &boundary, &candidate, true @@ -2655,7 +2652,7 @@ mod tests { #[test] fn mcp_authority_fails_closed_in_both_inputs() { let mcp = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: mcp\n enforcement: enforce\n access: full\n mcp: {}\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: mcp\n enforcement: enforce\n access: full\n mcp: {}\n binaries: [{ path: /usr/bin/curl }]\n", ); let empty = parse("version: 1\n"); for (boundary, candidate, side) in [(&mcp, &empty, "boundary"), (&empty, &mcp, "candidate")] @@ -2677,10 +2674,10 @@ mod tests { fn exact_binary_under_a_boundary_glob_requires_image_resolution() { for pattern in ["/usr/bin/*3", "/usr/bin/py**", "/usr/bin/**3"] { let boundary = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: 'BINARY_GLOB' }]\n".replace("BINARY_GLOB", pattern).as_str(), + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: 'BINARY_GLOB' }]\n".replace("BINARY_GLOB", pattern).as_str(), ); let candidate = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/python3 }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/python3 }]\n", ); let result = check_within_boundary(&boundary, &candidate, options()); assert!( @@ -2697,10 +2694,10 @@ mod tests { #[test] fn unrelated_boundary_glob_does_not_make_exact_containment_unsupported() { let boundary = parse( - "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n unrelated:\n endpoints: [{ host: other.example.com, port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n", + "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n unrelated:\n endpoints: [{ host: other.example.com, ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); assert!(matches!( check_within_boundary(&boundary, &candidate, options()), @@ -2711,10 +2708,10 @@ mod tests { #[test] fn redundant_boundary_glob_does_not_hide_equivalent_exact_containment() { let boundary = parse( - "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n glob:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n", + "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n glob:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let result = check_within_boundary(&boundary, &candidate, options()); assert!(matches!(result, CheckResult::Within(_)), "{result:?}"); @@ -2723,10 +2720,10 @@ mod tests { #[test] fn unrelated_universal_glob_does_not_hide_symlink_ambiguity() { let boundary = parse( - "version: 1\nnetwork_policies:\n ambiguous:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n unrelated:\n endpoints: [{ host: unrelated.example.com, port: 80 }]\n binaries: [{ path: '/**' }]\n", + "version: 1\nnetwork_policies:\n ambiguous:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n unrelated:\n endpoints: [{ host: unrelated.example.com, ports: [80] }]\n binaries: [{ path: '/**' }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); assert!(matches!( check_within_boundary(&boundary, &candidate, options()), @@ -2738,10 +2735,10 @@ mod tests { #[test] fn shared_glob_does_not_hide_exact_binary_symlink_ambiguity() { let boundary = parse( - "version: 1\nnetwork_policies:\n shared:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n", + "version: 1\nnetwork_policies:\n shared:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n shared:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: '/usr/bin/*' }, { path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n shared:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }, { path: /usr/bin/curl }]\n", ); assert!(matches!( check_within_boundary(&boundary, &candidate, options()), @@ -2753,10 +2750,10 @@ mod tests { #[test] fn wildcard_endpoint_overlap_does_not_hide_symlink_ambiguity() { let boundary = parse( - "version: 1\nnetwork_policies:\n shared:\n endpoints: [{ host: '*.example.com', port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n", + "version: 1\nnetwork_policies:\n shared:\n endpoints: [{ host: '*.example.com', ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n shared:\n endpoints: [{ host: '*.example.com', port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n exact:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n shared:\n endpoints: [{ host: '*.example.com', ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n exact:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let boundary = fixed_test_ips(boundary); let candidate = fixed_test_ips(candidate); @@ -2770,10 +2767,10 @@ mod tests { #[test] fn ambiguity_check_preserves_shared_unrelated_globs() { let boundary = parse( - "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n ambiguous:\n endpoints: [{ host: unrelated.example.com, port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n shared:\n endpoints: [{ host: shared.example.com, port: 443 }, { host: mirror.example.com, port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n", + "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n ambiguous:\n endpoints: [{ host: unrelated.example.com, ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n shared:\n endpoints: [{ host: shared.example.com, ports: [443] }, { host: mirror.example.com, ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n shared:\n endpoints: [{ host: mirror.example.com, port: 443 }, { host: shared.example.com, port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n", + "version: 1\nnetwork_policies:\n exact:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n shared:\n endpoints: [{ host: mirror.example.com, ports: [443] }, { host: shared.example.com, ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n", ); assert!(matches!( check_within_boundary(&boundary, &candidate, options()), @@ -2784,10 +2781,10 @@ mod tests { #[test] fn reflexive_and_rule_order_invariant() { let first = parse( - "version: 1\nnetwork_policies:\n a:\n endpoints: [{ host: '*.example.com', port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n b:\n endpoints: [{ host: api.example.org, port: 8443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n a:\n endpoints: [{ host: '*.example.com', ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n b:\n endpoints: [{ host: api.example.org, ports: [8443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let second = parse( - "version: 1\nnetwork_policies:\n b:\n endpoints: [{ host: api.example.org, port: 8443 }]\n binaries: [{ path: /usr/bin/curl }]\n a:\n endpoints: [{ host: '*.example.com', port: 443 }]\n binaries: [{ path: '/usr/bin/*' }]\n", + "version: 1\nnetwork_policies:\n b:\n endpoints: [{ host: api.example.org, ports: [8443] }]\n binaries: [{ path: /usr/bin/curl }]\n a:\n endpoints: [{ host: '*.example.com', ports: [443] }]\n binaries: [{ path: '/usr/bin/*' }]\n", ); let reflexive = check_within_boundary(&first, &first, options()); assert!(matches!(reflexive, CheckResult::Within(_)), "{reflexive:?}"); @@ -2832,7 +2829,7 @@ mod tests { "version: 1 network_policies: n: - endpoints: [{ host: l4-0.example.com, port: 443 }] + endpoints: [{ host: l4-0.example.com, ports: [443] }] ", ) .network_policies["n"] @@ -2842,7 +2839,7 @@ network_policies: "version: 1 network_policies: n: - endpoints: [{ host: rest.example.com, port: 443, protocol: rest, enforcement: enforce, access: read-only }] + endpoints: [{ host: rest.example.com, ports: [443], protocol: rest, enforcement: enforce, access: read-only }] ", ) .network_policies["n"] @@ -2884,7 +2881,7 @@ network_policies: "version: 1 network_policies: n: - endpoints: [{ host: api.example.com, port: 443 }] + endpoints: [{ host: api.example.com, ports: [443] }] ", ) .network_policies["n"] @@ -3050,17 +3047,17 @@ network_policies: "version: 1 network_policies: n: - endpoints: [{ host: api.example.com, port: 443 }, { host: API.EXAMPLE.COM, port: 443, protocol: rest }] + endpoints: [{ host: api.example.com, ports: [443] }, { host: API.EXAMPLE.COM, ports: [443], protocol: rest }] ", "version: 1 network_policies: n: - endpoints: [{ host: '*.example.com', ports: [80, 443] }, { host: api.other.test, port: 443, protocol: rest }] + endpoints: [{ host: '*.example.com', ports: [80, 443] }, { host: api.other.test, ports: [443], protocol: rest }] ", "version: 1 network_policies: n: - endpoints: [{ host: api.example.com, port: 80 }, { host: api.example.com, port: 443, protocol: rest }] + endpoints: [{ host: api.example.com, ports: [80] }, { host: api.example.com, ports: [443], protocol: rest }] ", "version: 1 network_policies: @@ -3070,9 +3067,9 @@ network_policies: "version: 1 network_policies: a: - endpoints: [{ host: api.example.com, port: 443 }] + endpoints: [{ host: api.example.com, ports: [443] }] b: - endpoints: [{ host: api.example.com, port: 443 }, { host: api.example.com, port: 443, protocol: rest }] + endpoints: [{ host: api.example.com, ports: [443] }, { host: api.example.com, ports: [443], protocol: rest }] ", ]; for source in policies { @@ -3089,11 +3086,10 @@ network_policies: "version: 1 network_policies: api: - endpoints: [{ host: api.example.com, port: 443 }] + endpoints: [{ host: api.example.com, ports: [443] }] ", ); let endpoint = &mut policy.network_policies.get_mut("api").unwrap().endpoints[0]; - endpoint.port = 0; endpoint.ports = (1..=32_768).collect(); endpoint.allowed_ips = vec!["10.0.0.0/8".to_owned(); MAX_IP_RANGES / 2]; let second = endpoint.clone(); @@ -3186,10 +3182,10 @@ network_policies: #[test] fn deprecated_tls_spelling_does_not_change_authority() { let boundary = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n tls: terminate\n enforcement: enforce\n access: read-only\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n tls: terminate\n enforcement: enforce\n access: read-only\n binaries: [{ path: /usr/bin/curl }]\n", ); let candidate = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules:\n - allow: { method: GET, path: '/v1/**' }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules:\n - allow: { method: GET, path: '/v1/**' }\n binaries: [{ path: /usr/bin/curl }]\n", ); assert!(matches!( check_within_boundary(&boundary, &candidate, options()), @@ -3200,17 +3196,17 @@ network_policies: #[test] fn host_wildcards_do_not_cross_or_elide_labels() { let boundary = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: '*.example.com', port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: '*.example.com', ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let nested = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: deep.api.example.com, port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: deep.api.example.com, ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); assert!(matches!( check_within_boundary(&boundary, &nested, options()), CheckResult::Exceeds(_) )); let recursive = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: '**.example.com', port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: '**.example.com', ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let recursive = fixed_test_ips(recursive); let nested = fixed_test_ips(nested); @@ -3395,31 +3391,31 @@ network_policies: let policies = [ ( "binary path", - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, port: 443 }]\n binaries: [{ path: '/usr/bin/é*' }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: api.example.com, ports: [443] }]\n binaries: [{ path: '/usr/bin/é*' }]\n", ), ( "endpoint host", - "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: 'é.example.com', port: 443 }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints: [{ host: 'é.example.com', ports: [443] }]\n binaries: [{ path: /usr/bin/curl }]\n", ), ( "endpoint path", - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - { host: api.example.com, port: 443, protocol: rest, enforcement: enforce, path: '/é/**', access: full }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - { host: api.example.com, ports: [443], protocol: rest, enforcement: enforce, path: '/é/**', access: full }\n binaries: [{ path: /usr/bin/curl }]\n", ), ( "REST allow method", - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: 'GÉT', path: '/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: 'GÉT', path: '/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", ), ( "REST allow path", - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/é/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/é/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", ), ( "REST deny method", - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/**' } }]\n deny_rules: [{ method: 'DÉLETE', path: '/**' }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/**' } }]\n deny_rules: [{ method: 'DÉLETE', path: '/**' }]\n binaries: [{ path: /usr/bin/curl }]\n", ), ( "REST deny path", - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/**' } }]\n deny_rules: [{ method: GET, path: '/é/**' }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: GET, path: '/**' } }]\n deny_rules: [{ method: GET, path: '/é/**' }]\n binaries: [{ path: /usr/bin/curl }]\n", ), ]; let empty = parse("version: 1\n"); @@ -3458,7 +3454,7 @@ network_policies: ); let policy = parse( - "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n port: 443\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: \"G\\0ET\", path: '/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n n:\n endpoints:\n - host: api.example.com\n ports: [443]\n protocol: rest\n enforcement: enforce\n rules: [{ allow: { method: \"G\\0ET\", path: '/**' } }]\n binaries: [{ path: /usr/bin/curl }]\n", ); let empty = parse("version: 1\n"); for (boundary, candidate, label) in [ diff --git a/crates/openshell-prover/src/containment/ip.rs b/crates/openshell-prover/src/containment/ip.rs index 227484654..07514bf7b 100644 --- a/crates/openshell-prover/src/containment/ip.rs +++ b/crates/openshell-prover/src/containment/ip.rs @@ -327,7 +327,7 @@ mod tests { }; fn policy(host: &str, ips: &str) -> ContainmentPolicy { - parse_policy_str(&format!("version: 1\nnetwork_policies:\n api:\n endpoints: [{{host: '{host}', port: 443, allowed_ips: [{ips}]}}]\n binaries: [{{path: /usr/bin/curl}}]\n")).unwrap() + parse_policy_str(&format!("version: 1\nnetwork_policies:\n api:\n endpoints: [{{host: '{host}', ports: [443], allowed_ips: [{ips}]}}]\n binaries: [{{path: /usr/bin/curl}}]\n")).unwrap() } fn check(host: &str, boundary: &str, candidate: &str) -> CheckResult { @@ -407,11 +407,11 @@ mod tests { #[test] fn exact_and_wildcard_overlap_with_implicit_ip_modes_is_unsupported() { let boundary = parse_policy_str( - "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: '*.example.com', port: 6443 }\n - { host: api.example.com, port: 6443 }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: '*.example.com', ports: [6443] }\n - { host: api.example.com, ports: [6443] }\n binaries: [{ path: /usr/bin/curl }]\n", ) .unwrap(); let candidate = parse_policy_str( - "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: '*.example.com', port: 6443 }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: '*.example.com', ports: [6443] }\n binaries: [{ path: /usr/bin/curl }]\n", ) .unwrap(); @@ -429,11 +429,11 @@ mod tests { #[test] fn split_rule_exact_and_wildcard_overlap_with_implicit_ip_modes_is_unsupported() { let boundary = parse_policy_str( - "version: 1\nnetwork_policies:\n wildcard:\n endpoints:\n - { host: '*.example.com', port: 6443 }\n binaries: [{ path: /usr/bin/curl }]\n exact:\n endpoints:\n - { host: api.example.com, port: 6443 }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n wildcard:\n endpoints:\n - { host: '*.example.com', ports: [6443] }\n binaries: [{ path: /usr/bin/curl }]\n exact:\n endpoints:\n - { host: api.example.com, ports: [6443] }\n binaries: [{ path: /usr/bin/curl }]\n", ) .unwrap(); let candidate = parse_policy_str( - "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: '*.example.com', port: 6443 }\n binaries: [{ path: /usr/bin/curl }]\n", + "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: '*.example.com', ports: [6443] }\n binaries: [{ path: /usr/bin/curl }]\n", ) .unwrap(); diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index b8bd2bfc1..5b18aaaf2 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -149,7 +149,7 @@ filesystem_policy: #[test] fn explicit_tcp_is_l4_in_the_risk_projection() { let model = policy::parse_policy_str( - "version: 1\nnetwork_policies:\n tcp:\n endpoints:\n - host: example.com\n port: 443\n protocol: tcp\n", + "version: 1\nnetwork_policies:\n tcp:\n endpoints:\n - host: example.com\n ports: [443]\n protocol: tcp\n", ) .expect("parse"); let endpoint = &model.network_policies["tcp"].endpoints[0]; @@ -261,7 +261,7 @@ network_policies: name: github-wildcard endpoints: - host: "*.github.com" - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-write @@ -301,7 +301,7 @@ network_policies: name: metadata endpoints: - host: metadata.google.internal - port: 80 + ports: [80] binaries: - path: /usr/bin/curl ", diff --git a/crates/openshell-prover/src/policy.rs b/crates/openshell-prover/src/policy.rs index 0d4729f7e..bd6fc7e3f 100644 --- a/crates/openshell-prover/src/policy.rs +++ b/crates/openshell-prover/src/policy.rs @@ -257,13 +257,15 @@ impl PolicyModel { /// Parse an `OpenShell` policy YAML file into a `PolicyModel`. pub fn parse_policy(path: &Path) -> miette::Result { - let document = openshell_policy_schema::parse_policy_file(path, ParseLimits::default())?; + let generated = openshell_policy_schema::parse_policy_proto_file(path, ParseLimits::default())?; + let document = PolicyDocument::try_from(generated)?; Ok(project_policy(document)) } /// Parse a policy YAML string into a `PolicyModel`. pub fn parse_policy_str(yaml: &str) -> miette::Result { - let document = openshell_policy_schema::parse_policy(yaml)?; + let generated = openshell_policy_schema::parse_policy_proto(yaml)?; + let document = PolicyDocument::try_from(generated)?; Ok(project_policy(document)) } @@ -310,9 +312,10 @@ fn project_endpoint(endpoint: AuthoredEndpoint) -> Endpoint { .into_iter() .map(|rule| project_allow(rule.allow)) .collect(); + let port = endpoint.ports.first().copied().unwrap_or_default(); Endpoint { host: endpoint.host, - port: endpoint.port, + port, ports: endpoint.ports, protocol: endpoint.protocol, tls: endpoint.tls, diff --git a/crates/openshell-prover/testdata/policy.yaml b/crates/openshell-prover/testdata/policy.yaml index e98d95319..31a056f2b 100644 --- a/crates/openshell-prover/testdata/policy.yaml +++ b/crates/openshell-prover/testdata/policy.yaml @@ -34,14 +34,14 @@ network_policies: endpoints: # REST API — L7 enforced, read-only - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only # github.com — for git clone. # No protocol field — L4 only. - host: github.com - port: 443 + ports: [443] binaries: - { path: /usr/local/bin/claude } - { path: /usr/bin/git } diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 679c50cb7..8b6fdc687 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -37,8 +37,12 @@ pub enum ProfileError { MissingId, #[error("duplicate provider profile id: {0}")] DuplicateId(String), - #[error("provider profile '{id}' has invalid endpoint '{host}:{port}'")] - InvalidEndpoint { id: String, host: String, port: u32 }, + #[error("provider profile '{id}' has invalid endpoint '{host}' ports {ports:?}")] + InvalidEndpoint { + id: String, + host: String, + ports: Vec, + }, /// An MCP endpoint declared a malformed exact revision allowlist. #[error("provider profile '{id}' has invalid MCP configuration in '{field}': {message}")] InvalidMcpConfiguration { @@ -54,8 +58,8 @@ pub enum ProfileError { field: String, message: String, }, - #[error("provider profile contains an invalid authored policy endpoint: {0}")] - InvalidAuthoredEndpoint(String), + #[error("provider profile contains invalid authored network policy: {0}")] + InvalidAuthoredPolicy(String), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -320,11 +324,12 @@ pub struct DiscoveryProfile { pub credentials: Vec, } -// These YAML/JSON DTOs mirror the network policy protos intentionally. Keep -// every lossless conversion below in sync with proto/sandbox.proto. If a field -// is added to NetworkEndpoint, L7Rule, L7Allow, L7DenyRule, L7QueryMatcher, -// GraphqlOperation, or NetworkBinary, add it here and in both conversion -// directions unless the import/lint path explicitly rejects it. +// These provider-owned YAML/JSON DTOs mirror the authored network policy +// messages intentionally. Keep every lossless conversion below in sync with +// proto/policy.proto. If a field is added to NetworkEndpoint, L7Rule, L7Allow, +// L7DenyRule, Matcher, GraphqlOperation, or NetworkBinary, add it here and in +// both conversion directions unless the import/lint path explicitly rejects +// it. #[derive(Debug, Clone, PartialEq, Eq)] #[allow( clippy::struct_excessive_bools, @@ -332,7 +337,6 @@ pub struct DiscoveryProfile { )] pub struct EndpointProfile { pub host: String, - pub port: u32, pub protocol: String, pub tls: String, pub access: String, @@ -371,11 +375,9 @@ pub struct EndpointProfile { clippy::struct_excessive_bools, reason = "Endpoint profile mirror preserves independent policy schema toggles." )] -#[serde(remote = "EndpointProfile")] +#[serde(remote = "EndpointProfile", deny_unknown_fields)] struct EndpointProfileSerde { host: String, - #[serde(default, skip_serializing_if = "is_zero")] - port: u32, #[serde(default, skip_serializing_if = "String::is_empty")] protocol: String, #[serde(default, skip_serializing_if = "String::is_empty")] @@ -735,6 +737,18 @@ impl ProviderTypeProfile { /// Convert an untrusted public protobuf profile without panicking. pub fn try_from_proto(profile: &ProviderProfile) -> Result { + // Validate the complete authored rule before splitting it into the + // profile DTO. Per-endpoint validation misses aggregate constraints + // such as endpoint/binary counts and would let an import fail only + // after an earlier profile in the same batch had been persisted. + let authored_rule = authored::NetworkPolicyRule { + name: "provider-profile".to_string(), + endpoints: profile.endpoints.clone(), + binaries: profile.binaries.clone(), + }; + let network_rule = openshell_policy::lower_authored_rule("provider-profile", authored_rule) + .map_err(|error| ProfileError::InvalidAuthoredPolicy(error.to_string()))?; + Ok(Self { id: profile.id.clone(), resource_version: profile.resource_version, @@ -762,15 +776,17 @@ impl ProviderTypeProfile { token_grant: credential.token_grant.as_ref().map(token_grant_from_proto), }) .collect(), - endpoints: profile + endpoints: network_rule .endpoints .iter() - .map(authored_endpoint_from_proto) - .collect::, _>>()?, - binaries: profile + .map(endpoint_from_proto) + .collect(), + binaries: network_rule .binaries .iter() - .map(authored_binary_from_proto) + .map(|binary| BinaryProfile { + path: binary.path.clone(), + }) .collect(), inference_capable: profile.inference_capable, discovery: profile @@ -1565,7 +1581,15 @@ fn discovery_to_proto(discovery: &DiscoveryProfile) -> ProviderProfileDiscovery fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { NetworkEndpoint { host: endpoint.host.clone(), - port: endpoint.port, + // The runtime schema still carries its legacy scalar. Public authored + // input has one canonical representation, so only populate the + // scalar when it represents the complete repeated-port set. + port: endpoint + .ports + .first() + .copied() + .filter(|_| endpoint.ports.len() == 1) + .unwrap_or(0), protocol: endpoint.protocol.clone(), tls: network_tls_mode_from_str(&endpoint.tls).map_or(-1, |value| value as i32), enforcement: network_enforcement_mode_from_str(&endpoint.enforcement) @@ -1626,29 +1650,17 @@ fn authored_endpoint_to_proto(endpoint: &EndpointProfile) -> authored::NetworkEn .expect("projected provider profile rule must retain its endpoint") } -fn authored_endpoint_from_proto( - endpoint: &authored::NetworkEndpoint, -) -> Result { - let rule = authored::NetworkPolicyRule { - name: "provider-profile".to_string(), - endpoints: vec![endpoint.clone()], - binaries: Vec::new(), - }; - let internal = openshell_policy::lower_authored_rule("provider-profile", rule) - .map_err(|error| ProfileError::InvalidAuthoredEndpoint(error.to_string()))?; - Ok(endpoint_from_proto(internal.endpoints.first().ok_or_else( - || { - ProfileError::InvalidAuthoredEndpoint( - "lowered provider profile rule did not retain its endpoint".to_string(), - ) - }, - )?)) -} - fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile { + let ports = if endpoint.ports.is_empty() { + (endpoint.port != 0) + .then_some(endpoint.port) + .into_iter() + .collect() + } else { + endpoint.ports.clone() + }; let mut profile = EndpointProfile { host: endpoint.host.clone(), - port: endpoint.port, protocol: endpoint.protocol.clone(), tls: network_tls_mode_to_str(endpoint.tls) .map_or_else(|| format!("unknown({})", endpoint.tls), str::to_owned), @@ -1664,7 +1676,7 @@ fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile { Some(endpoint.rules.iter().map(rule_from_proto).collect()) }, allowed_ips: endpoint.allowed_ips.clone(), - ports: endpoint.ports.clone(), + ports, deny_rules: if endpoint.deny_rules.is_empty() { None } else { @@ -1764,12 +1776,6 @@ fn authored_binary_to_proto(binary: &BinaryProfile) -> authored::NetworkBinary { } } -fn authored_binary_from_proto(binary: &authored::NetworkBinary) -> BinaryProfile { - BinaryProfile { - path: binary.path.clone(), - } -} - fn rule_to_proto(rule: &L7RuleProfile) -> L7Rule { L7Rule { allow: rule.allow.as_ref().map(allow_to_proto), @@ -2057,7 +2063,7 @@ fn validate_profiles(profiles: &[ProviderTypeProfile]) -> Result<(), ProfileErro return Err(ProfileError::InvalidEndpoint { id: profile.id.clone(), host: endpoint.host.clone(), - port: endpoint.port, + ports: endpoint.ports.clone(), }); } Err(ProfileError::ValidationError { @@ -2568,7 +2574,10 @@ pub fn validate_profile_set( source, profile_id, format!("endpoints[{index}]"), - format!("invalid endpoint '{}:{}'", endpoint.host, endpoint.port), + format!( + "invalid endpoint '{}' ports {:?}", + endpoint.host, endpoint.ports + ), )); } collect_mcp_profile_diagnostics(source, profile_id, index, endpoint, &mut diagnostics); @@ -2937,8 +2946,8 @@ pub fn validate_profile_set( profile_id, format!("endpoints[{index}].allow_uninspected_credentials"), format!( - "credentialed endpoint '{}:{}' uses {mode}; configure L7 inspection or explicitly set allow_uninspected_credentials: true", - endpoint.host, endpoint.port + "credentialed endpoint '{}' ports {:?} uses {mode}; configure L7 inspection or explicitly set allow_uninspected_credentials: true", + endpoint.host, endpoint.ports ), )); } @@ -3022,13 +3031,11 @@ fn endpoint_is_valid(endpoint: &EndpointProfile) -> bool { if endpoint.host.trim().is_empty() { return false; } - if !endpoint.ports.is_empty() { - return endpoint + !endpoint.ports.is_empty() + && endpoint .ports .iter() - .all(|port| (1..=65_535).contains(port)); - } - (1..=65_535).contains(&endpoint.port) + .all(|port| (1..=65_535).contains(port)) } fn additional_l7_profile_fields(endpoint: &EndpointProfile) -> Vec<&'static str> { @@ -3224,7 +3231,7 @@ fn validate_token_grant_audience_overrides( let mut bindings: Vec = Vec::new(); for (override_index, override_config) in token_grant.audience_overrides.iter().enumerate() { for endpoint in endpoints { - for port in endpoint_ports(endpoint.port, &endpoint.ports) { + for &port in &endpoint.ports { if !token_grant_override_matches_endpoint(override_config, &endpoint.host, port) { continue; } @@ -3279,14 +3286,6 @@ fn validate_token_grant_audience_overrides( diagnostics } -fn endpoint_ports(port: u32, ports: &[u32]) -> Vec { - if ports.is_empty() { - if port == 0 { Vec::new() } else { vec![port] } - } else { - ports.iter().copied().filter(|port| *port != 0).collect() - } -} - fn token_grant_override_matches_endpoint( override_config: &TokenGrantAudienceOverrideProfile, endpoint_host: &str, @@ -3706,10 +3705,15 @@ credentials: .to_proto() .endpoints .iter() - .map(|endpoint| StaticCredentialEndpointBinding { - host: endpoint.host.clone(), - port: endpoint.port, - path: endpoint.path.clone(), + .flat_map(|endpoint| { + endpoint + .ports + .iter() + .map(|&port| StaticCredentialEndpointBinding { + host: endpoint.host.clone(), + port, + path: endpoint.path.clone(), + }) }) .collect(), }; @@ -3738,27 +3742,29 @@ credentials: { assert!(!endpoint.request_body_credential_rewrite); assert!(!endpoint.allow_uninspected_credentials); - let (_, classifier, _) = state.resolver_and_body_classifier_for_endpoint( - &endpoint.host, - u16::try_from(endpoint.port).unwrap(), - "/v1/responses", - ); - let classifier = classifier.unwrap(); - for token in [ - "openshell:resolve:env:KEY".to_owned(), - state.snapshot().child_env["GITHUB_TOKEN"].clone(), - state.snapshot().child_env[&model_key].clone(), - ] { - let body = format!(r#"{{"tool_output":"Token: {token}"}}"#); - let mut guard = BodyPlaceholderGuard::new(Some(&classifier)); - let mut forwarded = guard.push(body.as_bytes()).unwrap(); - forwarded.extend(guard.finish().unwrap()); - assert_eq!(forwarded, body.as_bytes()); + for &port in &endpoint.ports { + let (_, classifier, _) = state.resolver_and_body_classifier_for_endpoint( + &endpoint.host, + u16::try_from(port).unwrap(), + "/v1/responses", + ); + let classifier = classifier.unwrap(); + for token in [ + "openshell:resolve:env:KEY".to_owned(), + state.snapshot().child_env["GITHUB_TOKEN"].clone(), + state.snapshot().child_env[&model_key].clone(), + ] { + let body = format!(r#"{{"tool_output":"Token: {token}"}}"#); + let mut guard = BodyPlaceholderGuard::new(Some(&classifier)); + let mut forwarded = guard.push(body.as_bytes()).unwrap(); + forwarded.extend(guard.finish().unwrap()); + assert_eq!(forwarded, body.as_bytes()); + } + assert_eq!( + classifier.check(&state.snapshot().child_env[&model_key]), + Ok(()) + ); } - assert_eq!( - classifier.check(&state.snapshot().child_env[&model_key]), - Ok(()) - ); } } } @@ -3839,7 +3845,7 @@ credentials: let git_transport = proto .endpoints .iter() - .find(|endpoint| endpoint.host == "github.com" && endpoint.port == 443) + .find(|endpoint| endpoint.host == "github.com" && endpoint.ports.contains(&443)) .expect("github.com git transport endpoint"); // The git transport carries explicit rules rather than an access preset @@ -4031,7 +4037,7 @@ id: policy-only display_name: Policy Only endpoints: - host: example.com - port: 443 + ports: [443] ", ) .expect("profile"); @@ -4155,7 +4161,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] path: /mcp protocol: mcp mcp: @@ -4246,7 +4252,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ["2025-03-26", "2025-11-25"] @@ -4383,7 +4389,7 @@ id: mcp-params display_name: MCP Params endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -4427,7 +4433,7 @@ binaries: fn mcp_endpoint_profile_direct_serde_materializes_and_emits_the_pinned_default() { let endpoint_yaml = r" host: mcp.example.com -port: 443 +ports: [443] protocol: mcp "; let endpoint = serde_yml::from_str::(endpoint_yaml) @@ -4438,7 +4444,7 @@ protocol: mcp )) .expect("YAML endpoint vector omission must default"); let json_vector = serde_json::from_str::>( - r#"[{"host":"mcp.example.com","port":443,"protocol":"mcp"},{"host":"mcp.example.com","port":443,"protocol":"mcp","mcp":{}}]"#, + r#"[{"host":"mcp.example.com","ports":[443],"protocol":"mcp"},{"host":"mcp.example.com","ports":[443],"protocol":"mcp","mcp":{}}]"#, ) .expect("JSON endpoint vector omissions must default"); @@ -4501,7 +4507,7 @@ protocol: mcp "{versions: [latest]}", ] { let yaml = format!( - "- host: mcp.example.com\n port: 443\n protocol: mcp\n mcp: {invalid_mcp}\n" + "- host: mcp.example.com\n ports: [443]\n protocol: mcp\n mcp: {invalid_mcp}\n" ); assert!( serde_yml::from_str::>(&yaml).is_err(), @@ -4529,7 +4535,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp "; let omitted_versions = r" @@ -4537,7 +4543,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {} "; @@ -4547,7 +4553,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['{}'] @@ -4560,11 +4566,11 @@ endpoints: let omitted_versions = parse_profile_yaml(omitted_versions).expect("omitted versions must default"); let omitted_options_json = parse_profile_json( - r#"{"id":"mcp-example","display_name":"MCP Example","endpoints":[{"host":"mcp.example.com","port":443,"protocol":"mcp"}]}"#, + r#"{"id":"mcp-example","display_name":"MCP Example","endpoints":[{"host":"mcp.example.com","ports":[443],"protocol":"mcp"}]}"#, ) .expect("omitted JSON MCP options must default"); let omitted_versions_json = parse_profile_json( - r#"{"id":"mcp-example","display_name":"MCP Example","endpoints":[{"host":"mcp.example.com","port":443,"protocol":"mcp","mcp":{}}]}"#, + r#"{"id":"mcp-example","display_name":"MCP Example","endpoints":[{"host":"mcp.example.com","ports":[443],"protocol":"mcp","mcp":{}}]}"#, ) .expect("omitted JSON versions must default"); let explicit_default = @@ -4631,7 +4637,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {invalid_mcp} @@ -4648,7 +4654,7 @@ id: rest-example display_name: REST Example endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest mcp: versions: ['2025-11-25'] @@ -4667,7 +4673,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: null "; @@ -4679,49 +4685,44 @@ endpoints: assert!(serde_yml::from_value::(&value).is_err()); for json in [ - r#"{"id":"mcp-example","display_name":"MCP Example","endpoints":[{"host":"mcp.example.com","port":443,"protocol":"mcp","mcp":null}]}"#, - r#"{"id":"mcp-example","display_name":"MCP Example","endpoints":[{"host":"mcp.example.com","port":443,"protocol":"mcp","mcp":{"versions":null}}]}"#, + r#"{"id":"mcp-example","display_name":"MCP Example","endpoints":[{"host":"mcp.example.com","ports":[443],"protocol":"mcp","mcp":null}]}"#, + r#"{"id":"mcp-example","display_name":"MCP Example","endpoints":[{"host":"mcp.example.com","ports":[443],"protocol":"mcp","mcp":{"versions":null}}]}"#, ] { assert!(parse_profile_json(json).is_err()); } } #[test] - fn endpoint_profile_preserves_unknown_field_tolerance() { + fn endpoint_profile_rejects_unknown_fields() { let endpoint_yaml = r" host: api.example.com -port: 443 +ports: [443] protocol: rest access: full future_endpoint_option: true "; - let endpoint_json = r#"{"host":"api.example.com","port":443,"protocol":"rest","access":"full","future_endpoint_option":true}"#; + let endpoint_json = r#"{"host":"api.example.com","ports":[443],"protocol":"rest","access":"full","future_endpoint_option":true}"#; - let yaml_endpoint = serde_yml::from_str::(endpoint_yaml) - .expect("unknown endpoint fields remain forward-compatible in YAML"); - let json_endpoint = serde_json::from_str::(endpoint_json) - .expect("unknown endpoint fields remain forward-compatible in JSON"); - assert_eq!(yaml_endpoint.host, "api.example.com"); - assert_eq!(json_endpoint, yaml_endpoint); + assert!(serde_yml::from_str::(endpoint_yaml).is_err()); + assert!(serde_json::from_str::(endpoint_json).is_err()); let profile_yaml = format!( "id: future-profile\ndisplay_name: Future profile\nendpoints:\n - {}", endpoint_yaml.trim_start().replace('\n', "\n ") ); - parse_profile_yaml(&profile_yaml) - .expect("nested endpoint parsing must retain the prior unknown-field tolerance"); + assert!(parse_profile_yaml(&profile_yaml).is_err()); } #[test] fn mcp_options_profile_rejects_unknown_fields_across_parsing_routes() { let endpoint_yaml = r" host: mcp.example.com -port: 443 +ports: [443] protocol: mcp mcp: version: ['2025-11-25'] "; - let endpoint_json = r#"{"host":"mcp.example.com","port":443,"protocol":"mcp","mcp":{"versionss":["2025-11-25"]}}"#; + let endpoint_json = r#"{"host":"mcp.example.com","ports":[443],"protocol":"mcp","mcp":{"versionss":["2025-11-25"]}}"#; assert!(serde_yml::from_str::(endpoint_yaml).is_err()); assert!(serde_json::from_str::(endpoint_json).is_err()); @@ -4759,7 +4760,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: [draft] @@ -4792,7 +4793,7 @@ id: uppercase-mcp display_name: Uppercase MCP endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: MCP mcp: versions: ['2025-11-25', '2025-03-26'] @@ -4816,7 +4817,7 @@ id: uppercase-mcp display_name: Uppercase MCP endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: MCP rules: - allow: @@ -4842,7 +4843,7 @@ id: Invalid-Id display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: rest mcp: versions: ['2025-11-25'] @@ -4852,7 +4853,7 @@ id: Invalid-Id display_name: REST Example endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest mcp: versions: ['2025-11-25'] @@ -4882,12 +4883,12 @@ id: mcp-priority display_name: MCP Priority endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: rest mcp: versions: ['2025-11-25'] - host: "" - port: 443 + ports: [443] "#; assert!(matches!( @@ -5451,7 +5452,7 @@ credentials: audience: api://beta endpoints: - host: alpha.default.svc.cluster.local - port: 80 + ports: [80] path: /v1/** protocol: rest access: full @@ -5493,7 +5494,7 @@ credentials: audience: api://admin endpoints: - host: alpha.default.svc.cluster.local - port: 80 + ports: [80] path: /v1/** protocol: rest access: full @@ -5565,7 +5566,7 @@ display_name: Advanced category: other endpoints: - host: graphql.example.com - port: 443 + ports: [443] protocol: graphql access: read-only persisted_queries: allow_registered @@ -5621,7 +5622,6 @@ binaries: ); let rest_ep = &proto.endpoints[1]; - assert_eq!(rest_ep.port, 0); assert_eq!(rest_ep.ports, vec![443, 8443]); assert_eq!(rest_ep.tls, "terminate"); assert_eq!(rest_ep.allowed_ips, vec!["10.0.0.0/24"]); @@ -5682,7 +5682,7 @@ credentials: env_vars: [TOKEN] endpoints: - host: api.example.com - port: 443 + ports: [443] ", ) .expect("profile should parse"); @@ -5695,7 +5695,7 @@ display_name: Signed credentials: [] endpoints: - host: s3.example.com - port: 443 + ports: [443] credential_signing: sigv4 ", ) @@ -5709,7 +5709,7 @@ display_name: Plain credentials: [] endpoints: - host: pypi.org - port: 443 + ports: [443] ", ) .expect("profile should parse"); @@ -5727,7 +5727,7 @@ credentials: env_vars: [TOKEN] endpoints: - host: raw.example.com - port: 443 + ports: [443] ", ) .expect("profile should parse"); @@ -5745,7 +5745,7 @@ credentials: env_vars: [TOKEN] endpoints: - host: raw.example.com - port: 443 + ports: [443] allow_uninspected_credentials: true ", ) @@ -5777,7 +5777,7 @@ discovery: credentials: [api_key, missing_key] endpoints: - host: "" - port: 0 + ports: [0] binaries: ["", /usr/bin/broken] "#, ) @@ -5921,7 +5921,7 @@ id: bad-endpoint display_name: Bad Endpoint endpoints: - host: api.example.com - port: 0 + ports: [0] "]) .unwrap_err(); @@ -5935,7 +5935,7 @@ id: bad-l7 display_name: Bad L7 endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: read-write rules: @@ -6387,7 +6387,7 @@ id: invalid-modes display_name: Invalid modes endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest tls: skp enforcement: enforc @@ -6423,7 +6423,7 @@ discovery: credentials: [api_key] endpoints: - host: openrouter.ai - port: 443 + ports: [443] protocol: rest enforcement: enforce binaries: @@ -6456,7 +6456,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: read-write binaries: @@ -6488,7 +6488,7 @@ discovery: credentials: [api_key] endpoints: - host: database.example.com - port: 5432 + ports: [5432] protocol: tcp tls: skip allow_uninspected_credentials: true @@ -6522,7 +6522,7 @@ discovery: credentials: [api_key] endpoints: - host: database.example.com - port: 5432 + ports: [5432] protocol: tcp enforcement: enforce path: /query @@ -6594,7 +6594,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: ftp binaries: - /usr/bin/app @@ -6626,7 +6626,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: full rules: @@ -6663,7 +6663,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: full rules: [] @@ -6697,7 +6697,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest rules: - allow: {} @@ -6731,7 +6731,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: full deny_rules: [] @@ -6765,7 +6765,7 @@ discovery: credentials: [api_key] endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -6805,7 +6805,7 @@ discovery: credentials: [api_key] endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -6847,7 +6847,7 @@ discovery: credentials: [api_key] endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -6886,7 +6886,7 @@ id: mcp-deny-params display_name: MCP Deny Params endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -6930,7 +6930,7 @@ id: mcp-tool display_name: MCP Tool endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -6977,7 +6977,7 @@ id: mcp-deny-tool display_name: MCP Deny Tool endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7019,7 +7019,7 @@ id: mcp-both display_name: MCP Both endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7076,7 +7076,7 @@ id: mcp-broad display_name: MCP Broad endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7111,7 +7111,7 @@ id: mcp-glob display_name: MCP Glob endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7146,7 +7146,7 @@ id: mcp-deny-broad display_name: MCP Deny Broad endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7181,7 +7181,7 @@ id: mcp-deny-ok display_name: MCP Deny OK endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7218,7 +7218,7 @@ id: mcp-scalar-tool display_name: MCP Scalar Tool endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7272,7 +7272,7 @@ id: mcp-deny-both display_name: MCP Deny Both endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7307,7 +7307,7 @@ id: mcp-bad-method display_name: MCP Bad Method endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7339,7 +7339,7 @@ id: mcp-deny-bad-method display_name: MCP Deny Bad Method endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7371,7 +7371,7 @@ id: mcp-no-method display_name: MCP No Method endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7402,7 +7402,7 @@ id: mcp-both display_name: MCP Both endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7437,7 +7437,7 @@ id: mcp-tool-only display_name: MCP Tool Only endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7467,7 +7467,7 @@ id: mcp-deny-both display_name: MCP Deny Both endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7509,7 +7509,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] deny_rules: - method: POST binaries: @@ -7535,7 +7535,7 @@ id: empty-rules display_name: Empty Rules endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7563,7 +7563,7 @@ id: empty-deny-rules display_name: Empty Deny Rules endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7595,7 +7595,7 @@ id: bad-params display_name: Bad Params endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7629,7 +7629,7 @@ id: glob-and-any display_name: Glob And Any endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7663,7 +7663,7 @@ id: empty-matcher display_name: Empty Matcher endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7693,7 +7693,7 @@ id: wildcard-no-strict display_name: Wildcard No Strict endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7727,7 +7727,7 @@ id: wildcard-strict display_name: Wildcard Strict endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7761,7 +7761,7 @@ id: bad-method-glob display_name: Bad Method Glob endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7792,7 +7792,7 @@ id: mcp-path display_name: MCP Path endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -7824,7 +7824,7 @@ id: rest-tool display_name: REST Tool endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest rules: - allow: @@ -7854,7 +7854,7 @@ id: rest-mcp-opts display_name: REST MCP Options endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest mcp: versions: ['2025-11-25'] diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 3758338df..ef954f9cf 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -119,7 +119,7 @@ client let _sandbox = client .create_sandbox_from_template(SandboxTemplateCreateSpec { template_name: "python".to_string(), - policy: Some(openshell_sdk::raw::proto::SandboxPolicy { + policy: Some(openshell_sdk::raw::proto::policy::PolicyDocument { version: 1, ..Default::default() }), diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index fe1fa34a4..7ce6d3009 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -180,7 +180,7 @@ pub struct SandboxTemplateCreateSpec { pub tty: bool, /// Create-time sandbox policy. The named workload template supplies runtime /// workload fields; policy remains part of the sandbox's governance spec. - pub policy: Option, + pub policy: Option, } /// Reusable sandbox workload template resource. diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 5aafa39aa..bb150cc18 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -1021,7 +1021,7 @@ async fn create_sandbox_from_template_passes_template_name() { providers: vec!["openai".to_string()], command: vec!["python".to_string(), "-m".to_string(), "agent".to_string()], tty: false, - policy: Some(proto::policy::SandboxPolicy { + policy: Some(proto::policy::PolicyDocument { version: 1, ..Default::default() }), @@ -1652,7 +1652,7 @@ async fn workspace_scoped_create_from_template_passes_workspace() { .create_sandbox_from_template(SandboxTemplateCreateSpec { name: Some("from-template".to_string()), template_name: "python".to_string(), - policy: Some(proto::policy::SandboxPolicy { + policy: Some(proto::policy::PolicyDocument { version: 2, ..Default::default() }), diff --git a/crates/openshell-server/src/compute/provisioning_deadline.rs b/crates/openshell-server/src/compute/provisioning_deadline.rs index 54d1f8368..cb7ce4c1b 100644 --- a/crates/openshell-server/src/compute/provisioning_deadline.rs +++ b/crates/openshell-server/src/compute/provisioning_deadline.rs @@ -342,15 +342,16 @@ impl super::ComputeRuntime { ObjectId, proto::{Sandbox, SandboxPhase}, }; - use prost::Message; let records = self .store .collect_records(Sandbox::object_type(), ObjectListQuery::AllWorkspaces) .await .map_err(|error| error.to_string())?; for record in records { - let candidate = - Sandbox::decode(record.payload.as_slice()).map_err(|error| error.to_string())?; + // `collect_records` returns the private durable envelope rather + // than the public API message used by `get_message`. Decode it at + // the storage boundary before inspecting lifecycle state. + let candidate = crate::storage_proto::decode_sandbox(record.payload.as_slice())?; if !matches!( SandboxPhase::try_from(candidate.phase()), Ok(SandboxPhase::Provisioning | SandboxPhase::Starting) diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs index 9e2a63f22..59bf50b93 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs @@ -9,7 +9,7 @@ use crate::grpc::mutation_replay::{Admission, OBJECT_TYPE, OriginalMutation, fin use crate::grpc::test_support::{authed_request, test_server_state}; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::policy::{ - NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyDocument, }; use openshell_core::proto::{ DeletionOutcome, DraftChunkApproval, GetDraftPolicyRequest, PolicyChunk, @@ -1006,7 +1006,7 @@ async fn draft_receipts_replay_after_chunk_state_and_review_tokens_change() { let sandbox = Sandbox { metadata: Some(meta(name)), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: Some(PolicyDocument { version: 1, ..Default::default() }), @@ -1019,7 +1019,7 @@ async fn draft_receipts_replay_after_chunk_state_and_review_tokens_change() { name: name.into(), endpoints: vec![NetworkEndpoint { host: format!("{name}.example.com"), - port: 443, + ports: vec![443], ..Default::default() }], binaries: vec![NetworkBinary { diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index a6de388fc..48b640d41 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -96,13 +96,13 @@ use std::sync::Arc; use tonic::{Request, Response, Status}; pub(super) fn lower_public_policy( - policy: authored::SandboxPolicy, + policy: authored::PolicyDocument, ) -> Result { openshell_policy::lower_authored_policy(policy) .map_err(|error| Status::invalid_argument(format!("invalid authored policy: {error}"))) } -fn project_public_policy(policy: &ProtoSandboxPolicy) -> Result { +fn project_public_policy(policy: &ProtoSandboxPolicy) -> Result { openshell_policy::project_base_policy(policy) .map_err(|error| Status::internal(format!("failed to project public policy: {error}"))) } @@ -2122,11 +2122,7 @@ fn signed_endpoint_is_covered( } fn authored_endpoint_ports(endpoint: &authored::NetworkEndpoint) -> Vec { - if endpoint.ports.is_empty() { - vec![endpoint.port] - } else { - endpoint.ports.clone() - } + endpoint.ports.clone() } fn endpoint_ports_for_validation(endpoint: &NetworkEndpoint) -> Vec { @@ -6775,6 +6771,13 @@ fn parse_proto_l7_target( let target = target.ok_or_else(|| { Status::invalid_argument(format!("merge_operations[{index}] requires an L7 target")) })?; + for binary in &target.binaries { + openshell_policy::validate_authored_network_binary(binary).map_err(|error| { + Status::invalid_argument(format!( + "merge_operations[{index}].target binary is invalid: {error}" + )) + })?; + } // An omitted binary declaration must never become any-binary authority. // The wire format permits both fields, so enforce the exclusive choice here. let binaries = match (target.any_binary, target.binaries.is_empty()) { @@ -6987,6 +6990,14 @@ fn validate_operator_merged_credential_policy( validate_uninspected_credentialed_endpoints(effective_policy) } +fn validate_merged_authored_contract(policy: &ProtoSandboxPolicy) -> Result<(), Status> { + // Fragment validation cannot see document-level limits. Project the full + // merged base and lower it through the same portable contract used by + // replacement requests before a proposal is staged or persisted. + let authored = project_public_policy(policy)?; + lower_public_policy(authored).map(|_| ()) +} + fn stage_validated_merge_operation( current_policy: &ProtoSandboxPolicy, operation: &PolicyMergeOp, @@ -6997,6 +7008,7 @@ fn stage_validated_merge_operation( .map_err(map_policy_merge_error)?; let candidate = merged.policy; validate_canonical_policy_size(&candidate, "merge_operations")?; + validate_merged_authored_contract(&candidate)?; validate_policy_safety(&candidate)?; validate_candidate_effective_policy(&candidate, validation_context.provider_layers)?; let mut effective = if validation_context.provider_layers.is_empty() { @@ -7060,6 +7072,7 @@ async fn apply_merge_operations_with_retry( let merged = merge_policy(current_policy, operations).map_err(map_policy_merge_error)?; let new_policy = merged.policy; validate_canonical_policy_size(&new_policy, "merge_operations")?; + validate_merged_authored_contract(&new_policy)?; let hash = deterministic_policy_hash(&new_policy); if let Some(baseline_policy) = baseline_policy { @@ -7610,14 +7623,14 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tonic::Code; - fn authored_policy(mut policy: ProtoSandboxPolicy) -> authored::SandboxPolicy { + fn authored_policy(mut policy: ProtoSandboxPolicy) -> authored::PolicyDocument { if policy.version == 0 { policy.version = 1; } openshell_policy::project_base_policy(&policy).expect("test policy must be authorable") } - fn authored_mcp_policy_with_versions(versions: &[&str]) -> authored::SandboxPolicy { + fn authored_mcp_policy_with_versions(versions: &[&str]) -> authored::PolicyDocument { let mut policy = authored_policy(mcp_policy_with_versions(&["2025-11-25"])); policy.network_policies.get_mut("mcp").unwrap().endpoints[0] .mcp @@ -7667,13 +7680,13 @@ mod tests { } } - impl IntoTestPolicy for ProtoSandboxPolicy { - fn into_test_policy(self) -> authored::SandboxPolicy { + impl IntoTestPolicy for ProtoSandboxPolicy { + fn into_test_policy(self) -> authored::PolicyDocument { authored_policy(self) } } - impl IntoTestPolicy for authored::SandboxPolicy { + impl IntoTestPolicy for authored::PolicyDocument { fn into_test_policy(self) -> ProtoSandboxPolicy { openshell_policy::lower_authored_policy(self).expect("test policy must lower") } @@ -8461,7 +8474,7 @@ mod tests { #[test] fn public_policy_boundary_rejects_structurally_incomplete_messages() { - let mut missing_allow = authored::SandboxPolicy { + let mut missing_allow = authored::PolicyDocument { version: 1, ..Default::default() }; @@ -8470,7 +8483,7 @@ mod tests { authored::NetworkPolicyRule { endpoints: vec![authored::NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], rules: vec![authored::L7Rule { allow: None }], ..Default::default() }], @@ -8480,7 +8493,7 @@ mod tests { let mut missing_matcher_kind = authored_policy( openshell_policy::parse_sandbox_policy( - "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: api.example.com, port: 443 }\n", + "version: 1\nnetwork_policies:\n api:\n endpoints:\n - { host: api.example.com, ports: [443] }\n", ) .unwrap(), ); @@ -8497,7 +8510,7 @@ mod tests { }), }]; - let mut oversized_port = authored::SandboxPolicy { + let mut oversized_port = authored::PolicyDocument { version: 1, ..Default::default() }; @@ -8506,7 +8519,7 @@ mod tests { authored::NetworkPolicyRule { endpoints: vec![authored::NetworkEndpoint { host: "api.example.com".to_string(), - port: 65_536, + ports: vec![65_536], ..Default::default() }], ..Default::default() @@ -8517,7 +8530,7 @@ mod tests { ("missing allow", missing_allow), ("missing matcher kind", missing_matcher_kind), ("oversized port", oversized_port), - ("missing version", authored::SandboxPolicy::default()), + ("missing version", authored::PolicyDocument::default()), ] { let Err(error) = lower_public_policy(policy) else { panic!("{name} unexpectedly lowered"); @@ -8580,14 +8593,29 @@ mod tests { for (case, policy) in cases { let sandbox_id = format!("stored-invalid-{case}"); + let sandbox_name = format!("stored-invalid-{case}"); + let valid_shell = test_sandbox( + &sandbox_id, + &sandbox_name, + openshell_policy::restrictive_default_policy(), + Vec::new(), + ); + let payload = crate::storage_proto::encode_sandbox(&valid_shell) + .expect("encode valid durable sandbox shell"); + let mut stored = crate::storage_proto::StoredSandbox::decode(payload.as_slice()) + .expect("decode durable sandbox shell"); + stored.spec.as_mut().expect("stored sandbox spec").policy = Some(policy); state .store - .put_message(&test_sandbox( + .put_if( + "sandbox", &sandbox_id, - &format!("stored-invalid-{case}"), - policy, - Vec::new(), - )) + &sandbox_name, + "default", + &stored.encode_to_vec(), + None, + crate::persistence::WriteCondition::MustCreate, + ) .await .expect("store legacy sandbox spec"); @@ -8604,9 +8632,11 @@ mod tests { .await .expect_err("invalid stored spec must fail before history backfill"); - assert_eq!(error.code(), Code::FailedPrecondition, "{case}"); + // The public Sandbox projection now validates while decoding the + // durable internal policy, before config admission can inspect it. + assert_eq!(error.code(), Code::Internal, "{case}"); assert!( - error.message().contains(STORED_POLICY_SOURCE_SPEC), + error.message().contains("decode sandbox payload failed"), "{case}" ); assert!( @@ -8655,6 +8685,51 @@ mod tests { ); } + #[test] + fn incremental_merge_rejects_policy_over_portable_rule_limit() { + let mut current = ProtoSandboxPolicy { + version: 1, + ..Default::default() + }; + for index in 0..1024 { + current.network_policies.insert( + format!("rule-{index}"), + NetworkPolicyRule { + endpoints: vec![NetworkEndpoint { + host: format!("api-{index}.example.com"), + port: 443, + ..Default::default() + }], + ..Default::default() + }, + ); + } + let operation = PolicyMergeOp::AddRule { + rule_name: "rule-over-limit".to_string(), + rule: NetworkPolicyRule { + endpoints: vec![NetworkEndpoint { + host: "overflow.example.com".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }, + }; + + let error = stage_validated_merge_operation( + ¤t, + &operation, + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, + ) + .expect_err("a 1025th network policy must be rejected"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("network_policies"), "{error}"); + } + #[tokio::test] async fn get_sandbox_config_backfills_canonical_spec_policy_bytes_and_hash() { let state = test_server_state().await; @@ -10444,6 +10519,9 @@ mod tests { let mut target = l7_scope_target(); target.binaries[0].path.clear(); invalid_targets.push(target); + let mut target = l7_scope_target(); + target.binaries[0].path = "x".repeat(4097); + invalid_targets.push(target); for target in invalid_targets { for deny in [false, true] { diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 02e991940..ac9e220c8 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1165,13 +1165,13 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin .endpoints .iter() .flat_map(|endpoint| { - endpoint_ports(endpoint.port, &endpoint.ports) - .into_iter() - .map(move |port| StaticCredentialEndpointBinding { + endpoint.ports.iter().copied().map(move |port| { + StaticCredentialEndpointBinding { host: endpoint.host.clone(), port, path: endpoint.path.clone(), - }) + } + }) }) .collect::>() }); @@ -1521,7 +1521,7 @@ fn insert_dynamic_credentials_for_profile( continue; } for endpoint in &profile.endpoints { - for port in endpoint_ports(endpoint.port, &endpoint.ports) { + for &port in &endpoint.ports { insert_dynamic_credentials_for_endpoint( dynamic_creds, &endpoint.host, @@ -1536,14 +1536,6 @@ fn insert_dynamic_credentials_for_profile( } } -fn endpoint_ports(port: u32, ports: &[u32]) -> Vec { - if ports.is_empty() { - if port == 0 { Vec::new() } else { vec![port] } - } else { - ports.iter().copied().filter(|port| *port != 0).collect() - } -} - fn dynamic_credential_key( host: &str, port: u32, @@ -2104,7 +2096,7 @@ fn dynamic_token_grant_bindings_for_profile( continue; } for endpoint in &profile.endpoints { - for port in endpoint_ports(endpoint.port, &endpoint.ports) { + for &port in &endpoint.ports { push_dynamic_token_grant_bindings_for_endpoint( &mut bindings, provider_name, @@ -3676,10 +3668,10 @@ async fn profile_attached_sandbox_diagnostics( .credentials .keys() .any(|key| !is_non_injectable_provider_credential(&provider, key)); - let has_usable_endpoint = profile.to_proto().endpoints.iter().any(|endpoint| { - !endpoint_ports(endpoint.port, &endpoint.ports).is_empty() - && !endpoint.host.trim().is_empty() - }); + let has_usable_endpoint = + profile.to_proto().endpoints.iter().any(|endpoint| { + !endpoint.ports.is_empty() && !endpoint.host.trim().is_empty() + }); let has_policy_binding = super::policy::policy_has_credential_binding_for_provider( &base_policy, provider_name, @@ -5352,7 +5344,7 @@ mod tests { .iter() .map(|(host, _)| NetworkEndpoint { host: (*host).to_string(), - port: 80, + ports: vec![80], ..Default::default() }) .collect(), @@ -5387,7 +5379,7 @@ mod tests { profile.credentials = vec![token_grant_credential("access_token")]; profile.endpoints = vec![NetworkEndpoint { host: host.to_string(), - port, + ports: vec![port], path: path.to_string(), protocol: "rest".to_string(), access: "full".to_string(), @@ -5530,7 +5522,7 @@ mod tests { profile.credentials = vec![token_grant_credential("access_token")]; profile.endpoints = vec![NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], path: "/v1/**".to_string(), protocol: "rest".to_string(), access: "full".to_string(), @@ -5620,7 +5612,7 @@ mod tests { updated_profile.display_name = "Updated API".to_string(); updated_profile.endpoints = vec![NetworkEndpoint { host: "api.updated.example".to_string(), - port: 443, + ports: vec![443], ..Default::default() }]; let response = handle_update_provider_profiles( @@ -5890,7 +5882,7 @@ mod tests { profile.credentials = vec![token_grant_credential("access_token")]; profile.endpoints = vec![NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], path: "/v1/**".to_string(), protocol: "rest".to_string(), access: "full".to_string(), @@ -6142,7 +6134,7 @@ mod tests { let mut profile = custom_profile(id); profile.endpoints.push(NetworkEndpoint { host: String::new(), - port: 0, + ports: vec![0], ..Default::default() }); profile @@ -6601,7 +6593,7 @@ mod tests { let mut initial_profile = custom_profile("fanout-ambiguity"); initial_profile.endpoints.push(NetworkEndpoint { host: "other.example.com".to_string(), - port: 443, + ports: vec![443], ..Default::default() }); let imported = handle_import_provider_profiles( @@ -6645,7 +6637,7 @@ mod tests { }), spec: Some(SandboxSpec { providers: vec!["fanout-provider".to_string()], - policy: Some(openshell_core::proto::policy::SandboxPolicy { + policy: Some(openshell_core::proto::policy::PolicyDocument { version: 1, network_policies: HashMap::from([( "base".to_string(), @@ -6653,7 +6645,7 @@ mod tests { name: "base".to_string(), endpoints: vec![NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], ..Default::default() }], ..Default::default() @@ -6672,7 +6664,7 @@ mod tests { conflicting_profile.resource_version = resource_version; conflicting_profile.endpoints.push(NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], tls: "skip".to_string(), ..Default::default() }); @@ -6889,6 +6881,10 @@ mod tests { #[tokio::test] async fn import_provider_profiles_rejects_mixed_batch_without_partial_import() { let state = test_server_state().await; + let mut oversized = custom_profile("bulk-bad"); + oversized.binaries.push(NetworkBinary { + path: "x".repeat(4097), + }); let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { @@ -6899,7 +6895,7 @@ mod tests { source: "bulk-one.yaml".to_string(), }, ProviderProfileImportItem { - profile: Some(custom_profile_with_invalid_endpoint("bulk-bad")), + profile: Some(oversized), source: "bulk-bad.yaml".to_string(), }, ProviderProfileImportItem { @@ -6920,8 +6916,10 @@ mod tests { assert!(response.profiles.is_empty()); assert!(response.diagnostics.iter().any(|diagnostic| { diagnostic.profile_id == "bulk-bad" - && diagnostic.field == "endpoints[0]" - && diagnostic.message.contains("invalid endpoint") + && diagnostic.field == "profile" + && diagnostic + .message + .contains("invalid authored network policy") })); for id in ["bulk-one", "bulk-two"] { @@ -7055,8 +7053,10 @@ mod tests { assert!(!response.valid); assert!(response.diagnostics.iter().any(|diagnostic| { diagnostic.profile_id == "lint-bad" - && diagnostic.field == "endpoints[0]" - && diagnostic.message.contains("invalid endpoint") + && diagnostic.field == "profile" + && diagnostic + .message + .contains("invalid authored network policy") })); for id in ["lint-one", "lint-two"] { @@ -9834,7 +9834,7 @@ mod tests { profile.credentials = vec![static_credential("token", "GITHUB_TOKEN", true)]; profile.endpoints = vec![NetworkEndpoint { host: "github.enterprise.example".to_string(), - port: 443, + ports: vec![443], allow_uninspected_credentials: true, ..Default::default() }]; @@ -10004,7 +10004,7 @@ mod tests { ]; profile.endpoints = vec![NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], allow_uninspected_credentials: true, ..Default::default() }]; @@ -11019,7 +11019,7 @@ mod tests { profile.credentials = vec![refreshable_credential("access_token", "ACCESS_TOKEN")]; profile.endpoints = vec![NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], path: "/v1/**".to_string(), protocol: "rest".to_string(), access: "full".to_string(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 6e3796b83..60e822474 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -3363,7 +3363,7 @@ mod tests { fn authored_policy( mut policy: openshell_core::proto::SandboxPolicy, - ) -> openshell_core::proto::policy::SandboxPolicy { + ) -> openshell_core::proto::policy::PolicyDocument { if policy.version == 0 { policy.version = 1; } @@ -3372,7 +3372,7 @@ mod tests { fn authored_mcp_policy_with_versions( versions: &[&str], - ) -> openshell_core::proto::policy::SandboxPolicy { + ) -> openshell_core::proto::policy::PolicyDocument { let mut policy = authored_policy(mcp_policy_with_versions(&["2025-11-25"])); policy.network_policies.get_mut("mcp").unwrap().endpoints[0] .mcp @@ -3431,7 +3431,7 @@ mod tests { request_id: String::new(), spec: Some(SandboxSpec { providers: vec!["github".to_string()], - policy: Some(openshell_core::proto::policy::SandboxPolicy { + policy: Some(openshell_core::proto::policy::PolicyDocument { version: 1, ..Default::default() }), @@ -3446,7 +3446,7 @@ mod tests { let created = Sandbox { spec: Some(SandboxSpec { providers: vec!["github".to_string()], - policy: Some(openshell_core::proto::policy::SandboxPolicy { + policy: Some(openshell_core::proto::policy::PolicyDocument { version: 1, ..Default::default() }), @@ -3841,7 +3841,7 @@ mod tests { }), spec: Some(SandboxSpec { log_level: "debug".to_string(), - policy: Some(openshell_core::proto::policy::SandboxPolicy { + policy: Some(openshell_core::proto::policy::PolicyDocument { version: 1, ..Default::default() }), diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 9ba3392e3..5af10b2cd 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -1417,9 +1417,9 @@ mod tests { #[test] fn validate_sandbox_spec_rejects_oversized_policy() { use openshell_core::proto::policy::NetworkPolicyRule; - use openshell_core::proto::policy::SandboxPolicy as ProtoSandboxPolicy; + use openshell_core::proto::policy::PolicyDocument as ProtoPolicyDocument; - let mut policy = ProtoSandboxPolicy::default(); + let mut policy = ProtoPolicyDocument::default(); let big_name = "x".repeat(MAX_POLICY_SIZE); policy .network_policies diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 8172502b5..9e44c98ab 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -678,8 +678,9 @@ mod tests { }), ..Default::default() }; - let result = - project_policy_revision_onto_sandbox(&write, &sandbox.encode_to_vec(), 1); + let payload = crate::storage_proto::encode_sandbox(&sandbox) + .expect("encode durable sandbox fixture"); + let result = project_policy_revision_onto_sandbox(&write, &payload, 1); if activated == Some(false) && state != Admission::Accepted { let (projected, changed) = result.unwrap(); assert!(changed); diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 8bcad0d63..fb8fe06f1 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -924,7 +924,7 @@ mod tests { .endpoints .push(openshell_core::proto::policy::NetworkEndpoint { host: "mcp.example.com".to_string(), - port: 443, + ports: vec![443], protocol: "mcp".to_string(), mcp: Some(openshell_core::proto::policy::McpConfig { versions: versions @@ -950,7 +950,7 @@ mod tests { .endpoints .push(openshell_core::proto::policy::NetworkEndpoint { host: "mcp.example.com".to_string(), - port: 443, + ports: vec![443], protocol: "mcp".to_string(), mcp: None, rules: vec![openshell_core::proto::policy::L7Rule { diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 6a0ab5184..31957b9dd 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -264,7 +264,7 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "1df02ba6a9656566dea0388ba9fbcf84bb56895db7ec97ffa2d44fa0636e4fd4"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "2c601672270745be88fc78cf24338b714ba2e6ea0c7b137aab468f864e9cb684"; + "3254980f1f6a8503f9c338666ad60e0d901b1c63fabd1ed6fc6f0b362f6d9cfe"; const DURABLE_SCHEMA_SHA256: &str = "23c871a4cb4390be6e7d3ba7bfde3284f803927eac72f19a0e52286457edce7d"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = @@ -770,6 +770,59 @@ mod tests { hex::decode(encoded).expect("checked-in legacy fixture must be valid hex") } + fn legacy_endpoint_with_duplicate_set_values() -> openshell_core::proto::NetworkEndpoint { + let mut policy = openshell_policy::parse_sandbox_policy( + r" +version: 1 +network_policies: + legacy: + endpoints: + - host: legacy.example.com + ports: [443] + protocol: mcp + mcp: {} + rules: + - allow: + method: tools/call + tool: + any: + values: [read_status] +", + ) + .expect("valid baseline policy"); + let mut endpoint = policy + .network_policies + .remove("legacy") + .unwrap() + .endpoints + .pop() + .unwrap(); + endpoint.ports.push(443); + + let duplicate_matcher = openshell_core::proto::L7QueryMatcher { + glob: String::new(), + any: vec!["same".to_string(), "same".to_string()], + }; + let allow = endpoint.rules[0].allow.as_mut().unwrap(); + allow + .query + .insert("state".to_string(), duplicate_matcher.clone()); + allow + .params + .insert("arguments.mode".to_string(), duplicate_matcher.clone()); + allow.params.get_mut("name").unwrap().any = vec!["read_status".into(); 2]; + endpoint.deny_rules.push(openshell_core::proto::L7DenyRule { + method: "tools/call".to_string(), + query: HashMap::from([("state".to_string(), duplicate_matcher.clone())]), + params: HashMap::from([ + ("name".to_string(), duplicate_matcher.clone()), + ("arguments.mode".to_string(), duplicate_matcher), + ]), + ..Default::default() + }); + endpoint + } + #[test] fn sandbox_payload_without_endpoint_status_decodes() { use openshell_core::proto::{Sandbox, SandboxPhase}; @@ -801,8 +854,9 @@ network_policies: name: mcp endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp + enforcement: enforce rules: - allow: method: tools/call @@ -824,15 +878,73 @@ network_policies: let stored = StoredSandbox::decode(payload.as_slice()).unwrap(); let internal = stored.spec.unwrap().policy.unwrap(); assert!(internal.network_policies["mcp"].endpoints[0].mcp.is_some()); + assert!( + Sandbox::decode(payload.as_slice()).is_err(), + "the private durable envelope must not be decoded as the public API message" + ); let decoded = decode_sandbox(&payload).unwrap(); let decoded_policy = decoded.spec.unwrap().policy.unwrap(); + assert_eq!( + decoded_policy.network_policies["mcp"].endpoints[0].enforcement, + "enforce" + ); assert_eq!( openshell_policy::lower_authored_policy(decoded_policy).unwrap(), openshell_policy::lower_authored_policy(policy).unwrap() ); } + #[test] + fn legacy_duplicate_ports_survive_sandbox_status_rewrite() { + let mut internal_policy = openshell_core::proto::SandboxPolicy { + version: 1, + ..Default::default() + }; + internal_policy.network_policies.insert( + "legacy".to_string(), + openshell_core::proto::NetworkPolicyRule { + name: "legacy".to_string(), + endpoints: vec![legacy_endpoint_with_duplicate_set_values()], + ..Default::default() + }, + ); + let payload = StoredSandbox { + spec: Some(StoredSandboxSpec { + policy: Some(internal_policy), + ..Default::default() + }), + ..Default::default() + } + .encode_to_vec(); + + let mut sandbox = decode_sandbox(&payload).expect("legacy sandbox should decode"); + assert_eq!( + sandbox + .spec + .as_ref() + .unwrap() + .policy + .as_ref() + .unwrap() + .network_policies["legacy"] + .endpoints[0] + .ports, + [443] + ); + sandbox.status = Some(SandboxStatus { + phase: SandboxPhase::Ready as i32, + ..Default::default() + }); + + let rewritten = encode_sandbox(&sandbox).expect("status-only rewrite should encode"); + let stored = StoredSandbox::decode(rewritten.as_slice()).unwrap(); + assert_eq!( + stored.spec.unwrap().policy.unwrap().network_policies["legacy"].endpoints[0].ports, + [443] + ); + } + #[test] fn provider_profile_storage_keeps_policy_internal_and_projects_on_read() { let policy = openshell_policy::parse_authored_policy( @@ -843,7 +955,7 @@ network_policies: name: profile endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp rules: - allow: @@ -884,6 +996,27 @@ network_policies: ); } + #[test] + fn legacy_duplicate_ports_survive_provider_profile_rewrite() { + let payload = StoredProviderProfileWire { + profile: Some(StoredProviderProfileData { + id: "legacy-profile".to_string(), + display_name: "Legacy Profile".to_string(), + endpoints: vec![legacy_endpoint_with_duplicate_set_values()], + ..Default::default() + }), + ..Default::default() + } + .encode_to_vec(); + + let profile = decode_provider_profile(&payload).expect("legacy profile should decode"); + assert_eq!(profile.profile.as_ref().unwrap().endpoints[0].ports, [443]); + + let rewritten = encode_provider_profile(&profile).expect("profile rewrite should encode"); + let stored = StoredProviderProfileWire::decode(rewritten.as_slice()).unwrap(); + assert_eq!(stored.profile.unwrap().endpoints[0].ports, [443]); + } + #[test] fn pre_move_storage_payloads_decode_after_package_relocation() { let refresh = StoredProviderCredentialRefreshState::decode( diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 586454f48..d11fe5747 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -3439,7 +3439,7 @@ network_policies: name: parity endpoints: - host: rest.parity.test - port: 443 + ports: [443] path: /items/** protocol: rest enforcement: enforce @@ -3447,7 +3447,7 @@ network_policies: rules: - allow: { method: GET, path: /items/** } - host: graphql.parity.test - port: 443 + ports: [443] path: /graphql protocol: graphql enforcement: enforce @@ -3458,7 +3458,7 @@ network_policies: operation_name: GetWidget fields: [id, name] - host: websocket.parity.test - port: 443 + ports: [443] path: /graphql protocol: websocket enforcement: enforce @@ -3469,7 +3469,7 @@ network_policies: operation_type: subscription fields: [messageAdded] - host: jsonrpc.parity.test - port: 443 + ports: [443] path: /rpc protocol: json-rpc enforcement: enforce @@ -3477,7 +3477,7 @@ network_policies: rules: - allow: { method: status.get } - host: mcp.parity.test - port: 443 + ports: [443] path: /mcp protocol: mcp enforcement: enforce @@ -3665,7 +3665,7 @@ network_policies: name: matchers endpoints: - host: matchers.parity.test - port: 443 + ports: [443] protocol: {protocol} enforcement: enforce rules: @@ -3777,7 +3777,7 @@ network_policies: name: parity endpoints: - host: sql-l4.parity.test - port: 443 + ports: [443] protocol: "{protocol}" {fields} binaries: @@ -8741,7 +8741,7 @@ network_policies: name: native_tcp endpoints: - host: database.example.com - port: 5432 + ports: [5432] protocol: tcp binaries: - path: /usr/bin/client @@ -8789,7 +8789,7 @@ network_policies: name: native_tcp endpoints: - host: database.example.com - port: 5432 + ports: [5432] protocol: tcp credential_binding: provider: database @@ -10977,7 +10977,7 @@ network_policies: grant: endpoints: - host: example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: [{{ allow: {{ method: GET, path: "/**" }} }}] @@ -10985,7 +10985,7 @@ network_policies: deny: endpoints: - host: example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: [{{ allow: {{ method: GET, path: "/**" }} }}] diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index b6da3ebec..bf287177a 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -698,7 +698,15 @@ fn summarize_chunk_for_audit(chunk: &PolicyChunk) -> String { }; let endpoint = rule.endpoints.first().map_or_else( || "unknown".to_string(), - |ep| format!("{}:{}", ep.host, ep.port), + |ep| { + let ports = ep + .ports + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + format!("{}:[{ports}]", ep.host) + }, ); let l7 = rule .endpoints @@ -1141,12 +1149,9 @@ fn network_endpoint_from_json( return Err(reason.to_string()); } - let mut ports = endpoint.ports; - if ports.is_empty() && endpoint.port > 0 { - ports.push(endpoint.port); - } + let ports = endpoint.ports; if ports.is_empty() { - return Err("endpoint.port or endpoint.ports is required".to_string()); + return Err("endpoint.ports is required".to_string()); } if endpoint .rules @@ -1364,8 +1369,6 @@ struct NetworkPolicyRuleJson { struct NetworkEndpointJson { host: String, #[serde(default)] - port: u32, - #[serde(default)] ports: Vec, #[serde(default)] protocol: String, @@ -1436,7 +1439,7 @@ mod tests { "endpoints": [ { "host": "api.github.com", - "port": 443, + "ports": [443], "protocol": "rest", "tls": "terminate", "enforcement": "enforce", @@ -1470,8 +1473,7 @@ mod tests { let rule = chunks[0].proposed_rule.as_ref().unwrap(); assert_eq!(rule.name, "github_api_repo_create"); assert_eq!(rule.endpoints[0].host, "api.github.com"); - assert_eq!(rule.endpoints[0].port, 443); - assert!(rule.endpoints[0].ports.is_empty()); + assert_eq!(rule.endpoints[0].ports, vec![443]); assert_eq!(rule.endpoints[0].protocol, "rest"); assert!(openshell_policy::lower_authored_rule(&chunks[0].rule_name, rule.clone()).is_ok()); assert_eq!(rule.binaries[0].path, "/usr/bin/gh"); @@ -1492,7 +1494,7 @@ mod tests { "endpoints": [ { "host": "api.github.com", - "port": 443, + "ports": [443], "rules": [ { "allow": { @@ -1517,8 +1519,8 @@ mod tests { #[test] fn proposal_chunks_from_body_rejects_native_tcp_and_tls_skip() { for endpoint in [ - r#"{"host":"db.example.com","port":5432,"protocol":"tcp"}"#, - r#"{"host":"api.example.com","port":443,"tls":"skip"}"#, + r#"{"host":"db.example.com","ports":[5432],"protocol":"tcp"}"#, + r#"{"host":"api.example.com","ports":[443],"tls":"skip"}"#, ] { let body = format!( r#"{{ @@ -1543,7 +1545,7 @@ mod tests { "addRule": { "ruleName": "explicit_proxy", "rule": { - "endpoints": [{"host":"api.example.com","port":443}] + "endpoints": [{"host":"api.example.com","ports":[443]}] } } }] @@ -2030,7 +2032,7 @@ mod tests { ..Default::default() }; let summary = summarize_chunk_for_audit(&chunk); - assert!(summary.contains("api.github.com:443")); + assert!(summary.contains("api.github.com:[443]")); assert!(summary.contains("PUT /repos/foo/bar/contents/x.md")); assert!(summary.contains("/usr/bin/curl")); } diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index bb5733c66..874082ad7 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -2108,7 +2108,7 @@ trait StartupGateway: Send + Sync { async fn sync( &self, sandbox: &str, - policy: &openshell_core::proto::policy::SandboxPolicy, + policy: &openshell_core::proto::policy::PolicyDocument, workspace: &str, ) -> Result; async fn report( @@ -2142,7 +2142,7 @@ impl StartupGateway for RemoteStartupGateway { async fn sync( &self, sandbox: &str, - policy: &openshell_core::proto::policy::SandboxPolicy, + policy: &openshell_core::proto::policy::PolicyDocument, workspace: &str, ) -> Result { openshell_core::grpc_client::sync_policy_and_fetch_snapshot( @@ -5110,7 +5110,7 @@ network_policies: test: name: test endpoints: - - { host: example.com, port: 443 } + - { host: example.com, ports: [443] } binaries: - { path: /usr/bin/curl } "#, @@ -5201,7 +5201,7 @@ network_policies: name: redis endpoints: - host: redis.example.com - port: 6379 + ports: [6379] protocol: tcp binaries: - path: /usr/bin/redis-cli @@ -5274,7 +5274,7 @@ network_policies: async fn sync( &self, _sandbox: &str, - _policy: &openshell_core::proto::policy::SandboxPolicy, + _policy: &openshell_core::proto::policy::PolicyDocument, _workspace: &str, ) -> Result { self.snapshot("").await diff --git a/crates/openshell-supervisor/src/mechanistic_mapper.rs b/crates/openshell-supervisor/src/mechanistic_mapper.rs index 9a8c59c8e..7f806bdfa 100644 --- a/crates/openshell-supervisor/src/mechanistic_mapper.rs +++ b/crates/openshell-supervisor/src/mechanistic_mapper.rs @@ -534,7 +534,7 @@ mod tests { let rule = proposals[0].proposed_rule.as_ref().unwrap(); assert_eq!(rule.endpoints.len(), 1); assert_eq!(rule.endpoints[0].host, "api.example.com"); - assert_eq!(rule.endpoints[0].port, 443); + assert_eq!(rule.endpoints[0].ports, vec![443]); assert_eq!(rule.binaries.len(), 1); assert_eq!(rule.binaries[0].path, "/usr/bin/curl"); // No L7 fields when no samples provided. diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 11b0c221e..f40c60f27 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -3366,10 +3366,13 @@ impl App { } else { format!(" path={}", endpoint.path) }; - format!( - "{}:{} {protocol} {access}{path}", - endpoint.host, endpoint.port - ) + let ports = endpoint + .ports + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + format!("{}:{ports} {protocol} {access}{path}", endpoint.host) }) .collect::>(); if lines.is_empty() { diff --git a/crates/openshell-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs index 9bde99364..025e548a7 100644 --- a/crates/openshell-tui/src/ui/sandbox_draft.rs +++ b/crates/openshell-tui/src/ui/sandbox_draft.rs @@ -824,10 +824,16 @@ fn validation_issue_summary(validation: &str) -> String { } fn format_endpoint_summary(endpoint: &NetworkEndpoint) -> String { - let host_port = if endpoint.port > 0 { - format!("{}:{}", endpoint.host, endpoint.port) - } else { + let host_port = if endpoint.ports.is_empty() { endpoint.host.clone() + } else { + let ports = endpoint + .ports + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + format!("{}:{ports}", endpoint.host) }; let mut tags = vec![endpoint_layer_label(endpoint).to_string()]; diff --git a/docs/get-started/tutorials/first-network-policy.mdx b/docs/get-started/tutorials/first-network-policy.mdx index cbf9eaaf9..9d1d926ac 100644 --- a/docs/get-started/tutorials/first-network-policy.mdx +++ b/docs/get-started/tutorials/first-network-policy.mdx @@ -107,7 +107,7 @@ network_policies: name: github-api-readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only diff --git a/docs/get-started/tutorials/github-sandbox.mdx b/docs/get-started/tutorials/github-sandbox.mdx index e420bb697..53693196d 100644 --- a/docs/get-started/tutorials/github-sandbox.mdx +++ b/docs/get-started/tutorials/github-sandbox.mdx @@ -201,11 +201,11 @@ network_policies: claude_code: name: claude-code endpoints: - - { host: api.anthropic.com, port: 443, protocol: rest, enforcement: enforce, access: full } - - { host: statsig.anthropic.com, port: 443 } - - { host: sentry.io, port: 443 } - - { host: raw.githubusercontent.com, port: 443 } - - { host: platform.claude.com, port: 443 } + - { host: api.anthropic.com, ports: [443], protocol: rest, enforcement: enforce, access: full } + - { host: statsig.anthropic.com, ports: [443] } + - { host: sentry.io, ports: [443] } + - { host: raw.githubusercontent.com, ports: [443] } + - { host: platform.claude.com, ports: [443] } binaries: - { path: /usr/local/bin/claude } - { path: /usr/bin/node } @@ -214,7 +214,7 @@ network_policies: nvidia_inference: name: nvidia-inference endpoints: - - { host: integrate.api.nvidia.com, port: 443 } + - { host: integrate.api.nvidia.com, ports: [443] } binaries: - { path: /usr/bin/curl } - { path: /bin/bash } @@ -226,7 +226,7 @@ network_policies: name: github-git endpoints: - host: github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -248,7 +248,7 @@ network_policies: name: github-api endpoints: - host: api.github.com - port: 443 + ports: [443] path: "/repos///**" protocol: rest enforcement: enforce @@ -258,7 +258,7 @@ network_policies: method: "*" path: "/repos///**" - host: api.github.com - port: 443 + ports: [443] path: "/graphql" protocol: graphql enforcement: enforce @@ -283,12 +283,12 @@ network_policies: pypi: name: pypi endpoints: - - { host: pypi.org, port: 443 } - - { host: files.pythonhosted.org, port: 443 } - - { host: github.com, port: 443 } - - { host: objects.githubusercontent.com, port: 443 } - - { host: api.github.com, port: 443 } - - { host: downloads.python.org, port: 443 } + - { host: pypi.org, ports: [443] } + - { host: files.pythonhosted.org, ports: [443] } + - { host: github.com, ports: [443] } + - { host: objects.githubusercontent.com, ports: [443] } + - { host: api.github.com, ports: [443] } + - { host: downloads.python.org, ports: [443] } binaries: - { path: /sandbox/.venv/bin/python } - { path: /sandbox/.venv/bin/python3 } @@ -302,11 +302,11 @@ network_policies: vscode: name: vscode endpoints: - - { host: update.code.visualstudio.com, port: 443 } - - { host: "*.vo.msecnd.net", port: 443 } - - { host: vscode.download.prss.microsoft.com, port: 443 } - - { host: marketplace.visualstudio.com, port: 443 } - - { host: "*.gallerycdn.vsassets.io", port: 443 } + - { host: update.code.visualstudio.com, ports: [443] } + - { host: "*.vo.msecnd.net", ports: [443] } + - { host: vscode.download.prss.microsoft.com, ports: [443] } + - { host: marketplace.visualstudio.com, ports: [443] } + - { host: "*.gallerycdn.vsassets.io", ports: [443] } binaries: - { path: /usr/bin/curl } - { path: /usr/bin/wget } diff --git a/docs/get-started/tutorials/inference-ollama.mdx b/docs/get-started/tutorials/inference-ollama.mdx index c23e6eaf6..519379487 100644 --- a/docs/get-started/tutorials/inference-ollama.mdx +++ b/docs/get-started/tutorials/inference-ollama.mdx @@ -88,7 +88,7 @@ inference_capable: true credentials: [] endpoints: - host: host.openshell.internal - port: 11434 + ports: [11434] protocol: rest access: read-write enforcement: enforce diff --git a/docs/get-started/tutorials/local-inference-lmstudio.mdx b/docs/get-started/tutorials/local-inference-lmstudio.mdx index 4f7ffc7ae..87e1497ac 100644 --- a/docs/get-started/tutorials/local-inference-lmstudio.mdx +++ b/docs/get-started/tutorials/local-inference-lmstudio.mdx @@ -63,7 +63,7 @@ inference_capable: true credentials: [] endpoints: - host: host.openshell.internal - port: 1234 + ports: [1234] protocol: rest access: read-write enforcement: enforce diff --git a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx index a07bfabdb..427689728 100644 --- a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx +++ b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx @@ -81,7 +81,7 @@ credentials: secret: true endpoints: - host: graph.microsoft.com - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce diff --git a/docs/providers/aws-sigv4.mdx b/docs/providers/aws-sigv4.mdx index 101d796e5..3e37aa2ed 100644 --- a/docs/providers/aws-sigv4.mdx +++ b/docs/providers/aws-sigv4.mdx @@ -72,7 +72,7 @@ network_policies: aws_bedrock: endpoints: - host: bedrock-runtime.us-east-1.amazonaws.com - port: 443 + ports: [443] protocol: rest credential_binding: provider: aws-prod @@ -95,7 +95,7 @@ network_policies: aws_s3: endpoints: - host: "*.s3.us-east-1.amazonaws.com" - port: 443 + ports: [443] protocol: rest access: full credential_binding: @@ -111,7 +111,7 @@ network_policies: aws_sts: endpoints: - host: sts.us-east-1.amazonaws.com - port: 443 + ports: [443] protocol: rest access: full credential_binding: @@ -150,7 +150,7 @@ For endpoints where the region cannot be inferred from the hostname, set `signin ```yaml endpoints: - host: custom-vpc-endpoint.example.com - port: 443 + ports: [443] protocol: rest access: full credential_binding: diff --git a/docs/providers/google-cloud.mdx b/docs/providers/google-cloud.mdx index 9168be25e..d73fc7a98 100644 --- a/docs/providers/google-cloud.mdx +++ b/docs/providers/google-cloud.mdx @@ -171,7 +171,7 @@ network_policies: name: gcp-apis endpoints: - host: "*.googleapis.com" - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/docs/providers/profiles.mdx b/docs/providers/profiles.mdx index d89299720..f66c89c7b 100644 --- a/docs/providers/profiles.mdx +++ b/docs/providers/profiles.mdx @@ -84,7 +84,7 @@ from the profile to `api.example.com:443` under `/v1`: ```yaml showLineNumbers={false} endpoints: - host: api.example.com - port: 443 + ports: [443] path: /v1/** ``` @@ -103,7 +103,7 @@ network_policies: gcp_storage: endpoints: - host: storage.googleapis.com - port: 443 + ports: [443] protocol: rest access: full credential_binding: @@ -127,7 +127,7 @@ network_policies: aws_s3: endpoints: - host: s3.us-west-2.amazonaws.com - port: 443 + ports: [443] protocol: rest access: full credential_binding: @@ -380,13 +380,12 @@ discovery: endpoints: - host: api.example.com - port: 443 + ports: [443] path: /v1/** protocol: rest tls: "" enforcement: enforce allowed_ips: [] - ports: [] allow_encoded_slash: false websocket_credential_rewrite: false request_body_credential_rewrite: false @@ -883,18 +882,18 @@ credentials: header_name: authorization endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce - host: api.github.com - port: 443 + ports: [443] path: /graphql protocol: graphql access: read-only enforcement: enforce - host: github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -915,7 +914,7 @@ network_policies: name: custom_pypi endpoints: - host: pypi.org - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce @@ -926,18 +925,18 @@ network_policies: name: _provider_work_github endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce - host: api.github.com - port: 443 + ports: [443] path: /graphql protocol: graphql access: read-only enforcement: enforce - host: github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 09d7a3e9b..16c0168eb 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -60,10 +60,10 @@ strings are intended. Middleware config integers must be exactly representable as protobuf `double` values, so integers that would round above the 53-bit precision range are rejected. -ProtoJSON treats YAML `null` as an unset field. Do not use `null` for matchers -or selectors. For example, `tool: null` removes the tool selector instead of -matching a null value. `version: null` becomes the default value `0` and fails -policy validation. +OpenShell rejects YAML `null` for every typed protobuf field. Omit an optional +field instead. Null remains valid only as data nested inside a middleware +`config` object, which uses `google.protobuf.Struct` and intentionally accepts +arbitrary JSON values. The gateway separately limits the canonical protobuf policy to 256 KiB. This limit applies after defaults and normalization to sandbox creation, full policy @@ -78,12 +78,11 @@ query matcher names, GraphQL persisted-query names, and recursively nested MCP An absent `filesystem_policy` resolves to the runtime-effective `include_workdir: true`. An explicitly present `filesystem_policy: {}` keeps -`include_workdir: false`. During protobuf conversion, `ports` takes precedence -over scalar `port`, one effective port serializes in compact scalar form, empty -rule names fall back to their map key, and runtime-only provenance fields are -omitted. Because proto3 scalar fields do not preserve presence, protobuf `version: 0` means omission. Canonical serialization materializes -`version: 1`; any other unsupported protobuf version is rejected. A protobuf port above -65535 is rejected rather than clamped. +`include_workdir: false`. Public endpoints have one port representation: +`ports` must be a non-empty list of unique integers from 1 through 65535. +Empty rule names fall back to their map key, and runtime-only provenance fields +are omitted. The document must set `version: 1`; protobuf's default value `0` +is invalid. A protobuf port above 65535 is rejected rather than clamped. ## Policy Load Diagnostics @@ -221,7 +220,7 @@ Each entry in the `network_policies` map has the following fields: |---|---|---|---| | `name` | string | No | Display name for the policy entry. Used in log output. Defaults to the map key. | | `endpoints` | list of endpoint objects | Yes | Hosts and ports this entry permits. | -| `binaries` | list of binary objects | Yes | Executables allowed to connect to these endpoints. | +| `binaries` | list of binary objects | No | Executables allowed to connect to these endpoints. Omitted and empty lists are equivalent and match no executable, so the entry grants no network access until it names at least one binary. | ### Endpoint Object @@ -230,7 +229,7 @@ Each endpoint defines a reachable destination and optional inspection rules. | Field | Type | Required | Description | |---|---|---|---| | `host` | string | Conditional | Hostname or IP address. Required for `protocol: tcp`; transparent TCP requires a valid DNS hostname and rejects literal IPs. A non-TCP proxy endpoint may omit `host` only when `allowed_ips` supplies the destination constraint. Supports a `*` wildcard inside the first DNS label only: `*.example.com`, `**.example.com`, and intra-label patterns like `*-aiplatform.googleapis.com` are accepted; bare `*`/`**`, TLD wildcards (`*.com`), and wildcards outside the first label are rejected at load time. Prefer exact hosts for `protocol: tcp`: a wildcard authorizes DNS queries for all matching names and can provide a DNS-label exfiltration channel. | -| `port` | integer | Yes | TCP port number. | +| `ports` | list of integers | Yes | Non-empty, unique TCP port numbers from `1` through `65535`. Use a one-item list for a single port. | | `path` | string | No | Optional HTTP path glob used to select between L7 endpoints that share the same host and port. Empty means all paths. Use this when REST and GraphQL live under the same host, such as `/repos/**` and `/graphql`. | | `protocol` | string | No | Set to `tcp` with a valid DNS hostname to allow native TCP clients through policy DNS and transparent capture without payload inspection. Omit the field for L4 passthrough through an explicit proxy, including legacy hostless `allowed_ips` endpoints. Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | | `tls` | string | No | TLS handling mode. The proxy auto-detects TLS by peeking the first bytes of each connection and terminates it for inspected HTTPS traffic, so this field is optional in most cases. Set to `skip` to disable auto-detection for edge cases such as client-certificate mTLS or non-standard protocols. Provider-credentialed endpoints reject `tls: skip` unless `allow_uninspected_credentials` is explicitly set. The values `terminate` and `passthrough` are deprecated and log a warning; they are still accepted for backward compatibility but have no effect on behavior. Remove either value and omit `tls` to use the equivalent automatic handling. Other values are rejected before activation. | @@ -251,21 +250,17 @@ Each endpoint defines a reachable destination and optional inspection rules. | `persisted_queries` | string | No | GraphQL hash-only behavior for `protocol: graphql` and GraphQL-over-WebSocket operation policy. Default is `deny`; use `allow_registered` only with `graphql_persisted_queries`. | | `graphql_persisted_queries` | map | No | Trusted GraphQL persisted-query registry keyed by hash or saved-query ID. Values contain `operation_type`, optional `operation_name`, and optional root `fields`. | | `graphql_max_body_bytes` | integer | No | Maximum GraphQL-over-HTTP request body bytes buffered for inspection. Defaults to `65536`. | -| `mcp` | object | No | MCP endpoint options for `protocol: mcp`. Omit or set this field to `null` to use all MCP endpoint defaults, including the exact `2025-11-25` revision. The object is rejected on other protocols. Every MCP endpoint must still set a concrete `host` and `port` or `ports`; an entry containing only `protocol: mcp` is invalid and is not treated as a wildcard endpoint. | -| `mcp.versions` | list of string | No | Allowlist of exact supported MCP core revisions: `2025-03-26`, `2025-06-18`, and `2025-11-25`. Omitted, `null`, and empty lists resolve to `["2025-11-25"]`; they never mean latest or all known revisions. Nonempty values must be unique, contain no extra whitespace, and name a revision in the closed supported set. For every request except a valid standalone `initialize`, OpenShell selects the revision from one `MCP-Protocol-Version` header or, when the header is absent, the MCP specification's `2025-03-26` compatibility fallback. The selected revision must appear in this allowlist. Duplicate, empty, or unsupported header values receive `400 Bad Request`; a supported revision outside the allowlist receives `403 Forbidden`. The sessionless `2026-07-28` revision is not yet supported. | +| `mcp` | object | No | MCP endpoint options for `protocol: mcp`. Omit this field to use all MCP endpoint defaults, including the exact `2025-11-25` revision. The object is rejected on other protocols. Every MCP endpoint must still set a concrete `host` and non-empty `ports`; an entry containing only `protocol: mcp` is invalid and is not treated as a wildcard endpoint. | +| `mcp.versions` | list of string | No | Allowlist of exact supported MCP core revisions: `2025-03-26`, `2025-06-18`, and `2025-11-25`. Omitted and empty lists resolve to `["2025-11-25"]`; they never mean latest or all known revisions. Nonempty values must be unique, contain no extra whitespace, and name a revision in the closed supported set. For every request except a valid standalone `initialize`, OpenShell selects the revision from one `MCP-Protocol-Version` header or, when the header is absent, the MCP specification's `2025-03-26` compatibility fallback. The selected revision must appear in this allowlist. Duplicate, empty, or unsupported header values receive `400 Bad Request`; a supported revision outside the allowlist receives `403 Forbidden`. The sessionless `2026-07-28` revision is not yet supported. | | `mcp.max_body_bytes` | integer | No | Maximum MCP JSON-RPC-over-HTTP request body bytes buffered for inspection. Defaults to `65536`. | | `mcp.strict_tool_names` | bool | No | Defaults to `true`. Requires `tools/call` `params.name` values to match `^[A-Za-z0-9_.-]{1,128}$` before policy evaluation. Set to `false` only for compatibility with MCP servers that intentionally use non-recommended tool names. Wildcard `tool` matchers require this to remain enabled. | | `mcp.allow_all_known_mcp_methods` | bool | No | Defaults to `false`. When `true`, enables the endpoint MCP method profile: omitted `rules` allow all MCP-family methods and all tools before `deny_rules`, and omitted rule `method` uses that profile. When unset or `false`, explicit MCP method rules are required; rules with `tool` or `params.name` must set `method: tools/call`. | | `json_rpc` | object | No | JSON-RPC endpoint options. For `protocol: json-rpc`, `json_rpc.max_body_bytes` sets the maximum JSON-RPC-over-HTTP request body bytes buffered for inspection. Defaults to `65536`. | -The YAML representation keeps the names shown above. Protobuf and generated SDK -clients use `NetworkTlsMode`, `NetworkEnforcementMode`, and -`NetworkAccessPreset` enums for these fields. This is a breaking source and wire -contract change for clients generated from the earlier string fields. -Regenerate bindings from the current protobuf definitions and replace string -assignments with the corresponding enum values. Unspecified TLS keeps automatic -handling, unspecified enforcement keeps the audit default, and unspecified -access selects no preset. Unknown enum numbers are rejected before activation. +The YAML representation, protobuf API, and generated SDKs use the string values +shown above. Unspecified TLS keeps automatic handling, unspecified enforcement +keeps the audit default, and unspecified access selects no preset. OpenShell +rejects other values before activation. **Validation constraints:** @@ -281,7 +276,7 @@ access selects no preset. Unknown enum numbers are rejected before activation. - `mcp` and `json-rpc` reject `access` presets; use explicit `rules`. - `json-rpc` requires explicit `rules` with `allow.method`. - `mcp` requires `rules` unless `mcp.allow_all_known_mcp_methods: true`. -- Policy YAML follows protobuf presence semantics. Omitted, `null`, and empty `mcp.versions` values all select the pinned `["2025-11-25"]` default because protobuf repeated fields do not preserve field presence. +- Policy YAML follows protobuf presence semantics. Omitted and empty `mcp.versions` values both select the pinned `["2025-11-25"]` default because protobuf repeated fields do not preserve field presence. Explicit `null` is rejected. - `deny_rules` require `protocol`. For non-MCP protocols, `deny_rules` also require `rules` or `access` to define the base allow set. MCP `deny_rules` may omit both when `mcp.allow_all_known_mcp_methods: true` supplies the base allow set. - `rules: []` (empty list) is rejected; use `access: full` or remove `rules`. - Non-empty `rules` must contain at least one effective allow clause; rules where every entry lacks an allow are rejected as deny-all. @@ -309,7 +304,7 @@ network_policies: gcp_storage: endpoints: - host: storage.googleapis.com - port: 443 + ports: [443] protocol: rest access: full credential_binding: @@ -439,7 +434,7 @@ An MCP client first sends `initialize`. After the server returns a successful re ```yaml showLineNumbers={false} endpoints: - host: mcp.example.com - port: 443 + ports: [443] path: /mcp protocol: mcp enforcement: enforce @@ -489,7 +484,7 @@ Example JSON-RPC allow rules: ```yaml showLineNumbers={false} endpoints: - host: jsonrpc.example.com - port: 443 + ports: [443] path: /rpc protocol: json-rpc enforcement: enforce @@ -523,7 +518,7 @@ Example REST deny rules: ```yaml showLineNumbers={false} endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-write @@ -551,7 +546,7 @@ Example WebSocket deny rules: ```yaml showLineNumbers={false} endpoints: - host: realtime.example.com - port: 443 + ports: [443] protocol: websocket enforcement: enforce access: read-write @@ -575,7 +570,7 @@ Example GraphQL deny rules: ```yaml showLineNumbers={false} endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: graphql enforcement: enforce access: read-write @@ -601,7 +596,7 @@ Example JSON-RPC deny rules: ```yaml showLineNumbers={false} endpoints: - host: jsonrpc.example.com - port: 443 + ports: [443] path: /rpc protocol: json-rpc enforcement: enforce @@ -663,7 +658,7 @@ network_policies: name: github-rest-api endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only @@ -675,7 +670,7 @@ network_policies: name: npm-registry endpoints: - host: registry.npmjs.org - port: 443 + ports: [443] protocol: rest access: read-only allow_encoded_slash: true diff --git a/docs/sandboxes/inference-routing.mdx b/docs/sandboxes/inference-routing.mdx index b0234e968..9df23b92b 100644 --- a/docs/sandboxes/inference-routing.mdx +++ b/docs/sandboxes/inference-routing.mdx @@ -139,7 +139,7 @@ inference_capable: true credentials: [] endpoints: - host: host.openshell.internal - port: 11434 + ports: [11434] protocol: rest access: read-write enforcement: enforce diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 86f51351e..5b542df4d 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -44,7 +44,7 @@ network_policies: name: my-api endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: full @@ -153,7 +153,7 @@ network_policies: name: postgres endpoints: - host: db.internal.example - port: 5432 + ports: [5432] protocol: tcp binaries: - path: /usr/bin/psql @@ -371,7 +371,7 @@ The incremental update surface is split into endpoint-level operations and metho | `--add-deny ` | Appends method/path deny rules to an existing REST or WebSocket endpoint. | Block a sensitive REST path or WebSocket text-message path under an endpoint that is otherwise allowed. | | `--binary ` | Adds binaries for `--add-endpoint`; declares the complete existing binary set for L7 appends. | Repeat for every executable sharing the target rule. | | `--rule-name ` | Names one new endpoint rule or selects the existing rule for L7 appends. | Required for `--add-allow` and `--add-deny`. | -| `--any-binary` | Explicitly acknowledges an existing rule that authorizes every binary. | Use for L7 appends instead of `--binary` when the target rule has no binary restriction. | +| `--any-binary` | Selects the incremental update API's explicit any-binary target state. | Use only when the gateway reports that internal target state. It is not an authored policy-file value. | | `--endpoint-path ` | Selects an exact existing endpoint path for L7 appends. | Resolve path ambiguity; pass `''` to select an endpoint without a path scope. | | `--dry-run` | Shows the merged policy locally and does not call the gateway. | Review the result before persisting it. | | `--wait` | Polls until the sandbox reports that the new revision loaded. | Confirm the change took effect before continuing. | @@ -497,7 +497,7 @@ Path globs follow the same semantics as YAML allow and deny rules: - `/repos/*/issues` matches any intervening text, including multiple path segments. - `/repos/**` matches everything under `/repos/`. -The rule-level commands only modify method and path constraints. They do not change binaries, hostnames, ports, protocol settings, or WebSocket message payload matching. Each change applies to every binary and port on the target endpoint. Supply `--rule-name` and every binary with repeated `--binary`, or use `--any-binary` when the stored rule has an empty binary list. The declared binary and port sets must match the stored scope exactly; OpenShell reports the expected scope when they differ. +The rule-level commands only modify method and path constraints. They do not change binaries, hostnames, ports, protocol settings, or WebSocket message payload matching. Each change applies to every binary and port on the target endpoint. Supply `--rule-name` and every binary with repeated `--binary`. The incremental update API also retains an explicit `any_binary` target selector for internal update scopes; `--any-binary` selects that state when the gateway reports it. This selector does not create an authored any-binary policy. In a complete policy document, omitted `binaries` and `binaries: []` both match no executable. The declared binary and port sets must match the stored scope exactly; OpenShell reports the expected scope when they differ. Use `--endpoint-path` to select an endpoint by its stored path when the rule contains more than one endpoint matching the host and ports. `--endpoint-path ''` selects an endpoint without a path selector. This selector is separate from the request path in the allow or deny rule. All L7 additions in one command use the same rule name, binary declaration, and endpoint-path selector. @@ -633,11 +633,10 @@ The CLI validates the argument shapes before it sends the request. The gateway t - `--add-deny` targets an endpoint that has no base allow set. - an update names an existing rule and adds a binary to it without declaring every endpoint and port that rule already authorizes. - an update names an existing rule and adds or changes an endpoint on it without declaring every binary that rule already authorizes. -- an update widens a rule to any binary without declaring every endpoint that rule already authorizes. - an update changes an endpoint without declaring every port that endpoint carries. - an update puts an MCP endpoint on the same host and port as a differently inspected endpoint, or gives one host and port two different MCP inspection contracts, including through separate rules or different paths. -A rule authorizes every listed binary to reach every listed endpoint and port, so merging a binary and an endpoint into the same rule authorizes that pair too. The gateway rejects the whole batch rather than granting a pair the update did not ask for. The error names the binary scope and the ports involved, and lists the binaries you still need to declare. An empty binary list means any binary, so widening a rule to any binary is subject to the same requirement. +A rule authorizes every listed binary to reach every listed endpoint and port, so merging a binary and an endpoint into the same rule authorizes that pair too. The gateway rejects the whole batch rather than granting a pair the update did not ask for. The error names the binary scope and the ports involved, and lists the binaries you still need to declare. An omitted or empty authored binary list matches no executable; the similarly named `any_binary` field belongs only to the incremental update target protocol. You can declare the scope across several `--add-endpoint` arguments. The update is complete as long as every binary-to-port pair the merged rule ends up authorizing appears somewhere in the update. @@ -730,9 +729,9 @@ Allow `pip install` and `uv pip install` to reach PyPI: name: pypi endpoints: - host: pypi.org - port: 443 + ports: [443] - host: files.pythonhosted.org - port: 443 + ports: [443] binaries: - { path: /usr/bin/pip } - { path: /usr/local/bin/uv } @@ -754,7 +753,7 @@ For an end-to-end walkthrough that combines this policy with a GitHub credential name: github_repos endpoints: - host: api.github.com - port: 443 + ports: [443] path: "/**" protocol: rest enforcement: enforce @@ -778,7 +777,7 @@ For an end-to-end walkthrough that combines this policy with a GitHub credential method: PATCH path: "/repos//bravo-repo/issues/*" - host: api.github.com - port: 443 + ports: [443] path: "/graphql" protocol: graphql enforcement: enforce @@ -810,7 +809,7 @@ REST rules can also constrain query parameter values: name: download_api endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -855,7 +854,7 @@ An MCP client first sends `initialize`. After the server returns a successful re name: mcp_server endpoints: - host: mcp.example.com - port: 443 + ports: [443] path: /mcp protocol: mcp enforcement: enforce @@ -905,7 +904,7 @@ GraphQL endpoint policies currently require full policy YAML applied with `opens name: github_graphql endpoints: - host: api.github.com - port: 443 + ports: [443] path: "/graphql" protocol: graphql enforcement: enforce @@ -937,7 +936,7 @@ Some APIs carry GraphQL operations over RFC 6455 WebSockets, commonly for subscr name: realtime_graphql endpoints: - host: realtime.example.com - port: 443 + ports: [443] path: "/graphql" protocol: websocket enforcement: enforce diff --git a/e2e/docker/Dockerfile.policy-document-community-base b/e2e/docker/Dockerfile.policy-document-community-base new file mode 100644 index 000000000..4486c57c5 --- /dev/null +++ b/e2e/docker/Dockerfile.policy-document-community-base @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +FROM ${BASE_IMAGE} + +# The published community image predates the breaking public PolicyDocument +# contract. Keep the workload identical while making its baked policy an +# explicit fixture for the contract under test. This is intentionally confined +# to E2E image preparation; production policy parsing remains strict. +USER root +RUN sed -E -i 's/port: ([0-9]+)/ports: [\1]/g' /etc/openshell/policy.yaml +USER sandbox + +LABEL openshell.ai/e2e-policy-contract="openshell.policy.v1.PolicyDocument" diff --git a/e2e/mcp-conformance/render-policy.py b/e2e/mcp-conformance/render-policy.py index 0d1b9e5c3..5e9f9a7be 100644 --- a/e2e/mcp-conformance/render-policy.py +++ b/e2e/mcp-conformance/render-policy.py @@ -78,7 +78,7 @@ rewritten = urlunparse( template = string.Template(Path(policy_template).read_text(encoding="utf-8")) policy = template.substitute( host_spec=f"host: {json.dumps(target_host)}", - port_spec=f" port: {port}", + port_spec=f" ports: [{port}]", path=json.dumps(path), mcp_version=json.dumps(mcp_version), ) diff --git a/e2e/policy-advisor/existing-endpoint-auto-approve.sh b/e2e/policy-advisor/existing-endpoint-auto-approve.sh index c80c6bbdc..19637a934 100755 --- a/e2e/policy-advisor/existing-endpoint-auto-approve.sh +++ b/e2e/policy-advisor/existing-endpoint-auto-approve.sh @@ -35,7 +35,7 @@ network_policies: name: cargo-registry endpoints: - host: index.crates.io - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only diff --git a/e2e/policy-advisor/policy.template.yaml b/e2e/policy-advisor/policy.template.yaml index 6452cb01c..99ae0d69a 100644 --- a/e2e/policy-advisor/policy.template.yaml +++ b/e2e/policy-advisor/policy.template.yaml @@ -20,7 +20,7 @@ network_policies: name: github-api-readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only diff --git a/e2e/python/test_policy_validation.py b/e2e/python/test_policy_validation.py index 7a117d27f..c923028f5 100644 --- a/e2e/python/test_policy_validation.py +++ b/e2e/python/test_policy_validation.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING import grpc import pytest -from openshell._proto import datamodel_pb2, openshell_pb2, sandbox_pb2 +from openshell._proto import datamodel_pb2, openshell_pb2, policy_pb2, sandbox_pb2 if TYPE_CHECKING: from collections.abc import Callable @@ -28,20 +28,20 @@ if TYPE_CHECKING: # Policy helpers # ============================================================================= -_SAFE_FILESYSTEM = sandbox_pb2.FilesystemPolicy( +_SAFE_FILESYSTEM = policy_pb2.FilesystemPolicy( include_workdir=True, read_only=["/usr", "/lib", "/etc", "/app", "/var/log"], read_write=["/sandbox", "/tmp"], ) -_SAFE_LANDLOCK = sandbox_pb2.LandlockPolicy(compatibility="best_effort") -_SAFE_PROCESS = sandbox_pb2.ProcessPolicy(run_as_user="sandbox", run_as_group="sandbox") +_SAFE_LANDLOCK = policy_pb2.LandlockPolicy(compatibility="best_effort") +_SAFE_PROCESS = policy_pb2.ProcessPolicy(run_as_user="sandbox", run_as_group="sandbox") -def _safe_policy() -> sandbox_pb2.SandboxPolicy: +def _safe_policy() -> policy_pb2.PolicyDocument: """Build a safe baseline policy for testing.""" - return sandbox_pb2.SandboxPolicy( + return policy_pb2.PolicyDocument( version=1, - filesystem=_SAFE_FILESYSTEM, + filesystem_policy=_SAFE_FILESYSTEM, landlock=_SAFE_LANDLOCK, process=_SAFE_PROCESS, ) @@ -56,11 +56,11 @@ def test_create_sandbox_rejects_root_user( sandbox_client: SandboxClient, ) -> None: """Server rejects CreateSandbox with run_as_user='root'.""" - policy = sandbox_pb2.SandboxPolicy( + policy = policy_pb2.PolicyDocument( version=1, - filesystem=_SAFE_FILESYSTEM, + filesystem_policy=_SAFE_FILESYSTEM, landlock=_SAFE_LANDLOCK, - process=sandbox_pb2.ProcessPolicy( + process=policy_pb2.ProcessPolicy( run_as_user="root", run_as_group="sandbox", ), @@ -85,9 +85,9 @@ def test_create_sandbox_rejects_path_traversal( sandbox_client: SandboxClient, ) -> None: """Server rejects CreateSandbox with '..' in filesystem paths.""" - policy = sandbox_pb2.SandboxPolicy( + policy = policy_pb2.PolicyDocument( version=1, - filesystem=sandbox_pb2.FilesystemPolicy( + filesystem_policy=policy_pb2.FilesystemPolicy( include_workdir=True, read_only=["/usr/../etc/shadow"], read_write=["/tmp"], @@ -115,9 +115,9 @@ def test_create_sandbox_rejects_overly_broad_paths( sandbox_client: SandboxClient, ) -> None: """Server rejects CreateSandbox with read_write=['/'].""" - policy = sandbox_pb2.SandboxPolicy( + policy = policy_pb2.PolicyDocument( version=1, - filesystem=sandbox_pb2.FilesystemPolicy( + filesystem_policy=policy_pb2.FilesystemPolicy( include_workdir=True, read_only=["/usr"], read_write=["/"], @@ -147,17 +147,15 @@ def test_create_sandbox_materializes_default_mcp_version( """An omitted MCP options stanza is stored with the pinned default version.""" policy = _safe_policy() policy.network_policies["mcp_default"].CopyFrom( - sandbox_pb2.NetworkPolicyRule( + policy_pb2.NetworkPolicyRule( name="mcp_default", endpoints=[ - sandbox_pb2.NetworkEndpoint( + policy_pb2.NetworkEndpoint( host="mcp.example.com", - port=443, + ports=[443], protocol="mcp", rules=[ - sandbox_pb2.L7Rule( - allow=sandbox_pb2.L7Allow(method="initialize") - ) + policy_pb2.L7Rule(allow=policy_pb2.L7Allow(method="initialize")) ], ) ], @@ -217,9 +215,9 @@ def test_update_policy_rejects_immutable_fields( stub = sandbox_client._stub # Try to update with a modified filesystem policy (immutable field) - unsafe_policy = sandbox_pb2.SandboxPolicy( + unsafe_policy = policy_pb2.PolicyDocument( version=1, - filesystem=sandbox_pb2.FilesystemPolicy( + filesystem_policy=policy_pb2.FilesystemPolicy( include_workdir=True, read_only=["/usr/../etc/shadow"], read_write=["/tmp"], diff --git a/e2e/python/test_sandbox_landlock.py b/e2e/python/test_sandbox_landlock.py index d0c63fc2c..f0c2ff1a2 100644 --- a/e2e/python/test_sandbox_landlock.py +++ b/e2e/python/test_sandbox_landlock.py @@ -22,7 +22,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from openshell._proto import datamodel_pb2, sandbox_pb2 +from openshell._proto import datamodel_pb2, policy_pb2 if TYPE_CHECKING: from collections.abc import Callable @@ -34,25 +34,25 @@ if TYPE_CHECKING: # Policy helpers # ============================================================================= -_LANDLOCK_FILESYSTEM = sandbox_pb2.FilesystemPolicy( +_LANDLOCK_FILESYSTEM = policy_pb2.FilesystemPolicy( include_workdir=True, read_only=["/usr", "/lib", "/etc", "/proc", "/dev/urandom"], read_write=["/sandbox", "/tmp"], ) -_LANDLOCK_BEST_EFFORT = sandbox_pb2.LandlockPolicy(compatibility="best_effort") -_LANDLOCK_PROCESS = sandbox_pb2.ProcessPolicy( +_LANDLOCK_BEST_EFFORT = policy_pb2.LandlockPolicy(compatibility="best_effort") +_LANDLOCK_PROCESS = policy_pb2.ProcessPolicy( run_as_user="sandbox", run_as_group="sandbox" ) def _landlock_policy( *, - filesystem: sandbox_pb2.FilesystemPolicy | None = None, - landlock: sandbox_pb2.LandlockPolicy | None = None, -) -> sandbox_pb2.SandboxPolicy: - return sandbox_pb2.SandboxPolicy( + filesystem: policy_pb2.FilesystemPolicy | None = None, + landlock: policy_pb2.LandlockPolicy | None = None, +) -> policy_pb2.PolicyDocument: + return policy_pb2.PolicyDocument( version=1, - filesystem=filesystem or _LANDLOCK_FILESYSTEM, + filesystem_policy=filesystem or _LANDLOCK_FILESYSTEM, landlock=landlock or _LANDLOCK_BEST_EFFORT, process=_LANDLOCK_PROCESS, network_policies={}, diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index 80d9963e0..a4fb4b7c7 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING import grpc import pytest -from openshell._proto import datamodel_pb2, sandbox_pb2 +from openshell._proto import datamodel_pb2, policy_pb2 if TYPE_CHECKING: from collections.abc import Callable @@ -23,21 +23,21 @@ if TYPE_CHECKING: from openshell import Sandbox -_BASE_FILESYSTEM = sandbox_pb2.FilesystemPolicy( +_BASE_FILESYSTEM = policy_pb2.FilesystemPolicy( include_workdir=True, read_only=["/usr", "/lib", "/etc", "/app", "/var/log", "/proc", "/dev/urandom"], read_write=["/sandbox", "/tmp"], ) -_BASE_LANDLOCK = sandbox_pb2.LandlockPolicy(compatibility="best_effort") -_BASE_PROCESS = sandbox_pb2.ProcessPolicy(run_as_user="sandbox", run_as_group="sandbox") +_BASE_LANDLOCK = policy_pb2.LandlockPolicy(compatibility="best_effort") +_BASE_PROCESS = policy_pb2.ProcessPolicy(run_as_user="sandbox", run_as_group="sandbox") def _base_policy( - network_policies: dict[str, sandbox_pb2.NetworkPolicyRule] | None = None, -) -> sandbox_pb2.SandboxPolicy: - return sandbox_pb2.SandboxPolicy( + network_policies: dict[str, policy_pb2.NetworkPolicyRule] | None = None, +) -> policy_pb2.PolicyDocument: + return policy_pb2.PolicyDocument( version=1, - filesystem=_BASE_FILESYSTEM, + filesystem_policy=_BASE_FILESYSTEM, landlock=_BASE_LANDLOCK, process=_BASE_PROCESS, network_policies=network_policies or {}, @@ -63,17 +63,17 @@ def _network_rule( *, binary: str = "/**", allowed_ips: list[str] | None = None, -) -> sandbox_pb2.NetworkPolicyRule: - return sandbox_pb2.NetworkPolicyRule( +) -> policy_pb2.NetworkPolicyRule: + return policy_pb2.NetworkPolicyRule( name="test_rule", endpoints=[ - sandbox_pb2.NetworkEndpoint( + policy_pb2.NetworkEndpoint( host=host, - port=port, + ports=[port], allowed_ips=allowed_ips or [], ) ], - binaries=[sandbox_pb2.NetworkBinary(path=binary)], + binaries=[policy_pb2.NetworkBinary(path=binary)], ) @@ -127,7 +127,7 @@ def test_policy_applies_to_exec_commands( ) def test_transparent_tcp_policy_denies_unauthorized_connections( sandbox: Callable[..., Sandbox], - policy: sandbox_pb2.SandboxPolicy, + policy: policy_pb2.PolicyDocument, host: str, port: int, ) -> None: @@ -152,21 +152,21 @@ def test_conflicting_destination_metadata_is_rejected( port = 19876 policy = _base_policy( network_policies={ - "user_rule": sandbox_pb2.NetworkPolicyRule( + "user_rule": policy_pb2.NetworkPolicyRule( name="user_rule", - endpoints=[sandbox_pb2.NetworkEndpoint(host=target, port=port)], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], + endpoints=[policy_pb2.NetworkEndpoint(host=target, ports=[port])], + binaries=[policy_pb2.NetworkBinary(path="/**")], ), - "approved_rule": sandbox_pb2.NetworkPolicyRule( + "approved_rule": policy_pb2.NetworkPolicyRule( name="approved_rule", endpoints=[ - sandbox_pb2.NetworkEndpoint( + policy_pb2.NetworkEndpoint( host=target, - port=port, + ports=[port], allowed_ips=["10.200.0.0/24"], ) ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], + binaries=[policy_pb2.NetworkBinary(path="/**")], ), } ) diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 43badec29..da5fbc2b8 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -23,7 +23,7 @@ from typing import TYPE_CHECKING import grpc import pytest -from openshell._proto import datamodel_pb2, openshell_pb2, sandbox_pb2 +from openshell._proto import datamodel_pb2, openshell_pb2, policy_pb2 if TYPE_CHECKING: from collections.abc import Callable, Iterator @@ -47,19 +47,17 @@ def _is_placeholder_for_env_key(value: str, key: str) -> bool: return token.startswith(("v", "s")) and token.endswith(f"_{key}") -def _default_policy() -> sandbox_pb2.SandboxPolicy: +def _default_policy() -> policy_pb2.PolicyDocument: """Build a sandbox policy with standard filesystem/process/landlock settings.""" - return sandbox_pb2.SandboxPolicy( + return policy_pb2.PolicyDocument( version=1, - filesystem=sandbox_pb2.FilesystemPolicy( + filesystem_policy=policy_pb2.FilesystemPolicy( include_workdir=True, read_only=["/usr", "/lib", "/etc", "/app", "/dev/urandom"], read_write=["/sandbox", "/tmp"], ), - landlock=sandbox_pb2.LandlockPolicy(compatibility="best_effort"), - process=sandbox_pb2.ProcessPolicy( - run_as_user="sandbox", run_as_group="sandbox" - ), + landlock=policy_pb2.LandlockPolicy(compatibility="best_effort"), + process=policy_pb2.ProcessPolicy(run_as_user="sandbox", run_as_group="sandbox"), ) @@ -160,7 +158,7 @@ def _native_inference_profile( profile_id: str, env_var: str, port: int, - rules: list[sandbox_pb2.L7Rule], + rules: list[policy_pb2.L7Rule], auth_style: str = "bearer", header_name: str = "authorization", ) -> openshell_pb2.ProviderProfile: @@ -181,12 +179,11 @@ def _native_inference_profile( ) ], endpoints=[ - sandbox_pb2.NetworkEndpoint( + policy_pb2.NetworkEndpoint( host="host.openshell.internal", - port=port, + ports=[port], protocol="rest", - tls=sandbox_pb2.NETWORK_TLS_MODE_UNSPECIFIED, - enforcement=sandbox_pb2.NETWORK_ENFORCEMENT_MODE_ENFORCE, + enforcement="enforce", rules=rules, allowed_ips=[ "10.0.0.0/8", @@ -197,9 +194,9 @@ def _native_inference_profile( ) ], binaries=[ - sandbox_pb2.NetworkBinary(path="/usr/bin/python*"), - sandbox_pb2.NetworkBinary(path="/usr/local/bin/python*"), - sandbox_pb2.NetworkBinary(path="/sandbox/.uv/python/**/python*"), + policy_pb2.NetworkBinary(path="/usr/bin/python*"), + policy_pb2.NetworkBinary(path="/usr/local/bin/python*"), + policy_pb2.NetworkBinary(path="/sandbox/.uv/python/**/python*"), ], ) @@ -379,15 +376,15 @@ def test_endpointless_profile_credentials_use_explicit_policy_binding( ) as provider_name: policy = _default_policy() policy.network_policies["gcp_storage"].CopyFrom( - sandbox_pb2.NetworkPolicyRule( + policy_pb2.NetworkPolicyRule( name="gcp_storage", endpoints=[ - sandbox_pb2.NetworkEndpoint( + policy_pb2.NetworkEndpoint( host="storage.googleapis.com", - port=443, + ports=[443], protocol="rest", - access=sandbox_pb2.NETWORK_ACCESS_PRESET_FULL, - credential_binding=sandbox_pb2.NetworkCredentialBinding( + access="full", + credential_binding=policy_pb2.NetworkCredentialBinding( provider=provider_name ), ) @@ -538,8 +535,8 @@ def test_imported_openai_profile_allows_native_endpoint_with_attached_provider( env_var="OPENAI_API_KEY", port=0, rules=[ - sandbox_pb2.L7Rule( - allow=sandbox_pb2.L7Allow( + policy_pb2.L7Rule( + allow=policy_pb2.L7Allow( method="POST", path="/v1/chat/completions", ) @@ -578,7 +575,7 @@ def test_imported_openai_profile_allows_native_endpoint_with_attached_provider( ) from exc with native_endpoint_server() as port: - profile.endpoints[0].port = port + profile.endpoints[0].ports[0] = port with imported_provider_profile( stub, profile=profile, @@ -624,8 +621,8 @@ def test_imported_anthropic_profile_allows_native_endpoint_with_attached_provide env_var="ANTHROPIC_API_KEY", port=0, rules=[ - sandbox_pb2.L7Rule( - allow=sandbox_pb2.L7Allow( + policy_pb2.L7Rule( + allow=policy_pb2.L7Allow( method="POST", path="/v1/messages", ) @@ -667,7 +664,7 @@ def test_imported_anthropic_profile_allows_native_endpoint_with_attached_provide ) from exc with native_endpoint_server() as port: - profile.endpoints[0].port = port + profile.endpoints[0].ports[0] = port with imported_provider_profile( stub, profile=profile, @@ -980,7 +977,7 @@ def test_github_provider_allows_https_git_clone( # _default_policy only grants /dev/urandom. Everything else (binaries, # CA bundle, clone target) is covered by the standard allowlist. policy = _default_policy() - policy.filesystem.read_write.append("/dev/null") + policy.filesystem_policy.read_write.append("/dev/null") spec = datamodel_pb2.SandboxSpec( policy=policy, providers=[provider_name], diff --git a/e2e/python/test_sandbox_venv.py b/e2e/python/test_sandbox_venv.py index 0461542e0..590288024 100644 --- a/e2e/python/test_sandbox_venv.py +++ b/e2e/python/test_sandbox_venv.py @@ -19,7 +19,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from openshell._proto import datamodel_pb2, sandbox_pb2 +from openshell._proto import datamodel_pb2, policy_pb2 if TYPE_CHECKING: from collections.abc import Callable @@ -37,25 +37,25 @@ def _pypi_spec() -> datamodel_pb2.SandboxSpec: "downloads.python.org", ] return datamodel_pb2.SandboxSpec( - policy=sandbox_pb2.SandboxPolicy( + policy=policy_pb2.PolicyDocument( version=1, - filesystem=sandbox_pb2.FilesystemPolicy( + filesystem_policy=policy_pb2.FilesystemPolicy( include_workdir=True, read_only=["/usr", "/lib", "/etc", "/app", "/proc"], read_write=["/sandbox", "/tmp"], ), - landlock=sandbox_pb2.LandlockPolicy(compatibility="best_effort"), - process=sandbox_pb2.ProcessPolicy( + landlock=policy_pb2.LandlockPolicy(compatibility="best_effort"), + process=policy_pb2.ProcessPolicy( run_as_user="sandbox", run_as_group="sandbox" ), network_policies={ - "pypi": sandbox_pb2.NetworkPolicyRule( + "pypi": policy_pb2.NetworkPolicyRule( name="pypi", endpoints=[ - sandbox_pb2.NetworkEndpoint(host=host, port=443) + policy_pb2.NetworkEndpoint(host=host, ports=[443]) for host in endpoints ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], + binaries=[policy_pb2.NetworkBinary(path="/**")], ) }, ) diff --git a/e2e/rust/tests/community_image.rs b/e2e/rust/tests/community_image.rs index 2fab46385..8c1b50d74 100644 --- a/e2e/rust/tests/community_image.rs +++ b/e2e/rust/tests/community_image.rs @@ -39,14 +39,17 @@ async fn sandbox_from_community_base_image() { guard.cleanup().await; } -/// Create a sandbox using the full GHCR image path explicitly. +/// Create a sandbox using a full community image reference explicitly. /// -/// This tests that explicit image references work correctly. +/// This tests that explicit image references work correctly. Docker E2E can +/// override the image with its schema-migrated local fixture while the normal +/// path continues to exercise the published GHCR reference. #[tokio::test] async fn sandbox_from_explicit_ghcr_image() { - let image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; + let image = std::env::var("OPENSHELL_E2E_COMMUNITY_BASE_IMAGE") + .unwrap_or_else(|_| "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string()); - let mut guard = SandboxGuard::create(&["--from", image, "--", "cat", "/etc/os-release"]) + let mut guard = SandboxGuard::create(&["--from", &image, "--", "cat", "/etc/os-release"]) .await .expect("sandbox create from explicit GHCR image"); diff --git a/e2e/rust/tests/credential_gating.rs b/e2e/rust/tests/credential_gating.rs index 728188525..c81dd9050 100644 --- a/e2e/rust/tests/credential_gating.rs +++ b/e2e/rust/tests/credential_gating.rs @@ -107,11 +107,11 @@ credentials: header_name: authorization endpoints: - host: {TEST_HOST} - port: {rest_port} + ports: [{rest_port}] protocol: rest access: full - host: {TEST_HOST} - port: {websocket_port} + ports: [{websocket_port}] protocol: websocket access: read-write binaries: @@ -271,7 +271,7 @@ network_policies: name: credential_gating endpoints: - host: {TEST_HOST} - port: {port} + ports: [{port}] {endpoint_options}{credential_binding} binaries: - path: /usr/bin/python* - path: /usr/local/bin/python* diff --git a/e2e/rust/tests/forward_proxy_graphql_l7.rs b/e2e/rust/tests/forward_proxy_graphql_l7.rs index 2a1b06d27..1bc71a009 100644 --- a/e2e/rust/tests/forward_proxy_graphql_l7.rs +++ b/e2e/rust/tests/forward_proxy_graphql_l7.rs @@ -91,7 +91,7 @@ network_policies: name: test_graphql_l7 endpoints: - host: {host} - port: {port} + ports: [{port}] protocol: graphql enforcement: enforce persisted_queries: allow_registered diff --git a/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs b/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs index b46dac131..ca76c8943 100644 --- a/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs +++ b/e2e/rust/tests/forward_proxy_jsonrpc_l7.rs @@ -92,7 +92,7 @@ network_policies: name: test_jsonrpc_l7 endpoints: - host: {host} - port: {port} + ports: [{port}] path: /rpc protocol: json-rpc enforcement: enforce @@ -157,7 +157,7 @@ network_policies: name: test_jsonrpc_l7_audit endpoints: - host: {host} - port: {port} + ports: [{port}] path: /rpc protocol: json-rpc allowed_ips: diff --git a/e2e/rust/tests/forward_proxy_l7_bypass.rs b/e2e/rust/tests/forward_proxy_l7_bypass.rs index e346ac169..382a05ed2 100644 --- a/e2e/rust/tests/forward_proxy_l7_bypass.rs +++ b/e2e/rust/tests/forward_proxy_l7_bypass.rs @@ -67,7 +67,7 @@ network_policies: name: test_l7 endpoints: - host: {host} - port: {port} + ports: [{port}] protocol: rest enforcement: enforce allowed_ips: diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index cf7b9e657..02d8aeb1b 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -169,7 +169,7 @@ credentials: header_name: authorization endpoints: - host: {host} - port: {port} + ports: [{port}] path: /allowed/** protocol: rest access: full @@ -206,13 +206,13 @@ network_policies: name: binding_test endpoints: - host: host.openshell.internal - port: {port} + ports: [{port}] path: /** protocol: rest access: full enforcement: enforce - host: host.docker.internal - port: {port} + ports: [{port}] path: /** protocol: rest access: full @@ -287,7 +287,7 @@ network_policies: name: host_echo endpoints: - host: host.openshell.internal - port: {port} + ports: [{port}] allowed_ips: - "10.0.0.0/8" - "172.0.0.0/8" diff --git a/e2e/rust/tests/internet_network_perf.rs b/e2e/rust/tests/internet_network_perf.rs index 70c0f1af2..3883e0ea3 100644 --- a/e2e/rust/tests/internet_network_perf.rs +++ b/e2e/rust/tests/internet_network_perf.rs @@ -108,10 +108,10 @@ network_policies: name: internet_performance endpoints: - host: example.com - port: 80 + ports: [80] protocol: tcp - host: example.com - port: 443 + ports: [443] protocol: tcp binaries: - path: "/**" diff --git a/e2e/rust/tests/kubernetes_corporate_proxy.rs b/e2e/rust/tests/kubernetes_corporate_proxy.rs index 3fc1c12a3..4f78f718f 100644 --- a/e2e/rust/tests/kubernetes_corporate_proxy.rs +++ b/e2e/rust/tests/kubernetes_corporate_proxy.rs @@ -106,7 +106,7 @@ network_policies: name: proxy_e2e endpoints: - host: {HOST_ALIAS} - port: {upstream_port} + ports: [{upstream_port}] tls: skip enforcement: enforce allowed_ips: ["10.0.0.0/8", "172.0.0.0/8", "192.168.0.0/16", "fc00::/7"] diff --git a/e2e/rust/tests/landlock.rs b/e2e/rust/tests/landlock.rs index ae1e16601..c8833bf69 100644 --- a/e2e/rust/tests/landlock.rs +++ b/e2e/rust/tests/landlock.rs @@ -40,7 +40,7 @@ network_policies: name: landlock_regression endpoints: - host: example.com - port: 443 + ports: [443] binaries: - path: "/**" "#; diff --git a/e2e/rust/tests/live_internet_traffic_perf.rs b/e2e/rust/tests/live_internet_traffic_perf.rs index e8727bbce..52fa567e1 100644 --- a/e2e/rust/tests/live_internet_traffic_perf.rs +++ b/e2e/rust/tests/live_internet_traffic_perf.rs @@ -237,15 +237,15 @@ network_policies: live_internet_performance: name: live_internet_performance endpoints: - - {{ host: example.com, port: 443, protocol: tcp }} - - {{ host: github.com, port: 443, protocol: tcp }} - - {{ host: raw.githubusercontent.com, port: 443, protocol: tcp }} - - {{ host: pypi.org, port: 443, protocol: tcp }} - - {{ host: files.pythonhosted.org, port: 443, protocol: tcp }} - - {{ host: registry.npmjs.org, port: 443, protocol: tcp }} - - {{ host: crates.io, port: 443, protocol: tcp }} - - {{ host: docs.python.org, port: 443, protocol: tcp }} - - {{ host: speed.cloudflare.com, port: 443, protocol: tcp }} + - {{ host: example.com, ports: [443], protocol: tcp }} + - {{ host: github.com, ports: [443], protocol: tcp }} + - {{ host: raw.githubusercontent.com, ports: [443], protocol: tcp }} + - {{ host: pypi.org, ports: [443], protocol: tcp }} + - {{ host: files.pythonhosted.org, ports: [443], protocol: tcp }} + - {{ host: registry.npmjs.org, ports: [443], protocol: tcp }} + - {{ host: crates.io, ports: [443], protocol: tcp }} + - {{ host: docs.python.org, ports: [443], protocol: tcp }} + - {{ host: speed.cloudflare.com, ports: [443], protocol: tcp }} binaries: - path: "/**" "# diff --git a/e2e/rust/tests/live_policy_update.rs b/e2e/rust/tests/live_policy_update.rs index 3577e20a3..f673a63ed 100644 --- a/e2e/rust/tests/live_policy_update.rs +++ b/e2e/rust/tests/live_policy_update.rs @@ -52,7 +52,7 @@ fn write_policy(hosts: &[&str]) -> Result { name: rule_{i} endpoints: - host: {host} - port: 443 + ports: [443] binaries: - path: "/**" "# @@ -222,7 +222,7 @@ network_policies: protocol: rest access: read-only - host: other.example.com - port: 443 + ports: [443] protocol: rest access: read-only sibling: diff --git a/e2e/rust/tests/podman_corporate_proxy.rs b/e2e/rust/tests/podman_corporate_proxy.rs index ef1a08f0b..dbd83f2bc 100644 --- a/e2e/rust/tests/podman_corporate_proxy.rs +++ b/e2e/rust/tests/podman_corporate_proxy.rs @@ -396,7 +396,7 @@ network_policies: name: corporate_proxy_e2e endpoints: - host: {ALLOWED_ALIAS} - port: {UPSTREAM_PORT} + ports: [{UPSTREAM_PORT}] tls: skip enforcement: enforce allowed_ips: diff --git a/e2e/rust/tests/policy_activation.rs b/e2e/rust/tests/policy_activation.rs index 0491e80c3..77554ca7b 100644 --- a/e2e/rust/tests/policy_activation.rs +++ b/e2e/rust/tests/policy_activation.rs @@ -29,7 +29,7 @@ network_policies: image_api: endpoints: - host: api.example.com - port: 443 + ports: [443] binaries: - path: /usr/bin/curl "; @@ -210,7 +210,7 @@ credentials: header_name: authorization endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: full binaries: @@ -327,8 +327,8 @@ binaries: std::fs::write( &repaired, POLICY.replace( - " port: 443", - " port: 443\n protocol: rest\n access: full", + " ports: [443]", + " ports: [443]\n protocol: rest\n access: full", ), ) .unwrap(); diff --git a/e2e/rust/tests/provider_readiness.rs b/e2e/rust/tests/provider_readiness.rs index 58278faa5..e4a285fc6 100644 --- a/e2e/rust/tests/provider_readiness.rs +++ b/e2e/rust/tests/provider_readiness.rs @@ -926,10 +926,10 @@ fn write_profile( "id": name, "display_name": "Provider readiness E2E", "category": "other", "credentials": [{"name": "synthetic", "env_vars": [TOKEN_ENV], "required": true, "auth_style": "bearer", "header_name": "authorization"}], - "endpoints": [{"host": host, "port": port, "path": "/v1/**", "protocol": "rest", + "endpoints": [{"host": host, "ports": [port], "path": "/v1/**", "protocol": "rest", "access": "full", "enforcement": "enforce", "allowed_ips": [host]}], - "binaries": [python], + "binaries": [{"path": python}], }); std::fs::write(path, document.to_string()) .map_err(|_| "could not write synthetic profile".to_string()) @@ -949,7 +949,7 @@ fn write_policy( let endpoints = [(host, port), (host, other_port), (other_host, port)] .into_iter() .map(|(host, port)| { - json!({"host": host, "port": port, "path": "/**", "protocol": "rest", + json!({"host": host, "ports": [port], "path": "/**", "protocol": "rest", "access": "full", "enforcement": "enforce", "allowed_ips": [host]}) }) diff --git a/e2e/rust/tests/provider_refresh_handles.rs b/e2e/rust/tests/provider_refresh_handles.rs index a12281f3e..f7704815b 100644 --- a/e2e/rust/tests/provider_refresh_handles.rs +++ b/e2e/rust/tests/provider_refresh_handles.rs @@ -128,7 +128,7 @@ credentials: secret: true endpoints: - host: host.openshell.internal - port: {resource_port} + ports: [{resource_port}] path: /probe protocol: rest access: full @@ -170,7 +170,7 @@ network_policies: name: refresh_probe endpoints: - host: host.openshell.internal - port: {resource_port} + ports: [{resource_port}] path: /probe protocol: rest access: full diff --git a/e2e/rust/tests/provider_token_exchange.rs b/e2e/rust/tests/provider_token_exchange.rs index c4e06f029..484ed1850 100644 --- a/e2e/rust/tests/provider_token_exchange.rs +++ b/e2e/rust/tests/provider_token_exchange.rs @@ -581,7 +581,7 @@ credentials: subject_token_type: {TOKEN_TYPE_ACCESS_TOKEN} endpoints: - host: host.openshell.internal - port: {target_port} + ports: [{target_port}] protocol: rest tls: none access: read-write @@ -592,8 +592,8 @@ endpoints: - 172.0.0.0/8 - 192.168.0.0/16 binaries: - - /usr/bin/curl - - /usr/local/bin/curl + - path: /usr/bin/curl + - path: /usr/local/bin/curl "# ); file.write_all(profile.as_bytes()) diff --git a/e2e/rust/tests/proxy_egress_pipeline.rs b/e2e/rust/tests/proxy_egress_pipeline.rs index d4c0094d2..43fbd60bb 100644 --- a/e2e/rust/tests/proxy_egress_pipeline.rs +++ b/e2e/rust/tests/proxy_egress_pipeline.rs @@ -145,7 +145,7 @@ credentials: header_name: authorization endpoints: - host: {TEST_SERVER_HOST} - port: {port} + ports: [{port}] path: /probe protocol: rest access: full @@ -204,7 +204,7 @@ network_policies: name: proxy_egress_test endpoints: - host: {host} - port: {port} + ports: [{port}] {endpoint_options} {PRIVATE_ALLOWED_IPS} binaries: @@ -302,7 +302,7 @@ network_policies: name: terminating endpoints: - host: {host} - port: {port} + ports: [{port}] {PRIVATE_ALLOWED_IPS} binaries: - path: "/**" @@ -310,7 +310,7 @@ network_policies: name: passthrough endpoints: - host: {host} - port: {port} + ports: [{port}] tls: skip {PRIVATE_ALLOWED_IPS} binaries: @@ -344,11 +344,11 @@ network_policies: destination_denials: name: destination_denials endpoints: - - { host: 169.254.169.254, port: 80 } - - { host: 127.0.0.1, port: 80 } - - { host: 203.0.113.10, port: 6443 } + - { host: 169.254.169.254, ports: [80] } + - { host: 127.0.0.1, ports: [80] } + - { host: 203.0.113.10, ports: [6443] } - host: 203.0.113.10 - port: 8080 + ports: [8080] allowed_ips: ["198.51.100.0/24"] binaries: - path: "/**" @@ -386,10 +386,10 @@ network_policies: name: destination_successes endpoints: - host: {ip} - port: {port} + ports: [{port}] allowed_ips: ["{explicit_ip}/32"] - host: {implicit_ip} - port: {port} + ports: [{port}] binaries: - path: "/**" "#, diff --git a/e2e/rust/tests/vm_corporate_proxy.rs b/e2e/rust/tests/vm_corporate_proxy.rs index 1e34e71d0..b44c692ad 100644 --- a/e2e/rust/tests/vm_corporate_proxy.rs +++ b/e2e/rust/tests/vm_corporate_proxy.rs @@ -446,7 +446,7 @@ network_policies: name: vm_corporate_proxy_e2e endpoints: - host: {HOST_ALIAS} - port: {allowed} + ports: [{allowed}] tls: skip enforcement: enforce allowed_ips: @@ -455,7 +455,7 @@ network_policies: - "192.168.0.0/16" - "fc00::/7" - host: {HOST_ALIAS} - port: {bypass} + ports: [{bypass}] tls: skip enforcement: enforce allowed_ips: diff --git a/e2e/rust/tests/websocket_conformance.rs b/e2e/rust/tests/websocket_conformance.rs index d95841d07..ba619edc6 100644 --- a/e2e/rust/tests/websocket_conformance.rs +++ b/e2e/rust/tests/websocket_conformance.rs @@ -107,7 +107,7 @@ credentials: header_name: authorization endpoints: - host: {TEST_SERVER_HOST} - port: {port} + ports: [{port}] path: /ws protocol: websocket enforcement: enforce @@ -332,7 +332,7 @@ network_policies: name: websocket_conformance endpoints: - host: {host} - port: {port} + ports: [{port}] protocol: websocket enforcement: enforce access: read-write diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 7334d31f7..ca332cf8c 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -499,6 +499,30 @@ ensure_sandbox_image_available() { docker_pull_with_retry "${image}" } +prepare_policy_document_community_image() { + local source_image=$1 + local source_id source_digest fixture_image + + source_id="$(docker image inspect --format '{{.Id}}' "${source_image}")" + source_digest="${source_id#sha256:}" + if ! [[ "${source_digest}" =~ ^[0-9a-f]{64}$ ]]; then + echo "ERROR: could not resolve a stable image ID for ${source_image}." >&2 + return 1 + fi + + fixture_image="openshell/e2e-community-base:policy-document-v1-${source_digest:0:12}" + if ! docker image inspect "${fixture_image}" >/dev/null 2>&1; then + echo "Preparing PolicyDocument E2E fixture from ${source_image}..." >&2 + ce_build \ + --build-arg "BASE_IMAGE=${source_image}" \ + --file "${ROOT}/e2e/docker/Dockerfile.policy-document-community-base" \ + --tag "${fixture_image}" \ + "${ROOT}/e2e/docker" >&2 + fi + + printf '%s\n' "${fixture_image}" +} + e2e_build_gateway_binaries "${ROOT}" TARGET_DIR GATEWAY_BIN CLI_BIN export OPENSHELL_BIN="${CLI_BIN}" if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then @@ -523,6 +547,13 @@ if ! ensure_sandbox_image_available "${SANDBOX_IMAGE}"; then echo "ERROR: sandbox image '${SANDBOX_IMAGE}' is not available." >&2 exit 2 fi +if [ "${SANDBOX_IMAGE}" = "${DEFAULT_SANDBOX_IMAGE}" ]; then + SANDBOX_IMAGE="$(prepare_policy_document_community_image "${SANDBOX_IMAGE}")" + SANDBOX_IMAGE_PULL_POLICY=never + export OPENSHELL_E2E_COMMUNITY_BASE_IMAGE="${SANDBOX_IMAGE}" + export OPENSHELL_COMMUNITY_REGISTRY="openshell/e2e-community-sandboxes" + docker image tag "${SANDBOX_IMAGE}" "${OPENSHELL_COMMUNITY_REGISTRY}/base:latest" +fi HOST_PORT=$(e2e_pick_port) HEALTH_PORT=$(e2e_pick_port) diff --git a/examples/agent-driven-policy-management/policy.template.yaml b/examples/agent-driven-policy-management/policy.template.yaml index 01f2d6e3b..b99277b00 100644 --- a/examples/agent-driven-policy-management/policy.template.yaml +++ b/examples/agent-driven-policy-management/policy.template.yaml @@ -33,10 +33,10 @@ network_policies: codex: name: codex endpoints: - - { host: api.openai.com, port: 443, protocol: rest, enforcement: enforce, access: full } - - { host: auth.openai.com, port: 443, protocol: rest, enforcement: enforce, access: full } - - { host: chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } - - { host: ab.chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } + - { host: api.openai.com, ports: [443], protocol: rest, enforcement: enforce, access: full } + - { host: auth.openai.com, ports: [443], protocol: rest, enforcement: enforce, access: full } + - { host: chatgpt.com, ports: [443], protocol: rest, enforcement: enforce, access: full } + - { host: ab.chatgpt.com, ports: [443], protocol: rest, enforcement: enforce, access: full } binaries: - { path: /usr/bin/codex } - { path: /sandbox/payload/codex } @@ -50,7 +50,7 @@ network_policies: name: github-api-readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only @@ -68,7 +68,7 @@ network_policies: name: github-raw-scoped endpoints: - host: raw.githubusercontent.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index d99f29d25..ca3992e64 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -167,6 +167,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bumpalo" version = "3.20.3" @@ -341,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -362,6 +368,17 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1141,6 +1158,7 @@ dependencies = [ "miette", "noyalib", "prost", + "prost-protovalidate", "prost-reflect", "prost-types", "protoc-bin-vendored", @@ -1351,6 +1369,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prost-protovalidate" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd223ccf88bbaf6d15f5b93eefa4c955da02f0f1da58896e146ef8d837539405" +dependencies = [ + "fluent-uri", + "prost", + "prost-protovalidate-types", + "prost-reflect", + "prost-types", + "regex", + "serde_json", + "thiserror", +] + +[[package]] +name = "prost-protovalidate-types" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4406a3b6ef7227ab6d5d264b9ae93fef5264a624954c41db12b0a91b7d098d8f" +dependencies = [ + "prost", + "prost-build", + "prost-reflect", + "prost-reflect-build", + "prost-types", + "thiserror", +] + [[package]] name = "prost-reflect" version = "0.16.5" @@ -1359,11 +1407,33 @@ checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" dependencies = [ "base64", "prost", + "prost-reflect-derive", "prost-types", "serde", "serde-value", ] +[[package]] +name = "prost-reflect-build" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a9e8261adf6617d5dc2a5a9e75cce5ab9d546a007f6f870f809a1ad25386b6" +dependencies = [ + "prost-build", + "prost-reflect", +] + +[[package]] +name = "prost-reflect-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30320eb03b43b7dfcaf9b361f808a4f1adad1e718ad219df1d7e4283e34e73f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "prost-types" version = "0.14.4" @@ -1520,6 +1590,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "regex" version = "1.13.1" @@ -1594,7 +1684,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1934,7 +2024,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2403,7 +2493,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/examples/governance-interceptor/policy.yaml b/examples/governance-interceptor/policy.yaml index 1ffe34a9f..be727f48e 100644 --- a/examples/governance-interceptor/policy.yaml +++ b/examples/governance-interceptor/policy.yaml @@ -16,7 +16,7 @@ network_policies: name: my-api endpoints: - host: api-1.example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: full diff --git a/examples/governance-interceptor/profiles/github.yaml b/examples/governance-interceptor/profiles/github.yaml index 4b90aa7c3..e3f53e37f 100644 --- a/examples/governance-interceptor/profiles/github.yaml +++ b/examples/governance-interceptor/profiles/github.yaml @@ -15,18 +15,18 @@ discovery: credentials: [api_token] endpoints: - host: api-1.github.com - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce - host: api.github.com - port: 443 + ports: [443] path: /graphql protocol: graphql access: read-only enforcement: enforce - host: github.com - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce diff --git a/examples/governance-interceptor/profiles/slack.yaml b/examples/governance-interceptor/profiles/slack.yaml index 129691954..0dbd6f4b9 100644 --- a/examples/governance-interceptor/profiles/slack.yaml +++ b/examples/governance-interceptor/profiles/slack.yaml @@ -15,7 +15,7 @@ discovery: credentials: [api_token] endpoints: - host: slack.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index e4ddcbca8..c2f6df389 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -448,7 +448,7 @@ display_name: Unvended API category: other endpoints: - host: api.unvended.example - port: 443 + ports: [443] binaries: [/usr/bin/curl] EOF "${CLI[@]}" provider profile import -f "$TMPDIR/unvended-profile.yaml" --global >/dev/null 2>&1 || true @@ -526,7 +526,7 @@ network_policies: name: example-api endpoints: - host: example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only @@ -571,23 +571,23 @@ discovery: credentials: [api_token] endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce - host: api.github.com - port: 443 + ports: [443] path: /graphql protocol: graphql access: read-only enforcement: enforce - host: github.com - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce - host: profile-reload.example - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 3a6674a6d..1f5803595 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -22,7 +22,7 @@ use openshell_core::proto::gateway_interceptor::v1::{ }; use openshell_core::proto::{ ListSandboxesRequest, ProviderProfile, Sandbox, SandboxPhase, UpdateConfigRequest, - open_shell_client::OpenShellClient, policy::SandboxPolicy, + open_shell_client::OpenShellClient, policy::PolicyDocument, }; use openshell_policy::parse_authored_policy; use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; @@ -58,7 +58,7 @@ const PROFILE_JWT_SUBJECT_PREFIX: &str = "provider-profile:"; const CREATE_SANDBOX_CORRELATION_PREFIX: &str = "governance:create-sandbox"; const RELOAD_CORRELATION_PREFIX: &str = "governance:reload-policy"; const SERVICE: &str = "openshell.v1.OpenShell"; -const SANDBOX_POLICY_TYPE: &str = "openshell.policy.v1.SandboxPolicy"; +const SANDBOX_POLICY_TYPE: &str = "openshell.policy.v1.PolicyDocument"; const DEFAULT_POLICY_WATCH_INTERVAL_MS: u64 = 1_000; #[derive(Clone)] @@ -243,7 +243,7 @@ struct GovernanceInterceptorService { #[derive(Clone, Debug)] struct PolicyState { policy: Value, - policy_proto: SandboxPolicy, + policy_document: PolicyDocument, policy_hash: String, policy_signature: String, policy_signature_kid: String, @@ -654,13 +654,13 @@ fn validate_signed_policy_payload( policy_state: &PolicyState, policy_signer: &PolicySigner, ) -> Result<(), String> { - let sandbox_policy = sandbox_policy_from_interceptor_json(policy)?; - let sandbox_policy_hash = canonical_policy_hash(&sandbox_policy)?; + let policy_document = policy_document_from_interceptor_json(policy)?; + let policy_document_hash = canonical_policy_hash(&policy_document)?; policy_signer - .verify_policy_signature(signature, &sandbox_policy_hash) + .verify_policy_signature(signature, &policy_document_hash) .map_err(|err| format!("sandbox policy signature is invalid: {err}"))?; - if sandbox_policy_hash != policy_state.policy_hash - || sandbox_policy != policy_state.policy_proto + if policy_document_hash != policy_state.policy_hash + || policy_document != policy_state.policy_document { return Err("sandbox policy must match the provider governance baseline".to_string()); } @@ -788,15 +788,15 @@ fn load_policy_state( policy_yaml: &str, policy_signer: &PolicySigner, ) -> Result { - let policy_proto = parse_authored_policy(policy_yaml) + let policy_document = parse_authored_policy(policy_yaml) .map_err(|err| format!("failed to parse policy YAML: {err}"))?; - let policy = sandbox_policy_to_proto_json(&policy_proto)?; + let policy = policy_document_to_proto_json(&policy_document)?; let policy = normalize_for_struct(policy)?; - let policy_hash = canonical_policy_hash(&policy_proto)?; + let policy_hash = canonical_policy_hash(&policy_document)?; let policy_signature = policy_signer.sign_policy(&policy_hash)?; Ok(PolicyState { policy, - policy_proto, + policy_document, policy_hash, policy_signature, policy_signature_kid: policy_signer.kid().to_string(), @@ -1069,15 +1069,15 @@ fn now_secs() -> i64 { .unwrap_or(i64::MAX) } -fn sandbox_policy_to_proto_json(policy: &SandboxPolicy) -> Result { +fn policy_document_to_proto_json(policy: &PolicyDocument) -> Result { decode_message_to_json(SANDBOX_POLICY_TYPE, policy) .map_err(|err| format!("failed to render policy protobuf JSON: {err}")) } -fn sandbox_policy_from_interceptor_json(policy: &Value) -> Result { +fn policy_document_from_interceptor_json(policy: &Value) -> Result { let bytes = encode_json_to_message(SANDBOX_POLICY_TYPE, policy) .map_err(|err| format!("sandbox policy cannot be decoded as protobuf JSON: {err}"))?; - SandboxPolicy::decode(bytes.as_slice()) + PolicyDocument::decode(bytes.as_slice()) .map_err(|err| format!("sandbox policy protobuf payload is invalid: {err}")) } @@ -1255,7 +1255,7 @@ async fn propagate_policy_to_running_sandboxes( workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(policy_state.policy_proto.clone()), + policy: Some(policy_state.policy_document.clone()), annotations: policy_update_annotations(policy_state, &correlation_id), expected_resource_version: resource_version, ..Default::default() diff --git a/examples/governance-interceptor/src/policy_hash.rs b/examples/governance-interceptor/src/policy_hash.rs index 4bef17214..306ebaec8 100644 --- a/examples/governance-interceptor/src/policy_hash.rs +++ b/examples/governance-interceptor/src/policy_hash.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use openshell_core::proto::{ProviderProfile, policy::SandboxPolicy}; +use openshell_core::proto::{ProviderProfile, policy::PolicyDocument}; use prost::Message; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -10,13 +10,13 @@ use crate::proto_json::decode_message_to_json; pub(crate) const HASH_ALGORITHM: &str = "openshell-governance-protojson-sha256-v2"; const HASH_PREFIX: &str = "sha256:v2:"; -const SANDBOX_POLICY_TYPE: &str = "openshell.policy.v1.SandboxPolicy"; +const SANDBOX_POLICY_TYPE: &str = "openshell.policy.v1.PolicyDocument"; const PROVIDER_PROFILE_TYPE: &str = "openshell.v1.ProviderProfile"; const POLICY_DOMAIN: &str = "openshell-governance-policy"; const PROFILE_DOMAIN: &str = "openshell-governance-provider-profile"; const PROFILE_SNAPSHOT_DOMAIN: &str = "openshell-governance-provider-profile-snapshot"; -pub(crate) fn canonical_policy_hash(policy: &SandboxPolicy) -> Result { +pub(crate) fn canonical_policy_hash(policy: &PolicyDocument) -> Result { canonical_message_hash(SANDBOX_POLICY_TYPE, policy, POLICY_DOMAIN) } @@ -215,13 +215,13 @@ mod tests { #[test] fn digest_format_is_explicitly_v2() { - let digest = canonical_policy_hash(&SandboxPolicy::default()).unwrap(); + let digest = canonical_policy_hash(&PolicyDocument::default()).unwrap(); assert!(is_v2_digest(&digest)); assert!(!is_v2_digest("sha256:deadbeef")); assert!(!is_v2_digest(&format!("{HASH_PREFIX}{}", "A".repeat(64)))); } - fn policy_with_nested_maps(reverse: bool) -> SandboxPolicy { + fn policy_with_nested_maps(reverse: bool) -> PolicyDocument { let allow = L7Allow { query: map(reverse, [("state", "open"), ("label", "bug")]), params: map(reverse, [("name", "search"), ("kind", "tool")]), @@ -235,7 +235,7 @@ mod tests { }; let endpoint = NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], rules: vec![L7Rule { allow: Some(allow) }], deny_rules: vec![deny], graphql_persisted_queries: map( @@ -249,7 +249,7 @@ mod tests { } else { [("api", "api.example.com"), ("unused", "unused.example.com")] }; - SandboxPolicy { + PolicyDocument { version: 1, network_policies: rules .into_iter() @@ -259,7 +259,7 @@ mod tests { } else { vec![NetworkEndpoint { host: host.to_string(), - port: 443, + ports: vec![443], ..NetworkEndpoint::default() }] }; @@ -273,7 +273,7 @@ mod tests { ) }) .collect(), - ..SandboxPolicy::default() + ..PolicyDocument::default() } } diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs index f8edc3c1d..8be9f0c30 100644 --- a/examples/governance-interceptor/src/tests.rs +++ b/examples/governance-interceptor/src/tests.rs @@ -206,7 +206,7 @@ fn policy_yaml_with_dynamic_rule() -> String { name: example-api endpoints: - host: example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only"#, @@ -621,7 +621,7 @@ fn sandbox_policy_sync_requires_current_signed_governance_policy() { let mut widened = state.policy.clone(); widened["networkPolicies"]["sandbox_added"] = json!({ "name": "sandbox-added", - "endpoints": [{"host": "sandbox-added.example", "port": 443}], + "endpoints": [{"host": "sandbox-added.example", "ports": [443]}], }); let copied_annotations = service .evaluate_inner(&sandbox_evaluation( diff --git a/examples/local-inference/nvidia-inference.yaml b/examples/local-inference/nvidia-inference.yaml index 526a813bb..abf76dcec 100644 --- a/examples/local-inference/nvidia-inference.yaml +++ b/examples/local-inference/nvidia-inference.yaml @@ -18,7 +18,7 @@ discovery: credentials: [api_key] endpoints: - host: integrate.api.nvidia.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/examples/local-inference/sandbox-policy.yaml b/examples/local-inference/sandbox-policy.yaml index 25a2e635b..2ad1b9f2d 100644 --- a/examples/local-inference/sandbox-policy.yaml +++ b/examples/local-inference/sandbox-policy.yaml @@ -26,7 +26,7 @@ network_policies: pypi: name: PyPI endpoints: - - { host: pypi.org, port: 443 } - - { host: files.pythonhosted.org, port: 443 } + - { host: pypi.org, ports: [443] } + - { host: files.pythonhosted.org, ports: [443] } binaries: - path: /usr/bin/python3.13 diff --git a/examples/multi-agent-notepad/policy.template.yaml b/examples/multi-agent-notepad/policy.template.yaml index 30be72875..292790920 100644 --- a/examples/multi-agent-notepad/policy.template.yaml +++ b/examples/multi-agent-notepad/policy.template.yaml @@ -15,10 +15,10 @@ network_policies: codex: name: codex endpoints: - - { host: api.openai.com, port: 443, protocol: rest, enforcement: enforce, access: full } - - { host: auth.openai.com, port: 443, protocol: rest, enforcement: enforce, access: full } - - { host: chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } - - { host: ab.chatgpt.com, port: 443, protocol: rest, enforcement: enforce, access: full } + - { host: api.openai.com, ports: [443], protocol: rest, enforcement: enforce, access: full } + - { host: auth.openai.com, ports: [443], protocol: rest, enforcement: enforce, access: full } + - { host: chatgpt.com, ports: [443], protocol: rest, enforcement: enforce, access: full } + - { host: ab.chatgpt.com, ports: [443], protocol: rest, enforcement: enforce, access: full } binaries: - { path: /usr/bin/codex } - { path: /usr/bin/node } @@ -28,7 +28,7 @@ network_policies: name: codex-plugins endpoints: - host: github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -47,7 +47,7 @@ network_policies: name: github-memory endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: diff --git a/examples/policy-advisor/sandbox-policy.yaml b/examples/policy-advisor/sandbox-policy.yaml index 3c850f45a..fd264cbc1 100644 --- a/examples/policy-advisor/sandbox-policy.yaml +++ b/examples/policy-advisor/sandbox-policy.yaml @@ -11,4 +11,4 @@ network_policies: anthropic: name: Anthropic API endpoints: - - { host: api.anthropic.com, port: 443 } + - { host: api.anthropic.com, ports: [443] } diff --git a/examples/private-ip-routing/README.md b/examples/private-ip-routing/README.md index aae18d7ae..d2a5d6b36 100644 --- a/examples/private-ip-routing/README.md +++ b/examples/private-ip-routing/README.md @@ -18,7 +18,7 @@ entry that allows any binary to reach port 8080 on the configured pod network: cluster_pods: name: cluster_pods endpoints: - - port: 8080 + - ports: [8080] allowed_ips: - "10.42.0.0/16" binaries: diff --git a/examples/sandbox-policy-quickstart/README.md b/examples/sandbox-policy-quickstart/README.md index ce6b16bfb..653c81703 100644 --- a/examples/sandbox-policy-quickstart/README.md +++ b/examples/sandbox-policy-quickstart/README.md @@ -96,7 +96,7 @@ network_policies: name: github-api-readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest tls: terminate enforcement: enforce diff --git a/examples/sandbox-policy-quickstart/policy.yaml b/examples/sandbox-policy-quickstart/policy.yaml index a17b359eb..ea0dae6ed 100644 --- a/examples/sandbox-policy-quickstart/policy.yaml +++ b/examples/sandbox-policy-quickstart/policy.yaml @@ -21,7 +21,7 @@ network_policies: name: github-api-readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only diff --git a/examples/spiffe-token-exchange-demo/podman/provider-profile.yaml b/examples/spiffe-token-exchange-demo/podman/provider-profile.yaml index b7d1cf30e..5731770ee 100644 --- a/examples/spiffe-token-exchange-demo/podman/provider-profile.yaml +++ b/examples/spiffe-token-exchange-demo/podman/provider-profile.yaml @@ -42,13 +42,13 @@ credentials: scopes: [beta] endpoints: - host: alpha-exchange - port: 8080 + ports: [8080] protocol: rest tls: none access: read-write enforcement: enforce - host: beta-exchange - port: 8080 + ports: [8080] protocol: rest tls: none access: read-write diff --git a/examples/spiffe-token-exchange-demo/provider-profile.yaml b/examples/spiffe-token-exchange-demo/provider-profile.yaml index 48e5dbe6c..c1c5c1569 100644 --- a/examples/spiffe-token-exchange-demo/provider-profile.yaml +++ b/examples/spiffe-token-exchange-demo/provider-profile.yaml @@ -37,13 +37,13 @@ credentials: scopes: [beta] endpoints: - host: alpha-exchange.default.svc.cluster.local - port: 80 + ports: [80] protocol: rest tls: none access: read-write enforcement: enforce - host: beta-exchange.default.svc.cluster.local - port: 80 + ports: [80] protocol: rest tls: none access: read-write diff --git a/examples/spiffe-token-grant-demo/provider-profile.yaml b/examples/spiffe-token-grant-demo/provider-profile.yaml index caadfe2b3..99bac85b0 100644 --- a/examples/spiffe-token-grant-demo/provider-profile.yaml +++ b/examples/spiffe-token-grant-demo/provider-profile.yaml @@ -29,7 +29,7 @@ credentials: scopes: [beta] endpoints: - host: alpha.default.svc.cluster.local - port: 80 + ports: [80] protocol: rest tls: none access: read-write @@ -37,7 +37,7 @@ endpoints: allowed_ips: - 10.43.0.0/16 - host: beta.default.svc.cluster.local - port: 80 + ports: [80] protocol: rest tls: none access: read-write diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index 654e39a7d..8d8b659f3 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -216,6 +216,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bumpalo" version = "3.20.3" @@ -430,7 +436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -451,6 +457,17 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1225,6 +1242,7 @@ dependencies = [ "miette", "noyalib", "prost", + "prost-protovalidate", "prost-reflect", "prost-types", "protoc-bin-vendored", @@ -1419,6 +1437,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prost-protovalidate" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd223ccf88bbaf6d15f5b93eefa4c955da02f0f1da58896e146ef8d837539405" +dependencies = [ + "fluent-uri", + "prost", + "prost-protovalidate-types", + "prost-reflect", + "prost-types", + "regex", + "serde_json", + "thiserror", +] + +[[package]] +name = "prost-protovalidate-types" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4406a3b6ef7227ab6d5d264b9ae93fef5264a624954c41db12b0a91b7d098d8f" +dependencies = [ + "prost", + "prost-build", + "prost-reflect", + "prost-reflect-build", + "prost-types", + "thiserror", +] + [[package]] name = "prost-reflect" version = "0.16.5" @@ -1427,11 +1475,33 @@ checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" dependencies = [ "base64", "prost", + "prost-reflect-derive", "prost-types", "serde", "serde-value", ] +[[package]] +name = "prost-reflect-build" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a9e8261adf6617d5dc2a5a9e75cce5ab9d546a007f6f870f809a1ad25386b6" +dependencies = [ + "prost-build", + "prost-reflect", +] + +[[package]] +name = "prost-reflect-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30320eb03b43b7dfcaf9b361f808a4f1adad1e718ad219df1d7e4283e34e73f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "prost-types" version = "0.14.4" @@ -1566,6 +1636,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "regex" version = "1.13.1" @@ -1640,7 +1730,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1965,7 +2055,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2390,7 +2480,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/examples/supervisor-middleware-content-guard/policy.yaml b/examples/supervisor-middleware-content-guard/policy.yaml index da08607f2..75c769895 100644 --- a/examples/supervisor-middleware-content-guard/policy.yaml +++ b/examples/supervisor-middleware-content-guard/policy.yaml @@ -25,7 +25,7 @@ network_policies: name: httpbin endpoints: - host: httpbin.org - port: 443 + ports: [443] protocol: rest rules: - allow: @@ -37,7 +37,7 @@ network_policies: name: httpbingo endpoints: - host: httpbingo.org - port: 443 + ports: [443] protocol: rest rules: - allow: @@ -49,7 +49,7 @@ network_policies: name: Guard responses endpoints: - host: host.openshell.internal - port: 18081 + ports: [18081] protocol: rest rules: - allow: diff --git a/examples/transparent-tcp-redis/policy.yaml b/examples/transparent-tcp-redis/policy.yaml index 197e18419..cb2a9df22 100644 --- a/examples/transparent-tcp-redis/policy.yaml +++ b/examples/transparent-tcp-redis/policy.yaml @@ -15,7 +15,7 @@ network_policies: name: redis-native-tcp endpoints: - host: redis.openshell.demo - port: 6379 + ports: [6379] protocol: tcp allowed_ips: - 10.0.0.0/8 diff --git a/proto/buf/validate/README.md b/proto/buf/validate/README.md new file mode 100644 index 000000000..96d264f8b --- /dev/null +++ b/proto/buf/validate/README.md @@ -0,0 +1,13 @@ +# Vendored Protovalidate schema + +`validate.proto` is the shared annotation schema used by every OpenShell +protobuf generator. It is vendored once because Rust and Python invoke +`protoc` directly while Go and TypeScript use Buf. + +The schema body comes from `prost-protovalidate-types` 0.6.0, repository +commit `90b6d55997fd3a14dbb1611d4677aaaf583a34d4`. Keep its declarations aligned +with that pinned Rust runtime when updating either dependency. The SPDX lines +prepended for repository license checks are the only local additions. + +The upstream file is Copyright 2023-2026 Buf Technologies, Inc. and licensed +under Apache-2.0, as recorded in its header. diff --git a/proto/buf/validate/validate.proto b/proto/buf/validate/validate.proto new file mode 100644 index 000000000..8aeb37a48 --- /dev/null +++ b/proto/buf/validate/validate.proto @@ -0,0 +1,5142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// SPDX-FileCopyrightText: Copyright 2023-2026 Buf Technologies, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto2"; + +// [Protovalidate](https://protovalidate.com/) is the semantic validation library for Protobuf. +// It provides standard annotations to validate common rules on messages and fields, as well as the ability to use [CEL](https://cel.dev) to write custom rules. +// It's the next generation of [protoc-gen-validate](https://github.com/bufbuild/protoc-gen-validate). +// +// This package provides the options, messages, and enums that power Protovalidate. +// Apply its options to messages, fields, and oneofs in your Protobuf schemas to add validation rules: +// +// ```proto +// message User { +// string id = 1 [(buf.validate.field).string.uuid = true]; +// string first_name = 2 [(buf.validate.field).string.max_len = 64]; +// string last_name = 3 [(buf.validate.field).string.max_len = 64]; +// +// option (buf.validate.message).cel = { +// id: "first_name_requires_last_name" +// message: "last_name must be present if first_name is present" +// expression: "!has(this.first_name) || has(this.last_name)" +// }; +// } +// ``` +// +// These rules are enforced at runtime by language-specific libraries. +// See the [developer quickstart](https://protovalidate.com/quickstart/) to get started, or go directly to the runtime library for your language: +// [Go](https://github.com/bufbuild/protovalidate-go), +// [JavaScript/TypeScript](https://github.com/bufbuild/protovalidate-es), +// [Java](https://github.com/bufbuild/protovalidate-java), +// [Python](https://github.com/bufbuild/protovalidate-python), +// or [C++](https://github.com/bufbuild/protovalidate-cc). +package buf.validate; + +import "google/protobuf/descriptor.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/field_mask.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate"; +option java_multiple_files = true; +option java_outer_classname = "ValidateProto"; +option java_package = "build.buf.validate"; + +// MessageOptions is an extension to google.protobuf.MessageOptions. It allows +// the addition of validation rules at the message level. These rules can be +// applied to incoming messages to ensure they meet certain criteria before +// being processed. +extend google.protobuf.MessageOptions { + // Rules specify the validations to be performed on this message. By default, + // no validation is performed against a message. + optional MessageRules message = 1159; +} + +// OneofOptions is an extension to google.protobuf.OneofOptions. It allows +// the addition of validation rules on a oneof. These rules can be +// applied to incoming messages to ensure they meet certain criteria before +// being processed. +extend google.protobuf.OneofOptions { + // Rules specify the validations to be performed on this oneof. By default, + // no validation is performed against a oneof. + optional OneofRules oneof = 1159; +} + +// FieldOptions is an extension to google.protobuf.FieldOptions. It allows +// the addition of validation rules at the field level. These rules can be +// applied to incoming messages to ensure they meet certain criteria before +// being processed. +extend google.protobuf.FieldOptions { + // Rules specify the validations to be performed on this field. By default, + // no validation is performed against a field. + optional FieldRules field = 1159; + + // Specifies predefined rules. When extending a standard rule message, + // this adds additional CEL expressions that apply when the extension is used. + // + // ```proto + // extend buf.validate.Int32Rules { + // bool is_zero = 1001 [(buf.validate.predefined).cel = { + // id: "int32.is_zero", + // message: "must be zero", + // expression: "!rule || this == 0", + // }]; + // } + // + // message Foo { + // int32 reserved = 1 [(buf.validate.field).int32.(is_zero) = true]; + // } + // ``` + optional PredefinedRules predefined = 1160; +} + +// `Rule` represents a validation rule written in the Common Expression +// Language (CEL) syntax. Each Rule includes a unique identifier, an +// optional error message, and the CEL expression to evaluate. For more +// information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). +// +// ```proto +// message Foo { +// option (buf.validate.message).cel = { +// id: "foo.bar" +// message: "bar must be greater than 0" +// expression: "this.bar > 0" +// }; +// int32 bar = 1; +// } +// ``` +message Rule { + // `id` is a string that serves as a machine-readable name for this Rule. + // It should be unique within its scope, which could be either a message or a field. + optional string id = 1; + + // `message` is an optional field that provides a human-readable error message + // for this Rule when the CEL expression evaluates to false. If a + // non-empty message is provided, any strings resulting from the CEL + // expression evaluation are ignored. + optional string message = 2; + + // `expression` is the actual CEL expression that will be evaluated for + // validation. This string must resolve to either a boolean or a string + // value. If the expression evaluates to false or a non-empty string, the + // validation is considered failed, and the message is rejected. + optional string expression = 3; +} + +// MessageRules represents validation rules that are applied to the entire message. +// It includes disabling options and a list of Rule messages representing Common Expression Language (CEL) validation rules. +message MessageRules { + // `cel_expression` is a repeated field CEL expressions. Each expression specifies a validation + // rule to be applied to this message. These rules are written in Common Expression Language (CEL) syntax. + // + // This is a simplified form of the `cel` Rule field, where only `expression` is set. This allows for + // simpler syntax when defining CEL Rules where `id` and `message` derived from the `expression`. `id` will + // be same as the `expression`. + // + // For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + // + // ```proto + // message MyMessage { + // // The field `foo` must be greater than 42. + // option (buf.validate.message).cel_expression = "this.foo > 42"; + // // The field `foo` must be less than 84. + // option (buf.validate.message).cel_expression = "this.foo < 84"; + // optional int32 foo = 1; + // } + // ``` + repeated string cel_expression = 5; + // `cel` is a repeated field of type Rule. Each Rule specifies a validation rule to be applied to this message. + // These rules are written in Common Expression Language (CEL) syntax. For more information, + // [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + // + // + // ```proto + // message MyMessage { + // // The field `foo` must be greater than 42. + // option (buf.validate.message).cel = { + // id: "my_message.value", + // message: "must be greater than 42", + // expression: "this.foo > 42", + // }; + // optional int32 foo = 1; + // } + // ``` + repeated Rule cel = 3; + + // `oneof` is a repeated field of type MessageOneofRule that specifies a list of fields + // of which at most one can be present. If `required` is also specified, then exactly one + // of the specified fields _must_ be present. + // + // This will enforce oneof-like constraints with a few features not provided by + // actual Protobuf oneof declarations: + // 1. Repeated and map fields are allowed in this validation. In a Protobuf oneof, + // only scalar fields are allowed. + // 2. Fields with implicit presence are allowed. In a Protobuf oneof, all member + // fields have explicit presence. This means that, for the purpose of determining + // how many fields are set, explicitly setting such a field to its zero value is + // effectively the same as not setting it at all. + // 3. This will always generate validation errors for a message unmarshalled from + // serialized data that sets more than one field. With a Protobuf oneof, when + // multiple fields are present in the serialized form, earlier values are usually + // silently ignored when unmarshalling, with only the last field being set when + // unmarshalling completes. + // + // Note that adding a field to a `oneof` will also set the IGNORE_IF_ZERO_VALUE on the fields. This means + // only the field that is set will be validated and the unset fields are not validated according to the field rules. + // This behavior can be overridden by setting `ignore` against a field. + // + // ```proto + // message MyMessage { + // // Only one of `field1` or `field2` _can_ be present in this message. + // option (buf.validate.message).oneof = { fields: ["field1", "field2"] }; + // // Exactly one of `field3` or `field4` _must_ be present in this message. + // option (buf.validate.message).oneof = { fields: ["field3", "field4"], required: true }; + // string field1 = 1; + // bytes field2 = 2; + // bool field3 = 3; + // int32 field4 = 4; + // } + // ``` + repeated MessageOneofRule oneof = 4; + + reserved 1; + reserved "disabled"; +} + +message MessageOneofRule { + // A list of field names to include in the oneof. All field names must be + // defined in the message. At least one field must be specified, and + // duplicates are not permitted. + repeated string fields = 1; + // If true, one of the fields specified _must_ be set. + optional bool required = 2; +} + +// The `OneofRules` message type enables you to manage rules for +// oneof fields in your protobuf messages. +message OneofRules { + // If `required` is true, exactly one field of the oneof must be set. A + // validation error is returned if no fields in the oneof are set. Further rules + // should be placed on the fields themselves to ensure they are valid values, + // such as `min_len` or `gt`. + // + // ```proto + // message MyMessage { + // oneof value { + // // Either `a` or `b` must be set. If `a` is set, it must also be + // // non-empty; whereas if `b` is set, it can still be an empty string. + // option (buf.validate.oneof).required = true; + // string a = 1 [(buf.validate.field).string.min_len = 1]; + // string b = 2; + // } + // } + // ``` + optional bool required = 1; +} + +// FieldRules encapsulates the rules for each type of field. Depending on +// the field, the correct set should be used to ensure proper validations. +message FieldRules { + // `cel_expression` is a repeated field CEL expressions. Each expression specifies a validation + // rule to be applied to this message. These rules are written in Common Expression Language (CEL) syntax. + // + // This is a simplified form of the `cel` Rule field, where only `expression` is set. This allows for + // simpler syntax when defining CEL Rules where `id` and `message` derived from the `expression`. `id` will + // be same as the `expression`. + // + // For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + // + // ```proto + // message MyMessage { + // // The field `value` must be greater than 42. + // optional int32 value = 1 [(buf.validate.field).cel_expression = "this > 42"]; + // } + // ``` + repeated string cel_expression = 29; + // `cel` is a repeated field used to represent a textual expression + // in the Common Expression Language (CEL) syntax. For more information, + // [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + // + // ```proto + // message MyMessage { + // // The field `value` must be greater than 42. + // optional int32 value = 1 [(buf.validate.field).cel = { + // id: "my_message.value", + // message: "must be greater than 42", + // expression: "this > 42", + // }]; + // } + // ``` + repeated Rule cel = 23; + // If `required` is true, the field must be set. A validation error is returned + // if the field is not set. + // + // ```proto + // syntax="proto3"; + // + // message FieldsWithPresence { + // // Requires any string to be set, including the empty string. + // optional string link = 1 [ + // (buf.validate.field).required = true + // ]; + // // Requires true or false to be set. + // optional bool disabled = 2 [ + // (buf.validate.field).required = true + // ]; + // // Requires a message to be set, including the empty message. + // SomeMessage msg = 4 [ + // (buf.validate.field).required = true + // ]; + // } + // ``` + // + // All fields in the example above track presence. By default, Protovalidate + // ignores rules on those fields if no value is set. `required` ensures that + // the fields are set and valid. + // + // Fields that don't track presence are always validated by Protovalidate, + // whether they are set or not. It is not necessary to add `required`. It + // can be added to indicate that the field cannot be the zero value. + // + // ```proto + // syntax="proto3"; + // + // message FieldsWithoutPresence { + // // `string.email` always applies, even to an empty string. + // string link = 1 [ + // (buf.validate.field).string.email = true + // ]; + // // `repeated.min_items` always applies, even to an empty list. + // repeated string labels = 2 [ + // (buf.validate.field).repeated.min_items = 1 + // ]; + // // `required`, for fields that don't track presence, indicates + // // the value of the field can't be the zero value. + // int32 zero_value_not_allowed = 3 [ + // (buf.validate.field).required = true + // ]; + // } + // ``` + // + // To learn which fields track presence, see the + // [Field Presence cheat sheet](https://protobuf.dev/programming-guides/field_presence/#cheat). + // + // Note: While field rules can be applied to repeated items, map keys, and map + // values, the elements are always considered to be set. Consequently, + // specifying `repeated.items.required` is redundant. + optional bool required = 25; + // Ignore validation rules on the field if its value matches the specified + // criteria. See the `Ignore` enum for details. + // + // ```proto + // message UpdateRequest { + // // The uri rule only applies if the field is not an empty string. + // string url = 1 [ + // (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE, + // (buf.validate.field).string.uri = true + // ]; + // } + // ``` + optional Ignore ignore = 27; + + oneof type { + // Scalar Field Types + FloatRules float = 1; + DoubleRules double = 2; + Int32Rules int32 = 3; + Int64Rules int64 = 4; + UInt32Rules uint32 = 5; + UInt64Rules uint64 = 6; + SInt32Rules sint32 = 7; + SInt64Rules sint64 = 8; + Fixed32Rules fixed32 = 9; + Fixed64Rules fixed64 = 10; + SFixed32Rules sfixed32 = 11; + SFixed64Rules sfixed64 = 12; + BoolRules bool = 13; + StringRules string = 14; + BytesRules bytes = 15; + + // Complex Field Types + EnumRules enum = 16; + RepeatedRules repeated = 18; + MapRules map = 19; + + // Well-Known Field Types + AnyRules any = 20; + DurationRules duration = 21; + FieldMaskRules field_mask = 28; + TimestampRules timestamp = 22; + } + + reserved 24, 26; + reserved "skipped", "ignore_empty"; +} + +// PredefinedRules are custom rules that can be re-used with +// multiple fields. +message PredefinedRules { + // `cel` is a repeated field used to represent a textual expression + // in the Common Expression Language (CEL) syntax. For more information, + // [see our documentation](https://buf.build/docs/protovalidate/schemas/predefined-rules/). + // + // ```proto + // message MyMessage { + // // The field `value` must be greater than 42. + // optional int32 value = 1 [(buf.validate.predefined).cel = { + // id: "my_message.value", + // message: "must be greater than 42", + // expression: "this > 42", + // }]; + // } + // ``` + repeated Rule cel = 1; + + reserved 24, 26; + reserved "skipped", "ignore_empty"; +} + +// Specifies how `FieldRules.ignore` behaves, depending on the field's value, and +// whether the field tracks presence. +enum Ignore { + // Ignore rules if the field tracks presence and is unset. This is the default + // behavior. + // + // In proto3, only message fields, members of a Protobuf `oneof`, and fields + // with the `optional` label track presence. Consequently, the following fields + // are always validated, whether a value is set or not: + // + // ```proto + // syntax="proto3"; + // + // message RulesApply { + // string email = 1 [ + // (buf.validate.field).string.email = true + // ]; + // int32 age = 2 [ + // (buf.validate.field).int32.gt = 0 + // ]; + // repeated string labels = 3 [ + // (buf.validate.field).repeated.min_items = 1 + // ]; + // } + // ``` + // + // In contrast, the following fields track presence, and are only validated if + // a value is set: + // + // ```proto + // syntax="proto3"; + // + // message RulesApplyIfSet { + // optional string email = 1 [ + // (buf.validate.field).string.email = true + // ]; + // oneof ref { + // string reference = 2 [ + // (buf.validate.field).string.uuid = true + // ]; + // string name = 3 [ + // (buf.validate.field).string.min_len = 4 + // ]; + // } + // SomeMessage msg = 4 [ + // (buf.validate.field).cel = {/* ... */} + // ]; + // } + // ``` + // + // To ensure that such a field is set, add the `required` rule. + // + // To learn which fields track presence, see the + // [Field Presence cheat sheet](https://protobuf.dev/programming-guides/field_presence/#cheat). + IGNORE_UNSPECIFIED = 0; + + // Ignore rules if the field is unset, or set to the zero value. + // + // The zero value depends on the field type: + // - For strings, the zero value is the empty string. + // - For bytes, the zero value is empty bytes. + // - For bool, the zero value is false. + // - For numeric types, the zero value is zero. + // - For enums, the zero value is the first defined enum value. + // - For repeated fields, the zero is an empty list. + // - For map fields, the zero is an empty map. + // - For message fields, absence of the message (typically a null-value) is considered zero value. + // + // For fields that track presence (e.g. adding the `optional` label in proto3), + // this a no-op and behavior is the same as the default `IGNORE_UNSPECIFIED`. + IGNORE_IF_ZERO_VALUE = 1; + + // Always ignore rules, including the `required` rule. + // + // This is useful for ignoring the rules of a referenced message, or to + // temporarily ignore rules during development. + // + // ```proto + // message MyMessage { + // // The field's rules will always be ignored, including any validations + // // on value's fields. + // MyOtherMessage value = 1 [ + // (buf.validate.field).ignore = IGNORE_ALWAYS + // ]; + // } + // ``` + IGNORE_ALWAYS = 3; + + reserved 2; + reserved "IGNORE_EMPTY", "IGNORE_DEFAULT", "IGNORE_IF_DEFAULT_VALUE", "IGNORE_IF_UNPOPULATED"; +} + +// FloatRules describes the rules applied to `float` values. These +// rules may also be applied to the `google.protobuf.FloatValue` Well-Known-Type. +message FloatRules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MyFloat { + // // value must equal 42.0 + // float value = 1 [(buf.validate.field).float.const = 42.0]; + // } + // ``` + optional float const = 1 [(predefined).cel = { + id: "float.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + + oneof less_than { + // `lt` requires the field value to be less than the specified value (field < + // value). If the field value is equal to or greater than the specified value, + // an error message is generated. + // + // ```proto + // message MyFloat { + // // must be less than 10.0 + // float value = 1 [(buf.validate.field).float.lt = 10.0]; + // } + // ``` + float lt = 2 [(predefined).cel = { + id: "float.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && (this.isNan() || this >= rules.lt)" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MyFloat { + // // must be less than or equal to 10.0 + // float value = 1 [(buf.validate.field).float.lte = 10.0]; + // } + // ``` + float lte = 3 [(predefined).cel = { + id: "float.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && (this.isNan() || this > rules.lte)" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyFloat { + // // must be greater than 5.0 [float.gt] + // float value = 1 [(buf.validate.field).float.gt = 5.0]; + // + // // must be greater than 5 and less than 10.0 [float.gt_lt] + // float other_value = 2 [(buf.validate.field).float = { gt: 5.0, lt: 10.0 }]; + // + // // must be greater than 10 or less than 5.0 [float.gt_lt_exclusive] + // float another_value = 3 [(buf.validate.field).float = { gt: 10.0, lt: 5.0 }]; + // } + // ``` + float gt = 4 [ + (predefined).cel = { + id: "float.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && (this.isNan() || this <= rules.gt)" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "float.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this.isNan() || this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "float.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (this.isNan() || (rules.lt <= this && this <= rules.gt))" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "float.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this.isNan() || this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "float.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (this.isNan() || (rules.lte < this && this <= rules.gt))" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyFloat { + // // must be greater than or equal to 5.0 [float.gte] + // float value = 1 [(buf.validate.field).float.gte = 5.0]; + // + // // must be greater than or equal to 5.0 and less than 10.0 [float.gte_lt] + // float other_value = 2 [(buf.validate.field).float = { gte: 5.0, lt: 10.0 }]; + // + // // must be greater than or equal to 10.0 or less than 5.0 [float.gte_lt_exclusive] + // float another_value = 3 [(buf.validate.field).float = { gte: 10.0, lt: 5.0 }]; + // } + // ``` + float gte = 5 [ + (predefined).cel = { + id: "float.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && (this.isNan() || this < rules.gte)" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "float.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this.isNan() || this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "float.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (this.isNan() || (rules.lt <= this && this < rules.gte))" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "float.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this.isNan() || this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "float.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (this.isNan() || (rules.lte < this && this < rules.gte))" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message + // is generated. + // + // ```proto + // message MyFloat { + // // must be in list [1.0, 2.0, 3.0] + // float value = 1 [(buf.validate.field).float = { in: [1.0, 2.0, 3.0] }]; + // } + // ``` + repeated float in = 6 [(predefined).cel = { + id: "float.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MyFloat { + // // value must not be in list [1.0, 2.0, 3.0] + // float value = 1 [(buf.validate.field).float = { not_in: [1.0, 2.0, 3.0] }]; + // } + // ``` + repeated float not_in = 7 [(predefined).cel = { + id: "float.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `finite` requires the field value to be finite. If the field value is + // infinite or NaN, an error message is generated. + optional bool finite = 8 [(predefined).cel = { + id: "float.finite" + expression: "rules.finite ? (this.isNan() || this.isInf() ? 'must be finite' : '') : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyFloat { + // float value = 1 [ + // (buf.validate.field).float.example = 1.0, + // (buf.validate.field).float.example = inf + // ]; + // } + // ``` + repeated float example = 9 [(predefined).cel = { + id: "float.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// DoubleRules describes the rules applied to `double` values. These +// rules may also be applied to the `google.protobuf.DoubleValue` Well-Known-Type. +message DoubleRules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MyDouble { + // // value must equal 42.0 + // double value = 1 [(buf.validate.field).double.const = 42.0]; + // } + // ``` + optional double const = 1 [(predefined).cel = { + id: "double.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field < + // value). If the field value is equal to or greater than the specified + // value, an error message is generated. + // + // ```proto + // message MyDouble { + // // must be less than 10.0 + // double value = 1 [(buf.validate.field).double.lt = 10.0]; + // } + // ``` + double lt = 2 [(predefined).cel = { + id: "double.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && (this.isNan() || this >= rules.lt)" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified value + // (field <= value). If the field value is greater than the specified value, + // an error message is generated. + // + // ```proto + // message MyDouble { + // // must be less than or equal to 10.0 + // double value = 1 [(buf.validate.field).double.lte = 10.0]; + // } + // ``` + double lte = 3 [(predefined).cel = { + id: "double.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && (this.isNan() || this > rules.lte)" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or `lte`, + // the range is reversed, and the field value must be outside the specified + // range. If the field value doesn't meet the required conditions, an error + // message is generated. + // + // ```proto + // message MyDouble { + // // must be greater than 5.0 [double.gt] + // double value = 1 [(buf.validate.field).double.gt = 5.0]; + // + // // must be greater than 5 and less than 10.0 [double.gt_lt] + // double other_value = 2 [(buf.validate.field).double = { gt: 5.0, lt: 10.0 }]; + // + // // must be greater than 10 or less than 5.0 [double.gt_lt_exclusive] + // double another_value = 3 [(buf.validate.field).double = { gt: 10.0, lt: 5.0 }]; + // } + // ``` + double gt = 4 [ + (predefined).cel = { + id: "double.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && (this.isNan() || this <= rules.gt)" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "double.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this.isNan() || this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "double.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (this.isNan() || (rules.lt <= this && this <= rules.gt))" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "double.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this.isNan() || this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "double.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (this.isNan() || (rules.lte < this && this <= rules.gt))" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyDouble { + // // must be greater than or equal to 5.0 [double.gte] + // double value = 1 [(buf.validate.field).double.gte = 5.0]; + // + // // must be greater than or equal to 5.0 and less than 10.0 [double.gte_lt] + // double other_value = 2 [(buf.validate.field).double = { gte: 5.0, lt: 10.0 }]; + // + // // must be greater than or equal to 10.0 or less than 5.0 [double.gte_lt_exclusive] + // double another_value = 3 [(buf.validate.field).double = { gte: 10.0, lt: 5.0 }]; + // } + // ``` + double gte = 5 [ + (predefined).cel = { + id: "double.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && (this.isNan() || this < rules.gte)" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "double.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this.isNan() || this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "double.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (this.isNan() || (rules.lt <= this && this < rules.gte))" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "double.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this.isNan() || this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "double.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (this.isNan() || (rules.lte < this && this < rules.gte))" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message is + // generated. + // + // ```proto + // message MyDouble { + // // must be in list [1.0, 2.0, 3.0] + // double value = 1 [(buf.validate.field).double = { in: [1.0, 2.0, 3.0] }]; + // } + // ``` + repeated double in = 6 [(predefined).cel = { + id: "double.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MyDouble { + // // value must not be in list [1.0, 2.0, 3.0] + // double value = 1 [(buf.validate.field).double = { not_in: [1.0, 2.0, 3.0] }]; + // } + // ``` + repeated double not_in = 7 [(predefined).cel = { + id: "double.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `finite` requires the field value to be finite. If the field value is + // infinite or NaN, an error message is generated. + optional bool finite = 8 [(predefined).cel = { + id: "double.finite" + expression: "rules.finite ? (this.isNan() || this.isInf() ? 'must be finite' : '') : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyDouble { + // double value = 1 [ + // (buf.validate.field).double.example = 1.0, + // (buf.validate.field).double.example = inf + // ]; + // } + // ``` + repeated double example = 9 [(predefined).cel = { + id: "double.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// Int32Rules describes the rules applied to `int32` values. These +// rules may also be applied to the `google.protobuf.Int32Value` Well-Known-Type. +message Int32Rules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MyInt32 { + // // value must equal 42 + // int32 value = 1 [(buf.validate.field).int32.const = 42]; + // } + // ``` + optional int32 const = 1 [(predefined).cel = { + id: "int32.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field + // < value). If the field value is equal to or greater than the specified + // value, an error message is generated. + // + // ```proto + // message MyInt32 { + // // must be less than 10 + // int32 value = 1 [(buf.validate.field).int32.lt = 10]; + // } + // ``` + int32 lt = 2 [(predefined).cel = { + id: "int32.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MyInt32 { + // // must be less than or equal to 10 + // int32 value = 1 [(buf.validate.field).int32.lte = 10]; + // } + // ``` + int32 lte = 3 [(predefined).cel = { + id: "int32.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyInt32 { + // // must be greater than 5 [int32.gt] + // int32 value = 1 [(buf.validate.field).int32.gt = 5]; + // + // // must be greater than 5 and less than 10 [int32.gt_lt] + // int32 other_value = 2 [(buf.validate.field).int32 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [int32.gt_lt_exclusive] + // int32 another_value = 3 [(buf.validate.field).int32 = { gt: 10, lt: 5 }]; + // } + // ``` + int32 gt = 4 [ + (predefined).cel = { + id: "int32.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "int32.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "int32.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "int32.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "int32.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified value + // (exclusive). If the value of `gte` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyInt32 { + // // must be greater than or equal to 5 [int32.gte] + // int32 value = 1 [(buf.validate.field).int32.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [int32.gte_lt] + // int32 other_value = 2 [(buf.validate.field).int32 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [int32.gte_lt_exclusive] + // int32 another_value = 3 [(buf.validate.field).int32 = { gte: 10, lt: 5 }]; + // } + // ``` + int32 gte = 5 [ + (predefined).cel = { + id: "int32.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "int32.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "int32.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "int32.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "int32.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message is + // generated. + // + // ```proto + // message MyInt32 { + // // must be in list [1, 2, 3] + // int32 value = 1 [(buf.validate.field).int32 = { in: [1, 2, 3] }]; + // } + // ``` + repeated int32 in = 6 [(predefined).cel = { + id: "int32.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error message + // is generated. + // + // ```proto + // message MyInt32 { + // // value must not be in list [1, 2, 3] + // int32 value = 1 [(buf.validate.field).int32 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated int32 not_in = 7 [(predefined).cel = { + id: "int32.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyInt32 { + // int32 value = 1 [ + // (buf.validate.field).int32.example = 1, + // (buf.validate.field).int32.example = -10 + // ]; + // } + // ``` + repeated int32 example = 8 [(predefined).cel = { + id: "int32.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// Int64Rules describes the rules applied to `int64` values. These +// rules may also be applied to the `google.protobuf.Int64Value` Well-Known-Type. +message Int64Rules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MyInt64 { + // // value must equal 42 + // int64 value = 1 [(buf.validate.field).int64.const = 42]; + // } + // ``` + optional int64 const = 1 [(predefined).cel = { + id: "int64.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field < + // value). If the field value is equal to or greater than the specified value, + // an error message is generated. + // + // ```proto + // message MyInt64 { + // // must be less than 10 + // int64 value = 1 [(buf.validate.field).int64.lt = 10]; + // } + // ``` + int64 lt = 2 [(predefined).cel = { + id: "int64.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MyInt64 { + // // must be less than or equal to 10 + // int64 value = 1 [(buf.validate.field).int64.lte = 10]; + // } + // ``` + int64 lte = 3 [(predefined).cel = { + id: "int64.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyInt64 { + // // must be greater than 5 [int64.gt] + // int64 value = 1 [(buf.validate.field).int64.gt = 5]; + // + // // must be greater than 5 and less than 10 [int64.gt_lt] + // int64 other_value = 2 [(buf.validate.field).int64 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [int64.gt_lt_exclusive] + // int64 another_value = 3 [(buf.validate.field).int64 = { gt: 10, lt: 5 }]; + // } + // ``` + int64 gt = 4 [ + (predefined).cel = { + id: "int64.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "int64.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "int64.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "int64.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "int64.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyInt64 { + // // must be greater than or equal to 5 [int64.gte] + // int64 value = 1 [(buf.validate.field).int64.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [int64.gte_lt] + // int64 other_value = 2 [(buf.validate.field).int64 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [int64.gte_lt_exclusive] + // int64 another_value = 3 [(buf.validate.field).int64 = { gte: 10, lt: 5 }]; + // } + // ``` + int64 gte = 5 [ + (predefined).cel = { + id: "int64.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "int64.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "int64.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "int64.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "int64.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message is + // generated. + // + // ```proto + // message MyInt64 { + // // must be in list [1, 2, 3] + // int64 value = 1 [(buf.validate.field).int64 = { in: [1, 2, 3] }]; + // } + // ``` + repeated int64 in = 6 [(predefined).cel = { + id: "int64.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MyInt64 { + // // value must not be in list [1, 2, 3] + // int64 value = 1 [(buf.validate.field).int64 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated int64 not_in = 7 [(predefined).cel = { + id: "int64.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyInt64 { + // int64 value = 1 [ + // (buf.validate.field).int64.example = 1, + // (buf.validate.field).int64.example = -10 + // ]; + // } + // ``` + repeated int64 example = 9 [(predefined).cel = { + id: "int64.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// UInt32Rules describes the rules applied to `uint32` values. These +// rules may also be applied to the `google.protobuf.UInt32Value` Well-Known-Type. +message UInt32Rules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MyUInt32 { + // // value must equal 42 + // uint32 value = 1 [(buf.validate.field).uint32.const = 42]; + // } + // ``` + optional uint32 const = 1 [(predefined).cel = { + id: "uint32.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field < + // value). If the field value is equal to or greater than the specified value, + // an error message is generated. + // + // ```proto + // message MyUInt32 { + // // must be less than 10 + // uint32 value = 1 [(buf.validate.field).uint32.lt = 10]; + // } + // ``` + uint32 lt = 2 [(predefined).cel = { + id: "uint32.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MyUInt32 { + // // must be less than or equal to 10 + // uint32 value = 1 [(buf.validate.field).uint32.lte = 10]; + // } + // ``` + uint32 lte = 3 [(predefined).cel = { + id: "uint32.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyUInt32 { + // // must be greater than 5 [uint32.gt] + // uint32 value = 1 [(buf.validate.field).uint32.gt = 5]; + // + // // must be greater than 5 and less than 10 [uint32.gt_lt] + // uint32 other_value = 2 [(buf.validate.field).uint32 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [uint32.gt_lt_exclusive] + // uint32 another_value = 3 [(buf.validate.field).uint32 = { gt: 10, lt: 5 }]; + // } + // ``` + uint32 gt = 4 [ + (predefined).cel = { + id: "uint32.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "uint32.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "uint32.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "uint32.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "uint32.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyUInt32 { + // // must be greater than or equal to 5 [uint32.gte] + // uint32 value = 1 [(buf.validate.field).uint32.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [uint32.gte_lt] + // uint32 other_value = 2 [(buf.validate.field).uint32 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [uint32.gte_lt_exclusive] + // uint32 another_value = 3 [(buf.validate.field).uint32 = { gte: 10, lt: 5 }]; + // } + // ``` + uint32 gte = 5 [ + (predefined).cel = { + id: "uint32.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "uint32.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "uint32.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "uint32.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "uint32.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message is + // generated. + // + // ```proto + // message MyUInt32 { + // // must be in list [1, 2, 3] + // uint32 value = 1 [(buf.validate.field).uint32 = { in: [1, 2, 3] }]; + // } + // ``` + repeated uint32 in = 6 [(predefined).cel = { + id: "uint32.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MyUInt32 { + // // value must not be in list [1, 2, 3] + // uint32 value = 1 [(buf.validate.field).uint32 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated uint32 not_in = 7 [(predefined).cel = { + id: "uint32.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyUInt32 { + // uint32 value = 1 [ + // (buf.validate.field).uint32.example = 1, + // (buf.validate.field).uint32.example = 10 + // ]; + // } + // ``` + repeated uint32 example = 8 [(predefined).cel = { + id: "uint32.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// UInt64Rules describes the rules applied to `uint64` values. These +// rules may also be applied to the `google.protobuf.UInt64Value` Well-Known-Type. +message UInt64Rules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MyUInt64 { + // // value must equal 42 + // uint64 value = 1 [(buf.validate.field).uint64.const = 42]; + // } + // ``` + optional uint64 const = 1 [(predefined).cel = { + id: "uint64.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field < + // value). If the field value is equal to or greater than the specified value, + // an error message is generated. + // + // ```proto + // message MyUInt64 { + // // must be less than 10 + // uint64 value = 1 [(buf.validate.field).uint64.lt = 10]; + // } + // ``` + uint64 lt = 2 [(predefined).cel = { + id: "uint64.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MyUInt64 { + // // must be less than or equal to 10 + // uint64 value = 1 [(buf.validate.field).uint64.lte = 10]; + // } + // ``` + uint64 lte = 3 [(predefined).cel = { + id: "uint64.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyUInt64 { + // // must be greater than 5 [uint64.gt] + // uint64 value = 1 [(buf.validate.field).uint64.gt = 5]; + // + // // must be greater than 5 and less than 10 [uint64.gt_lt] + // uint64 other_value = 2 [(buf.validate.field).uint64 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [uint64.gt_lt_exclusive] + // uint64 another_value = 3 [(buf.validate.field).uint64 = { gt: 10, lt: 5 }]; + // } + // ``` + uint64 gt = 4 [ + (predefined).cel = { + id: "uint64.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "uint64.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "uint64.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "uint64.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "uint64.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyUInt64 { + // // must be greater than or equal to 5 [uint64.gte] + // uint64 value = 1 [(buf.validate.field).uint64.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [uint64.gte_lt] + // uint64 other_value = 2 [(buf.validate.field).uint64 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [uint64.gte_lt_exclusive] + // uint64 another_value = 3 [(buf.validate.field).uint64 = { gte: 10, lt: 5 }]; + // } + // ``` + uint64 gte = 5 [ + (predefined).cel = { + id: "uint64.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "uint64.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "uint64.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "uint64.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "uint64.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message is + // generated. + // + // ```proto + // message MyUInt64 { + // // must be in list [1, 2, 3] + // uint64 value = 1 [(buf.validate.field).uint64 = { in: [1, 2, 3] }]; + // } + // ``` + repeated uint64 in = 6 [(predefined).cel = { + id: "uint64.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MyUInt64 { + // // value must not be in list [1, 2, 3] + // uint64 value = 1 [(buf.validate.field).uint64 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated uint64 not_in = 7 [(predefined).cel = { + id: "uint64.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyUInt64 { + // uint64 value = 1 [ + // (buf.validate.field).uint64.example = 1, + // (buf.validate.field).uint64.example = 10 + // ]; + // } + // ``` + repeated uint64 example = 8 [(predefined).cel = { + id: "uint64.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// SInt32Rules describes the rules applied to `sint32` values. +message SInt32Rules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MySInt32 { + // // value must equal 42 + // sint32 value = 1 [(buf.validate.field).sint32.const = 42]; + // } + // ``` + optional sint32 const = 1 [(predefined).cel = { + id: "sint32.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field + // < value). If the field value is equal to or greater than the specified + // value, an error message is generated. + // + // ```proto + // message MySInt32 { + // // must be less than 10 + // sint32 value = 1 [(buf.validate.field).sint32.lt = 10]; + // } + // ``` + sint32 lt = 2 [(predefined).cel = { + id: "sint32.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MySInt32 { + // // must be less than or equal to 10 + // sint32 value = 1 [(buf.validate.field).sint32.lte = 10]; + // } + // ``` + sint32 lte = 3 [(predefined).cel = { + id: "sint32.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MySInt32 { + // // must be greater than 5 [sint32.gt] + // sint32 value = 1 [(buf.validate.field).sint32.gt = 5]; + // + // // must be greater than 5 and less than 10 [sint32.gt_lt] + // sint32 other_value = 2 [(buf.validate.field).sint32 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [sint32.gt_lt_exclusive] + // sint32 another_value = 3 [(buf.validate.field).sint32 = { gt: 10, lt: 5 }]; + // } + // ``` + sint32 gt = 4 [ + (predefined).cel = { + id: "sint32.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "sint32.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sint32.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sint32.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "sint32.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MySInt32 { + // // must be greater than or equal to 5 [sint32.gte] + // sint32 value = 1 [(buf.validate.field).sint32.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [sint32.gte_lt] + // sint32 other_value = 2 [(buf.validate.field).sint32 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [sint32.gte_lt_exclusive] + // sint32 another_value = 3 [(buf.validate.field).sint32 = { gte: 10, lt: 5 }]; + // } + // ``` + sint32 gte = 5 [ + (predefined).cel = { + id: "sint32.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "sint32.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sint32.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sint32.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "sint32.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message is + // generated. + // + // ```proto + // message MySInt32 { + // // must be in list [1, 2, 3] + // sint32 value = 1 [(buf.validate.field).sint32 = { in: [1, 2, 3] }]; + // } + // ``` + repeated sint32 in = 6 [(predefined).cel = { + id: "sint32.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MySInt32 { + // // value must not be in list [1, 2, 3] + // sint32 value = 1 [(buf.validate.field).sint32 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated sint32 not_in = 7 [(predefined).cel = { + id: "sint32.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MySInt32 { + // sint32 value = 1 [ + // (buf.validate.field).sint32.example = 1, + // (buf.validate.field).sint32.example = -10 + // ]; + // } + // ``` + repeated sint32 example = 8 [(predefined).cel = { + id: "sint32.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// SInt64Rules describes the rules applied to `sint64` values. +message SInt64Rules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MySInt64 { + // // value must equal 42 + // sint64 value = 1 [(buf.validate.field).sint64.const = 42]; + // } + // ``` + optional sint64 const = 1 [(predefined).cel = { + id: "sint64.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field + // < value). If the field value is equal to or greater than the specified + // value, an error message is generated. + // + // ```proto + // message MySInt64 { + // // must be less than 10 + // sint64 value = 1 [(buf.validate.field).sint64.lt = 10]; + // } + // ``` + sint64 lt = 2 [(predefined).cel = { + id: "sint64.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MySInt64 { + // // must be less than or equal to 10 + // sint64 value = 1 [(buf.validate.field).sint64.lte = 10]; + // } + // ``` + sint64 lte = 3 [(predefined).cel = { + id: "sint64.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MySInt64 { + // // must be greater than 5 [sint64.gt] + // sint64 value = 1 [(buf.validate.field).sint64.gt = 5]; + // + // // must be greater than 5 and less than 10 [sint64.gt_lt] + // sint64 other_value = 2 [(buf.validate.field).sint64 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [sint64.gt_lt_exclusive] + // sint64 another_value = 3 [(buf.validate.field).sint64 = { gt: 10, lt: 5 }]; + // } + // ``` + sint64 gt = 4 [ + (predefined).cel = { + id: "sint64.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "sint64.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sint64.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sint64.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "sint64.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MySInt64 { + // // must be greater than or equal to 5 [sint64.gte] + // sint64 value = 1 [(buf.validate.field).sint64.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [sint64.gte_lt] + // sint64 other_value = 2 [(buf.validate.field).sint64 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [sint64.gte_lt_exclusive] + // sint64 another_value = 3 [(buf.validate.field).sint64 = { gte: 10, lt: 5 }]; + // } + // ``` + sint64 gte = 5 [ + (predefined).cel = { + id: "sint64.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "sint64.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sint64.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sint64.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "sint64.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message + // is generated. + // + // ```proto + // message MySInt64 { + // // must be in list [1, 2, 3] + // sint64 value = 1 [(buf.validate.field).sint64 = { in: [1, 2, 3] }]; + // } + // ``` + repeated sint64 in = 6 [(predefined).cel = { + id: "sint64.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MySInt64 { + // // value must not be in list [1, 2, 3] + // sint64 value = 1 [(buf.validate.field).sint64 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated sint64 not_in = 7 [(predefined).cel = { + id: "sint64.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MySInt64 { + // sint64 value = 1 [ + // (buf.validate.field).sint64.example = 1, + // (buf.validate.field).sint64.example = -10 + // ]; + // } + // ``` + repeated sint64 example = 8 [(predefined).cel = { + id: "sint64.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// Fixed32Rules describes the rules applied to `fixed32` values. +message Fixed32Rules { + // `const` requires the field value to exactly match the specified value. + // If the field value doesn't match, an error message is generated. + // + // ```proto + // message MyFixed32 { + // // value must equal 42 + // fixed32 value = 1 [(buf.validate.field).fixed32.const = 42]; + // } + // ``` + optional fixed32 const = 1 [(predefined).cel = { + id: "fixed32.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field < + // value). If the field value is equal to or greater than the specified value, + // an error message is generated. + // + // ```proto + // message MyFixed32 { + // // must be less than 10 + // fixed32 value = 1 [(buf.validate.field).fixed32.lt = 10]; + // } + // ``` + fixed32 lt = 2 [(predefined).cel = { + id: "fixed32.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MyFixed32 { + // // must be less than or equal to 10 + // fixed32 value = 1 [(buf.validate.field).fixed32.lte = 10]; + // } + // ``` + fixed32 lte = 3 [(predefined).cel = { + id: "fixed32.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyFixed32 { + // // must be greater than 5 [fixed32.gt] + // fixed32 value = 1 [(buf.validate.field).fixed32.gt = 5]; + // + // // must be greater than 5 and less than 10 [fixed32.gt_lt] + // fixed32 other_value = 2 [(buf.validate.field).fixed32 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [fixed32.gt_lt_exclusive] + // fixed32 another_value = 3 [(buf.validate.field).fixed32 = { gt: 10, lt: 5 }]; + // } + // ``` + fixed32 gt = 4 [ + (predefined).cel = { + id: "fixed32.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "fixed32.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "fixed32.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "fixed32.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "fixed32.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyFixed32 { + // // must be greater than or equal to 5 [fixed32.gte] + // fixed32 value = 1 [(buf.validate.field).fixed32.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [fixed32.gte_lt] + // fixed32 other_value = 2 [(buf.validate.field).fixed32 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [fixed32.gte_lt_exclusive] + // fixed32 another_value = 3 [(buf.validate.field).fixed32 = { gte: 10, lt: 5 }]; + // } + // ``` + fixed32 gte = 5 [ + (predefined).cel = { + id: "fixed32.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "fixed32.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "fixed32.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "fixed32.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "fixed32.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message + // is generated. + // + // ```proto + // message MyFixed32 { + // // must be in list [1, 2, 3] + // fixed32 value = 1 [(buf.validate.field).fixed32 = { in: [1, 2, 3] }]; + // } + // ``` + repeated fixed32 in = 6 [(predefined).cel = { + id: "fixed32.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MyFixed32 { + // // value must not be in list [1, 2, 3] + // fixed32 value = 1 [(buf.validate.field).fixed32 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated fixed32 not_in = 7 [(predefined).cel = { + id: "fixed32.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyFixed32 { + // fixed32 value = 1 [ + // (buf.validate.field).fixed32.example = 1, + // (buf.validate.field).fixed32.example = 2 + // ]; + // } + // ``` + repeated fixed32 example = 8 [(predefined).cel = { + id: "fixed32.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// Fixed64Rules describes the rules applied to `fixed64` values. +message Fixed64Rules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MyFixed64 { + // // value must equal 42 + // fixed64 value = 1 [(buf.validate.field).fixed64.const = 42]; + // } + // ``` + optional fixed64 const = 1 [(predefined).cel = { + id: "fixed64.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field < + // value). If the field value is equal to or greater than the specified value, + // an error message is generated. + // + // ```proto + // message MyFixed64 { + // // must be less than 10 + // fixed64 value = 1 [(buf.validate.field).fixed64.lt = 10]; + // } + // ``` + fixed64 lt = 2 [(predefined).cel = { + id: "fixed64.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MyFixed64 { + // // must be less than or equal to 10 + // fixed64 value = 1 [(buf.validate.field).fixed64.lte = 10]; + // } + // ``` + fixed64 lte = 3 [(predefined).cel = { + id: "fixed64.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyFixed64 { + // // must be greater than 5 [fixed64.gt] + // fixed64 value = 1 [(buf.validate.field).fixed64.gt = 5]; + // + // // must be greater than 5 and less than 10 [fixed64.gt_lt] + // fixed64 other_value = 2 [(buf.validate.field).fixed64 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [fixed64.gt_lt_exclusive] + // fixed64 another_value = 3 [(buf.validate.field).fixed64 = { gt: 10, lt: 5 }]; + // } + // ``` + fixed64 gt = 4 [ + (predefined).cel = { + id: "fixed64.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "fixed64.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "fixed64.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "fixed64.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "fixed64.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyFixed64 { + // // must be greater than or equal to 5 [fixed64.gte] + // fixed64 value = 1 [(buf.validate.field).fixed64.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [fixed64.gte_lt] + // fixed64 other_value = 2 [(buf.validate.field).fixed64 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [fixed64.gte_lt_exclusive] + // fixed64 another_value = 3 [(buf.validate.field).fixed64 = { gte: 10, lt: 5 }]; + // } + // ``` + fixed64 gte = 5 [ + (predefined).cel = { + id: "fixed64.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "fixed64.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "fixed64.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "fixed64.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "fixed64.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message is + // generated. + // + // ```proto + // message MyFixed64 { + // // must be in list [1, 2, 3] + // fixed64 value = 1 [(buf.validate.field).fixed64 = { in: [1, 2, 3] }]; + // } + // ``` + repeated fixed64 in = 6 [(predefined).cel = { + id: "fixed64.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MyFixed64 { + // // value must not be in list [1, 2, 3] + // fixed64 value = 1 [(buf.validate.field).fixed64 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated fixed64 not_in = 7 [(predefined).cel = { + id: "fixed64.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyFixed64 { + // fixed64 value = 1 [ + // (buf.validate.field).fixed64.example = 1, + // (buf.validate.field).fixed64.example = 2 + // ]; + // } + // ``` + repeated fixed64 example = 8 [(predefined).cel = { + id: "fixed64.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// SFixed32Rules describes the rules applied to `fixed32` values. +message SFixed32Rules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MySFixed32 { + // // value must equal 42 + // sfixed32 value = 1 [(buf.validate.field).sfixed32.const = 42]; + // } + // ``` + optional sfixed32 const = 1 [(predefined).cel = { + id: "sfixed32.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field < + // value). If the field value is equal to or greater than the specified value, + // an error message is generated. + // + // ```proto + // message MySFixed32 { + // // must be less than 10 + // sfixed32 value = 1 [(buf.validate.field).sfixed32.lt = 10]; + // } + // ``` + sfixed32 lt = 2 [(predefined).cel = { + id: "sfixed32.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MySFixed32 { + // // must be less than or equal to 10 + // sfixed32 value = 1 [(buf.validate.field).sfixed32.lte = 10]; + // } + // ``` + sfixed32 lte = 3 [(predefined).cel = { + id: "sfixed32.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MySFixed32 { + // // must be greater than 5 [sfixed32.gt] + // sfixed32 value = 1 [(buf.validate.field).sfixed32.gt = 5]; + // + // // must be greater than 5 and less than 10 [sfixed32.gt_lt] + // sfixed32 other_value = 2 [(buf.validate.field).sfixed32 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [sfixed32.gt_lt_exclusive] + // sfixed32 another_value = 3 [(buf.validate.field).sfixed32 = { gt: 10, lt: 5 }]; + // } + // ``` + sfixed32 gt = 4 [ + (predefined).cel = { + id: "sfixed32.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "sfixed32.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sfixed32.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sfixed32.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "sfixed32.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MySFixed32 { + // // must be greater than or equal to 5 [sfixed32.gte] + // sfixed32 value = 1 [(buf.validate.field).sfixed32.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [sfixed32.gte_lt] + // sfixed32 other_value = 2 [(buf.validate.field).sfixed32 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [sfixed32.gte_lt_exclusive] + // sfixed32 another_value = 3 [(buf.validate.field).sfixed32 = { gte: 10, lt: 5 }]; + // } + // ``` + sfixed32 gte = 5 [ + (predefined).cel = { + id: "sfixed32.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "sfixed32.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sfixed32.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sfixed32.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "sfixed32.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message is + // generated. + // + // ```proto + // message MySFixed32 { + // // must be in list [1, 2, 3] + // sfixed32 value = 1 [(buf.validate.field).sfixed32 = { in: [1, 2, 3] }]; + // } + // ``` + repeated sfixed32 in = 6 [(predefined).cel = { + id: "sfixed32.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MySFixed32 { + // // value must not be in list [1, 2, 3] + // sfixed32 value = 1 [(buf.validate.field).sfixed32 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated sfixed32 not_in = 7 [(predefined).cel = { + id: "sfixed32.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MySFixed32 { + // sfixed32 value = 1 [ + // (buf.validate.field).sfixed32.example = 1, + // (buf.validate.field).sfixed32.example = 2 + // ]; + // } + // ``` + repeated sfixed32 example = 8 [(predefined).cel = { + id: "sfixed32.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// SFixed64Rules describes the rules applied to `fixed64` values. +message SFixed64Rules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MySFixed64 { + // // value must equal 42 + // sfixed64 value = 1 [(buf.validate.field).sfixed64.const = 42]; + // } + // ``` + optional sfixed64 const = 1 [(predefined).cel = { + id: "sfixed64.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the field value to be less than the specified value (field < + // value). If the field value is equal to or greater than the specified value, + // an error message is generated. + // + // ```proto + // message MySFixed64 { + // // must be less than 10 + // sfixed64 value = 1 [(buf.validate.field).sfixed64.lt = 10]; + // } + // ``` + sfixed64 lt = 2 [(predefined).cel = { + id: "sfixed64.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the field value to be less than or equal to the specified + // value (field <= value). If the field value is greater than the specified + // value, an error message is generated. + // + // ```proto + // message MySFixed64 { + // // must be less than or equal to 10 + // sfixed64 value = 1 [(buf.validate.field).sfixed64.lte = 10]; + // } + // ``` + sfixed64 lte = 3 [(predefined).cel = { + id: "sfixed64.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the field value to be greater than the specified value + // (exclusive). If the value of `gt` is larger than a specified `lt` or + // `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MySFixed64 { + // // must be greater than 5 [sfixed64.gt] + // sfixed64 value = 1 [(buf.validate.field).sfixed64.gt = 5]; + // + // // must be greater than 5 and less than 10 [sfixed64.gt_lt] + // sfixed64 other_value = 2 [(buf.validate.field).sfixed64 = { gt: 5, lt: 10 }]; + // + // // must be greater than 10 or less than 5 [sfixed64.gt_lt_exclusive] + // sfixed64 another_value = 3 [(buf.validate.field).sfixed64 = { gt: 10, lt: 5 }]; + // } + // ``` + sfixed64 gt = 4 [ + (predefined).cel = { + id: "sfixed64.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "sfixed64.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sfixed64.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sfixed64.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "sfixed64.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the field value to be greater than or equal to the specified + // value (exclusive). If the value of `gte` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MySFixed64 { + // // must be greater than or equal to 5 [sfixed64.gte] + // sfixed64 value = 1 [(buf.validate.field).sfixed64.gte = 5]; + // + // // must be greater than or equal to 5 and less than 10 [sfixed64.gte_lt] + // sfixed64 other_value = 2 [(buf.validate.field).sfixed64 = { gte: 5, lt: 10 }]; + // + // // must be greater than or equal to 10 or less than 5 [sfixed64.gte_lt_exclusive] + // sfixed64 another_value = 3 [(buf.validate.field).sfixed64 = { gte: 10, lt: 5 }]; + // } + // ``` + sfixed64 gte = 5 [ + (predefined).cel = { + id: "sfixed64.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "sfixed64.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sfixed64.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "sfixed64.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "sfixed64.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` requires the field value to be equal to one of the specified values. + // If the field value isn't one of the specified values, an error message is + // generated. + // + // ```proto + // message MySFixed64 { + // // must be in list [1, 2, 3] + // sfixed64 value = 1 [(buf.validate.field).sfixed64 = { in: [1, 2, 3] }]; + // } + // ``` + repeated sfixed64 in = 6 [(predefined).cel = { + id: "sfixed64.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not be equal to any of the specified + // values. If the field value is one of the specified values, an error + // message is generated. + // + // ```proto + // message MySFixed64 { + // // value must not be in list [1, 2, 3] + // sfixed64 value = 1 [(buf.validate.field).sfixed64 = { not_in: [1, 2, 3] }]; + // } + // ``` + repeated sfixed64 not_in = 7 [(predefined).cel = { + id: "sfixed64.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MySFixed64 { + // sfixed64 value = 1 [ + // (buf.validate.field).sfixed64.example = 1, + // (buf.validate.field).sfixed64.example = 2 + // ]; + // } + // ``` + repeated sfixed64 example = 8 [(predefined).cel = { + id: "sfixed64.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// BoolRules describes the rules applied to `bool` values. These rules +// may also be applied to the `google.protobuf.BoolValue` Well-Known-Type. +message BoolRules { + // `const` requires the field value to exactly match the specified boolean value. + // If the field value doesn't match, an error message is generated. + // + // ```proto + // message MyBool { + // // value must equal true + // bool value = 1 [(buf.validate.field).bool.const = true]; + // } + // ``` + optional bool const = 1 [(predefined).cel = { + id: "bool.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyBool { + // bool value = 1 [ + // (buf.validate.field).bool.example = true, + // (buf.validate.field).bool.example = false + // ]; + // } + // ``` + repeated bool example = 2 [(predefined).cel = { + id: "bool.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// StringRules describes the rules applied to `string` values These +// rules may also be applied to the `google.protobuf.StringValue` Well-Known-Type. +message StringRules { + // `const` requires the field value to exactly match the specified value. If + // the field value doesn't match, an error message is generated. + // + // ```proto + // message MyString { + // // value must equal `hello` + // string value = 1 [(buf.validate.field).string.const = "hello"]; + // } + // ``` + optional string const = 1 [(predefined).cel = { + id: "string.const" + expression: "this != getField(rules, 'const') ? 'must equal `%s`'.format([getField(rules, 'const')]) : ''" + }]; + + // `len` dictates that the field value must have the specified + // number of characters (Unicode code points), which may differ from the number + // of bytes in the string. If the field value does not meet the specified + // length, an error message will be generated. + // + // ```proto + // message MyString { + // // value length must be 5 characters + // string value = 1 [(buf.validate.field).string.len = 5]; + // } + // ``` + optional uint64 len = 19 [(predefined).cel = { + id: "string.len" + expression: "uint(this.size()) != rules.len ? 'must be %s characters'.format([rules.len]) : ''" + }]; + + // `min_len` specifies that the field value must have at least the specified + // number of characters (Unicode code points), which may differ from the number + // of bytes in the string. If the field value contains fewer characters, an error + // message will be generated. + // + // ```proto + // message MyString { + // // value length must be at least 3 characters + // string value = 1 [(buf.validate.field).string.min_len = 3]; + // } + // ``` + optional uint64 min_len = 2 [(predefined).cel = { + id: "string.min_len" + expression: "uint(this.size()) < rules.min_len ? 'must be at least %s characters'.format([rules.min_len]) : ''" + }]; + + // `max_len` specifies that the field value must have no more than the specified + // number of characters (Unicode code points), which may differ from the + // number of bytes in the string. If the field value contains more characters, + // an error message will be generated. + // + // ```proto + // message MyString { + // // value length must be at most 10 characters + // string value = 1 [(buf.validate.field).string.max_len = 10]; + // } + // ``` + optional uint64 max_len = 3 [(predefined).cel = { + id: "string.max_len" + expression: "uint(this.size()) > rules.max_len ? 'must be at most %s characters'.format([rules.max_len]) : ''" + }]; + + // `len_bytes` dictates that the field value must have the specified number of + // bytes. If the field value does not match the specified length in bytes, + // an error message will be generated. + // + // ```proto + // message MyString { + // // value length must be 6 bytes + // string value = 1 [(buf.validate.field).string.len_bytes = 6]; + // } + // ``` + optional uint64 len_bytes = 20 [(predefined).cel = { + id: "string.len_bytes" + expression: "uint(bytes(this).size()) != rules.len_bytes ? 'must be %s bytes'.format([rules.len_bytes]) : ''" + }]; + + // `min_bytes` specifies that the field value must have at least the specified + // number of bytes. If the field value contains fewer bytes, an error message + // will be generated. + // + // ```proto + // message MyString { + // // value length must be at least 4 bytes + // string value = 1 [(buf.validate.field).string.min_bytes = 4]; + // } + // + // ``` + optional uint64 min_bytes = 4 [(predefined).cel = { + id: "string.min_bytes" + expression: "uint(bytes(this).size()) < rules.min_bytes ? 'must be at least %s bytes'.format([rules.min_bytes]) : ''" + }]; + + // `max_bytes` specifies that the field value must have no more than the + // specified number of bytes. If the field value contains more bytes, an + // error message will be generated. + // + // ```proto + // message MyString { + // // value length must be at most 8 bytes + // string value = 1 [(buf.validate.field).string.max_bytes = 8]; + // } + // ``` + optional uint64 max_bytes = 5 [(predefined).cel = { + id: "string.max_bytes" + expression: "uint(bytes(this).size()) > rules.max_bytes ? 'must be at most %s bytes'.format([rules.max_bytes]) : ''" + }]; + + // `pattern` specifies that the field value must match the specified + // regular expression (RE2 syntax), with the expression provided without any + // delimiters. If the field value doesn't match the regular expression, an + // error message will be generated. + // + // ```proto + // message MyString { + // // value does not match regex pattern `^[a-zA-Z]//$` + // string value = 1 [(buf.validate.field).string.pattern = "^[a-zA-Z]//$"]; + // } + // ``` + optional string pattern = 6 [(predefined).cel = { + id: "string.pattern" + expression: "!this.matches(rules.pattern) ? 'does not match regex pattern `%s`'.format([rules.pattern]) : ''" + }]; + + // `prefix` specifies that the field value must have the + // specified substring at the beginning of the string. If the field value + // doesn't start with the specified prefix, an error message will be + // generated. + // + // ```proto + // message MyString { + // // value does not have prefix `pre` + // string value = 1 [(buf.validate.field).string.prefix = "pre"]; + // } + // ``` + optional string prefix = 7 [(predefined).cel = { + id: "string.prefix" + expression: "!this.startsWith(rules.prefix) ? 'does not have prefix `%s`'.format([rules.prefix]) : ''" + }]; + + // `suffix` specifies that the field value must have the + // specified substring at the end of the string. If the field value doesn't + // end with the specified suffix, an error message will be generated. + // + // ```proto + // message MyString { + // // value does not have suffix `post` + // string value = 1 [(buf.validate.field).string.suffix = "post"]; + // } + // ``` + optional string suffix = 8 [(predefined).cel = { + id: "string.suffix" + expression: "!this.endsWith(rules.suffix) ? 'does not have suffix `%s`'.format([rules.suffix]) : ''" + }]; + + // `contains` specifies that the field value must have the + // specified substring anywhere in the string. If the field value doesn't + // contain the specified substring, an error message will be generated. + // + // ```proto + // message MyString { + // // value does not contain substring `inside`. + // string value = 1 [(buf.validate.field).string.contains = "inside"]; + // } + // ``` + optional string contains = 9 [(predefined).cel = { + id: "string.contains" + expression: "!this.contains(rules.contains) ? 'does not contain substring `%s`'.format([rules.contains]) : ''" + }]; + + // `not_contains` specifies that the field value must not have the + // specified substring anywhere in the string. If the field value contains + // the specified substring, an error message will be generated. + // + // ```proto + // message MyString { + // // value contains substring `inside`. + // string value = 1 [(buf.validate.field).string.not_contains = "inside"]; + // } + // ``` + optional string not_contains = 23 [(predefined).cel = { + id: "string.not_contains" + expression: "this.contains(rules.not_contains) ? 'contains substring `%s`'.format([rules.not_contains]) : ''" + }]; + + // `in` specifies that the field value must be equal to one of the specified + // values. If the field value isn't one of the specified values, an error + // message will be generated. + // + // ```proto + // message MyString { + // // must be in list ["apple", "banana"] + // string value = 1 [(buf.validate.field).string = { in: ["apple", "banana"] }]; + // } + // ``` + repeated string in = 10 [(predefined).cel = { + id: "string.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` specifies that the field value cannot be equal to any + // of the specified values. If the field value is one of the specified values, + // an error message will be generated. + // ```proto + // message MyString { + // // value must not be in list ["orange", "grape"] + // string value = 1 [(buf.validate.field).string = { not_in: ["orange", "grape"] }]; + // } + // ``` + repeated string not_in = 11 [(predefined).cel = { + id: "string.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `WellKnown` rules provide advanced rules against common string + // patterns. + oneof well_known { + // `email` specifies that the field value must be a valid email address, for + // example "foo@example.com". + // + // Conforms to the definition for a valid email address from the [HTML standard](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address). + // Note that this standard willfully deviates from [RFC 5322](https://datatracker.ietf.org/doc/html/rfc5322), + // which allows many unexpected forms of email addresses and will easily match + // a typographical error. + // + // If the field value isn't a valid email address, an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid email address + // string value = 1 [(buf.validate.field).string.email = true]; + // } + // ``` + bool email = 12 [ + (predefined).cel = { + id: "string.email" + message: "must be a valid email address" + expression: "!rules.email || this == '' || this.isEmail()" + }, + (predefined).cel = { + id: "string.email_empty" + message: "value is empty, which is not a valid email address" + expression: "!rules.email || this != ''" + } + ]; + + // `hostname` specifies that the field value must be a valid hostname, for + // example "foo.example.com". + // + // A valid hostname follows the rules below: + // - The name consists of one or more labels, separated by a dot ("."). + // - Each label can be 1 to 63 alphanumeric characters. + // - A label can contain hyphens ("-"), but must not start or end with a hyphen. + // - The right-most label must not be digits only. + // - The name can have a trailing dot—for example, "foo.example.com.". + // - The name can be 253 characters at most, excluding the optional trailing dot. + // + // If the field value isn't a valid hostname, an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid hostname + // string value = 1 [(buf.validate.field).string.hostname = true]; + // } + // ``` + bool hostname = 13 [ + (predefined).cel = { + id: "string.hostname" + message: "must be a valid hostname" + expression: "!rules.hostname || this == '' || this.isHostname()" + }, + (predefined).cel = { + id: "string.hostname_empty" + message: "value is empty, which is not a valid hostname" + expression: "!rules.hostname || this != ''" + } + ]; + + // `ip` specifies that the field value must be a valid IP (v4 or v6) address. + // + // IPv4 addresses are expected in the dotted decimal format—for example, "192.168.5.21". + // IPv6 addresses are expected in their text representation—for example, "::1", + // or "2001:0DB8:ABCD:0012::0". + // + // Both formats are well-defined in the internet standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). + // Zone identifiers for IPv6 addresses (for example, "fe80::a%en1") are supported. + // + // If the field value isn't a valid IP address, an error message will be + // generated. + // + // ```proto + // message MyString { + // // must be a valid IP address + // string value = 1 [(buf.validate.field).string.ip = true]; + // } + // ``` + bool ip = 14 [ + (predefined).cel = { + id: "string.ip" + message: "must be a valid IP address" + expression: "!rules.ip || this == '' || this.isIp()" + }, + (predefined).cel = { + id: "string.ip_empty" + message: "value is empty, which is not a valid IP address" + expression: "!rules.ip || this != ''" + } + ]; + + // `ipv4` specifies that the field value must be a valid IPv4 address—for + // example "192.168.5.21". If the field value isn't a valid IPv4 address, an + // error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid IPv4 address + // string value = 1 [(buf.validate.field).string.ipv4 = true]; + // } + // ``` + bool ipv4 = 15 [ + (predefined).cel = { + id: "string.ipv4" + message: "must be a valid IPv4 address" + expression: "!rules.ipv4 || this == '' || this.isIp(4)" + }, + (predefined).cel = { + id: "string.ipv4_empty" + message: "value is empty, which is not a valid IPv4 address" + expression: "!rules.ipv4 || this != ''" + } + ]; + + // `ipv6` specifies that the field value must be a valid IPv6 address—for + // example "::1", or "d7a:115c:a1e0:ab12:4843:cd96:626b:430b". If the field + // value is not a valid IPv6 address, an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid IPv6 address + // string value = 1 [(buf.validate.field).string.ipv6 = true]; + // } + // ``` + bool ipv6 = 16 [ + (predefined).cel = { + id: "string.ipv6" + message: "must be a valid IPv6 address" + expression: "!rules.ipv6 || this == '' || this.isIp(6)" + }, + (predefined).cel = { + id: "string.ipv6_empty" + message: "value is empty, which is not a valid IPv6 address" + expression: "!rules.ipv6 || this != ''" + } + ]; + + // `uri` specifies that the field value must be a valid URI, for example + // "https://example.com/foo/bar?baz=quux#frag". + // + // URI is defined in the internet standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). + // Zone Identifiers in IPv6 address literals are supported ([RFC 6874](https://datatracker.ietf.org/doc/html/rfc6874)). + // + // If the field value isn't a valid URI, an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid URI + // string value = 1 [(buf.validate.field).string.uri = true]; + // } + // ``` + bool uri = 17 [ + (predefined).cel = { + id: "string.uri" + message: "must be a valid URI" + expression: "!rules.uri || this == '' || this.isUri()" + }, + (predefined).cel = { + id: "string.uri_empty" + message: "value is empty, which is not a valid URI" + expression: "!rules.uri || this != ''" + } + ]; + + // `uri_ref` specifies that the field value must be a valid URI Reference—either + // a URI such as "https://example.com/foo/bar?baz=quux#frag", or a Relative + // Reference such as "./foo/bar?query". + // + // URI, URI Reference, and Relative Reference are defined in the internet + // standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). Zone + // Identifiers in IPv6 address literals are supported ([RFC 6874](https://datatracker.ietf.org/doc/html/rfc6874)). + // + // If the field value isn't a valid URI Reference, an error message will be + // generated. + // + // ```proto + // message MyString { + // // must be a valid URI Reference + // string value = 1 [(buf.validate.field).string.uri_ref = true]; + // } + // ``` + bool uri_ref = 18 [(predefined).cel = { + id: "string.uri_ref" + message: "must be a valid URI Reference" + expression: "!rules.uri_ref || this.isUriRef()" + }]; + + // `address` specifies that the field value must be either a valid hostname + // (for example, "example.com"), or a valid IP (v4 or v6) address (for example, + // "192.168.0.1", or "::1"). If the field value isn't a valid hostname or IP, + // an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid hostname, or ip address + // string value = 1 [(buf.validate.field).string.address = true]; + // } + // ``` + bool address = 21 [ + (predefined).cel = { + id: "string.address" + message: "must be a valid hostname, or ip address" + expression: "!rules.address || this == '' || this.isHostname() || this.isIp()" + }, + (predefined).cel = { + id: "string.address_empty" + message: "value is empty, which is not a valid hostname, or ip address" + expression: "!rules.address || this != ''" + } + ]; + + // `uuid` specifies that the field value must be a valid UUID as defined by + // [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2). If the + // field value isn't a valid UUID, an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid UUID + // string value = 1 [(buf.validate.field).string.uuid = true]; + // } + // ``` + bool uuid = 22 [ + (predefined).cel = { + id: "string.uuid" + message: "must be a valid UUID" + expression: "!rules.uuid || this == '' || this.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }, + (predefined).cel = { + id: "string.uuid_empty" + message: "value is empty, which is not a valid UUID" + expression: "!rules.uuid || this != ''" + } + ]; + + // `tuuid` (trimmed UUID) specifies that the field value must be a valid UUID as + // defined by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2) with all dashes + // omitted. If the field value isn't a valid UUID without dashes, an error message + // will be generated. + // + // ```proto + // message MyString { + // // must be a valid trimmed UUID + // string value = 1 [(buf.validate.field).string.tuuid = true]; + // } + // ``` + bool tuuid = 33 [ + (predefined).cel = { + id: "string.tuuid" + message: "must be a valid trimmed UUID" + expression: "!rules.tuuid || this == '' || this.matches('^[0-9a-fA-F]{32}$')" + }, + (predefined).cel = { + id: "string.tuuid_empty" + message: "value is empty, which is not a valid trimmed UUID" + expression: "!rules.tuuid || this != ''" + } + ]; + + // `ip_with_prefixlen` specifies that the field value must be a valid IP + // (v4 or v6) address with prefix length—for example, "192.168.5.21/16" or + // "2001:0DB8:ABCD:0012::F1/64". If the field value isn't a valid IP with + // prefix length, an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid IP with prefix length + // string value = 1 [(buf.validate.field).string.ip_with_prefixlen = true]; + // } + // ``` + bool ip_with_prefixlen = 26 [ + (predefined).cel = { + id: "string.ip_with_prefixlen" + message: "must be a valid IP prefix" + expression: "!rules.ip_with_prefixlen || this == '' || this.isIpPrefix()" + }, + (predefined).cel = { + id: "string.ip_with_prefixlen_empty" + message: "value is empty, which is not a valid IP prefix" + expression: "!rules.ip_with_prefixlen || this != ''" + } + ]; + + // `ipv4_with_prefixlen` specifies that the field value must be a valid + // IPv4 address with prefix length—for example, "192.168.5.21/16". If the + // field value isn't a valid IPv4 address with prefix length, an error + // message will be generated. + // + // ```proto + // message MyString { + // // must be a valid IPv4 address with prefix length + // string value = 1 [(buf.validate.field).string.ipv4_with_prefixlen = true]; + // } + // ``` + bool ipv4_with_prefixlen = 27 [ + (predefined).cel = { + id: "string.ipv4_with_prefixlen" + message: "must be a valid IPv4 address with prefix length" + expression: "!rules.ipv4_with_prefixlen || this == '' || this.isIpPrefix(4)" + }, + (predefined).cel = { + id: "string.ipv4_with_prefixlen_empty" + message: "value is empty, which is not a valid IPv4 address with prefix length" + expression: "!rules.ipv4_with_prefixlen || this != ''" + } + ]; + + // `ipv6_with_prefixlen` specifies that the field value must be a valid + // IPv6 address with prefix length—for example, "2001:0DB8:ABCD:0012::F1/64". + // If the field value is not a valid IPv6 address with prefix length, + // an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid IPv6 address prefix length + // string value = 1 [(buf.validate.field).string.ipv6_with_prefixlen = true]; + // } + // ``` + bool ipv6_with_prefixlen = 28 [ + (predefined).cel = { + id: "string.ipv6_with_prefixlen" + message: "must be a valid IPv6 address with prefix length" + expression: "!rules.ipv6_with_prefixlen || this == '' || this.isIpPrefix(6)" + }, + (predefined).cel = { + id: "string.ipv6_with_prefixlen_empty" + message: "value is empty, which is not a valid IPv6 address with prefix length" + expression: "!rules.ipv6_with_prefixlen || this != ''" + } + ]; + + // `ip_prefix` specifies that the field value must be a valid IP (v4 or v6) + // prefix—for example, "192.168.0.0/16" or "2001:0DB8:ABCD:0012::0/64". + // + // The prefix must have all zeros for the unmasked bits. For example, + // "2001:0DB8:ABCD:0012::0/64" designates the left-most 64 bits for the + // prefix, and the remaining 64 bits must be zero. + // + // If the field value isn't a valid IP prefix, an error message will be + // generated. + // + // ```proto + // message MyString { + // // must be a valid IP prefix + // string value = 1 [(buf.validate.field).string.ip_prefix = true]; + // } + // ``` + bool ip_prefix = 29 [ + (predefined).cel = { + id: "string.ip_prefix" + message: "must be a valid IP prefix" + expression: "!rules.ip_prefix || this == '' || this.isIpPrefix(true)" + }, + (predefined).cel = { + id: "string.ip_prefix_empty" + message: "value is empty, which is not a valid IP prefix" + expression: "!rules.ip_prefix || this != ''" + } + ]; + + // `ipv4_prefix` specifies that the field value must be a valid IPv4 + // prefix, for example "192.168.0.0/16". + // + // The prefix must have all zeros for the unmasked bits. For example, + // "192.168.0.0/16" designates the left-most 16 bits for the prefix, + // and the remaining 16 bits must be zero. + // + // If the field value isn't a valid IPv4 prefix, an error message + // will be generated. + // + // ```proto + // message MyString { + // // must be a valid IPv4 prefix + // string value = 1 [(buf.validate.field).string.ipv4_prefix = true]; + // } + // ``` + bool ipv4_prefix = 30 [ + (predefined).cel = { + id: "string.ipv4_prefix" + message: "must be a valid IPv4 prefix" + expression: "!rules.ipv4_prefix || this == '' || this.isIpPrefix(4, true)" + }, + (predefined).cel = { + id: "string.ipv4_prefix_empty" + message: "value is empty, which is not a valid IPv4 prefix" + expression: "!rules.ipv4_prefix || this != ''" + } + ]; + + // `ipv6_prefix` specifies that the field value must be a valid IPv6 prefix—for + // example, "2001:0DB8:ABCD:0012::0/64". + // + // The prefix must have all zeros for the unmasked bits. For example, + // "2001:0DB8:ABCD:0012::0/64" designates the left-most 64 bits for the + // prefix, and the remaining 64 bits must be zero. + // + // If the field value is not a valid IPv6 prefix, an error message will be + // generated. + // + // ```proto + // message MyString { + // // must be a valid IPv6 prefix + // string value = 1 [(buf.validate.field).string.ipv6_prefix = true]; + // } + // ``` + bool ipv6_prefix = 31 [ + (predefined).cel = { + id: "string.ipv6_prefix" + message: "must be a valid IPv6 prefix" + expression: "!rules.ipv6_prefix || this == '' || this.isIpPrefix(6, true)" + }, + (predefined).cel = { + id: "string.ipv6_prefix_empty" + message: "value is empty, which is not a valid IPv6 prefix" + expression: "!rules.ipv6_prefix || this != ''" + } + ]; + + // `host_and_port` specifies that the field value must be a valid host/port + // pair—for example, "example.com:8080". + // + // The host can be one of: + // - An IPv4 address in dotted decimal format—for example, "192.168.5.21". + // - An IPv6 address enclosed in square brackets—for example, "[2001:0DB8:ABCD:0012::F1]". + // - A hostname—for example, "example.com". + // + // The port is separated by a colon. It must be non-empty, with a decimal number + // in the range of 0-65535, inclusive. + bool host_and_port = 32 [ + (predefined).cel = { + id: "string.host_and_port" + message: "must be a valid host (hostname or IP address) and port pair" + expression: "!rules.host_and_port || this == '' || this.isHostAndPort(true)" + }, + (predefined).cel = { + id: "string.host_and_port_empty" + message: "value is empty, which is not a valid host and port pair" + expression: "!rules.host_and_port || this != ''" + } + ]; + + // `ulid` specifies that the field value must be a valid ULID (Universally Unique + // Lexicographically Sortable Identifier) as defined by the [ULID specification](https://github.com/ulid/spec). + // If the field value isn't a valid ULID, an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid ULID + // string value = 1 [(buf.validate.field).string.ulid = true]; + // } + // ``` + bool ulid = 35 [ + (predefined).cel = { + id: "string.ulid" + message: "must be a valid ULID" + expression: "!rules.ulid || this == '' || this.matches('^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$')" + }, + (predefined).cel = { + id: "string.ulid_empty" + message: "value is empty, which is not a valid ULID" + expression: "!rules.ulid || this != ''" + } + ]; + + // `protobuf_fqn` specifies that the field value must be a valid fully-qualified + // Protobuf name as defined by the [Protobuf Language Specification](https://protobuf.com/docs/language-spec). + // + // A fully-qualified Protobuf name is a dot-separated list of Protobuf identifiers, + // where each identifier starts with a letter or underscore and is followed by zero or + // more letters, underscores, or digits. + // + // Examples: "buf.validate", "google.protobuf.Timestamp", "my_package.MyMessage". + // + // Note: historically, fully-qualified Protobuf names were represented with a leading + // dot (for example, ".buf.validate.StringRules"). Modern Protobuf does not use the + // leading dot, and most fully-qualified names are represented without it. Use + // `protobuf_dot_fqn` if a leading dot is required. + // + // If the field value isn't a valid fully-qualified Protobuf name, an error message + // will be generated. + // + // ```proto + // message MyString { + // // value must be a valid fully-qualified Protobuf name + // string value = 1 [(buf.validate.field).string.protobuf_fqn = true]; + // } + // ``` + bool protobuf_fqn = 37 [ + (predefined).cel = { + id: "string.protobuf_fqn" + message: "must be a valid fully-qualified Protobuf name" + expression: "!rules.protobuf_fqn || this == '' || this.matches('^[A-Za-z_][A-Za-z_0-9]*(\\\\.[A-Za-z_][A-Za-z_0-9]*)*$')" + }, + (predefined).cel = { + id: "string.protobuf_fqn_empty" + message: "value is empty, which is not a valid fully-qualified Protobuf name" + expression: "!rules.protobuf_fqn || this != ''" + } + ]; + + // `protobuf_dot_fqn` specifies that the field value must be a valid fully-qualified + // Protobuf name with a leading dot, as defined by the + // [Protobuf Language Specification](https://protobuf.com/docs/language-spec). + // + // A fully-qualified Protobuf name with a leading dot is a dot followed by a + // dot-separated list of Protobuf identifiers, where each identifier starts with a + // letter or underscore and is followed by zero or more letters, underscores, or + // digits. + // + // Examples: ".buf.validate", ".google.protobuf.Timestamp", ".my_package.MyMessage". + // + // Note: this is the historical representation of fully-qualified Protobuf names, + // where a leading dot denotes an absolute reference. Modern Protobuf does not use + // the leading dot, and most fully-qualified names are represented without it. Most + // users will want to use `protobuf_fqn` instead. + // + // If the field value isn't a valid fully-qualified Protobuf name with a leading dot, + // an error message will be generated. + // + // ```proto + // message MyString { + // // value must be a valid fully-qualified Protobuf name with a leading dot + // string value = 1 [(buf.validate.field).string.protobuf_dot_fqn = true]; + // } + // ``` + bool protobuf_dot_fqn = 38 [ + (predefined).cel = { + id: "string.protobuf_dot_fqn" + message: "must be a valid fully-qualified Protobuf name with a leading dot" + expression: "!rules.protobuf_dot_fqn || this == '' || this.matches('^\\\\.[A-Za-z_][A-Za-z_0-9]*(\\\\.[A-Za-z_][A-Za-z_0-9]*)*$')" + }, + (predefined).cel = { + id: "string.protobuf_dot_fqn_empty" + message: "value is empty, which is not a valid fully-qualified Protobuf name with a leading dot" + expression: "!rules.protobuf_dot_fqn || this != ''" + } + ]; + + // `well_known_regex` specifies a common well-known pattern + // defined as a regex. If the field value doesn't match the well-known + // regex, an error message will be generated. + // + // ```proto + // message MyString { + // // must be a valid HTTP header value + // string value = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE]; + // } + // ``` + // + // #### KnownRegex + // + // `well_known_regex` contains some well-known patterns. + // + // | Name | Number | Description | + // |-------------------------------|--------|-------------------------------------------| + // | KNOWN_REGEX_UNSPECIFIED | 0 | | + // | KNOWN_REGEX_HTTP_HEADER_NAME | 1 | HTTP header name as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2) | + // | KNOWN_REGEX_HTTP_HEADER_VALUE | 2 | HTTP header value as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4) | + KnownRegex well_known_regex = 24 [ + (predefined).cel = { + id: "string.well_known_regex.header_name" + message: "must be a valid HTTP header name" + expression: + "rules.well_known_regex != 1 || this == '' || this.matches(!has(rules.strict) || rules.strict ?" + "'^:?[0-9a-zA-Z!#$%&\\'*+-.^_|~\\x60]+$' :" + "'^[^\\u0000\\u000A\\u000D]+$')" + }, + (predefined).cel = { + id: "string.well_known_regex.header_name_empty" + message: "value is empty, which is not a valid HTTP header name" + expression: "rules.well_known_regex != 1 || this != ''" + }, + (predefined).cel = { + id: "string.well_known_regex.header_value" + message: "must be a valid HTTP header value" + expression: + "rules.well_known_regex != 2 || this.matches(!has(rules.strict) || rules.strict ?" + "'^[^\\u0000-\\u0008\\u000A-\\u001F\\u007F]*$' :" + "'^[^\\u0000\\u000A\\u000D]*$')" + } + ]; + } + + // This applies to regexes `HTTP_HEADER_NAME` and `HTTP_HEADER_VALUE` to + // enable strict header validation. By default, this is true, and HTTP header + // validations are [RFC-compliant](https://datatracker.ietf.org/doc/html/rfc7230#section-3). Setting to false will enable looser + // validations that only disallow `\r\n\0` characters, which can be used to + // bypass header matching rules. + // + // ```proto + // message MyString { + // // The field `value` must have be a valid HTTP headers, but not enforced with strict rules. + // string value = 1 [(buf.validate.field).string.strict = false]; + // } + // ``` + optional bool strict = 25; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyString { + // string value = 1 [ + // (buf.validate.field).string.example = "hello", + // (buf.validate.field).string.example = "world" + // ]; + // } + // ``` + repeated string example = 34 [(predefined).cel = { + id: "string.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// KnownRegex contains some well-known patterns. +enum KnownRegex { + KNOWN_REGEX_UNSPECIFIED = 0; + + // HTTP header name as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2). + KNOWN_REGEX_HTTP_HEADER_NAME = 1; + + // HTTP header value as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4). + KNOWN_REGEX_HTTP_HEADER_VALUE = 2; +} + +// BytesRules describe the rules applied to `bytes` values. These rules +// may also be applied to the `google.protobuf.BytesValue` Well-Known-Type. +message BytesRules { + // `const` requires the field value to exactly match the specified bytes + // value. If the field value doesn't match, an error message is generated. + // + // ```proto + // message MyBytes { + // // must be "\x01\x02\x03\x04" + // bytes value = 1 [(buf.validate.field).bytes.const = "\x01\x02\x03\x04"]; + // } + // ``` + optional bytes const = 1 [(predefined).cel = { + id: "bytes.const" + expression: "this != getField(rules, 'const') ? 'must be %x'.format([getField(rules, 'const')]) : ''" + }]; + + // `len` requires the field value to have the specified length in bytes. + // If the field value doesn't match, an error message is generated. + // + // ```proto + // message MyBytes { + // // value length must be 4 bytes. + // optional bytes value = 1 [(buf.validate.field).bytes.len = 4]; + // } + // ``` + optional uint64 len = 13 [(predefined).cel = { + id: "bytes.len" + expression: "uint(this.size()) != rules.len ? 'must be %s bytes'.format([rules.len]) : ''" + }]; + + // `min_len` requires the field value to have at least the specified minimum + // length in bytes. + // If the field value doesn't meet the requirement, an error message is generated. + // + // ```proto + // message MyBytes { + // // value length must be at least 2 bytes. + // optional bytes value = 1 [(buf.validate.field).bytes.min_len = 2]; + // } + // ``` + optional uint64 min_len = 2 [(predefined).cel = { + id: "bytes.min_len" + expression: "uint(this.size()) < rules.min_len ? 'must be at least %s bytes'.format([rules.min_len]) : ''" + }]; + + // `max_len` requires the field value to have at most the specified maximum + // length in bytes. + // If the field value exceeds the requirement, an error message is generated. + // + // ```proto + // message MyBytes { + // // must be at most 6 bytes. + // optional bytes value = 1 [(buf.validate.field).bytes.max_len = 6]; + // } + // ``` + optional uint64 max_len = 3 [(predefined).cel = { + id: "bytes.max_len" + expression: "uint(this.size()) > rules.max_len ? 'must be at most %s bytes'.format([rules.max_len]) : ''" + }]; + + // `pattern` requires the field value to match the specified regular + // expression ([RE2 syntax](https://github.com/google/re2/wiki/Syntax)). + // The value of the field must be valid UTF-8 or validation will fail with a + // runtime error. + // If the field value doesn't match the pattern, an error message is generated. + // + // ```proto + // message MyBytes { + // // value must match regex pattern "^[a-zA-Z0-9]+$". + // optional bytes value = 1 [(buf.validate.field).bytes.pattern = "^[a-zA-Z0-9]+$"]; + // } + // ``` + optional string pattern = 4 [(predefined).cel = { + id: "bytes.pattern" + expression: "!string(this).matches(rules.pattern) ? 'must match regex pattern `%s`'.format([rules.pattern]) : ''" + }]; + + // `prefix` requires the field value to have the specified bytes at the + // beginning of the string. + // If the field value doesn't meet the requirement, an error message is generated. + // + // ```proto + // message MyBytes { + // // value does not have prefix \x01\x02 + // optional bytes value = 1 [(buf.validate.field).bytes.prefix = "\x01\x02"]; + // } + // ``` + optional bytes prefix = 5 [(predefined).cel = { + id: "bytes.prefix" + expression: "!this.startsWith(rules.prefix) ? 'does not have prefix %x'.format([rules.prefix]) : ''" + }]; + + // `suffix` requires the field value to have the specified bytes at the end + // of the string. + // If the field value doesn't meet the requirement, an error message is generated. + // + // ```proto + // message MyBytes { + // // value does not have suffix \x03\x04 + // optional bytes value = 1 [(buf.validate.field).bytes.suffix = "\x03\x04"]; + // } + // ``` + optional bytes suffix = 6 [(predefined).cel = { + id: "bytes.suffix" + expression: "!this.endsWith(rules.suffix) ? 'does not have suffix %x'.format([rules.suffix]) : ''" + }]; + + // `contains` requires the field value to have the specified bytes anywhere in + // the string. + // If the field value doesn't meet the requirement, an error message is generated. + // + // ```proto + // message MyBytes { + // // value does not contain \x02\x03 + // optional bytes value = 1 [(buf.validate.field).bytes.contains = "\x02\x03"]; + // } + // ``` + optional bytes contains = 7 [(predefined).cel = { + id: "bytes.contains" + expression: "!this.contains(rules.contains) ? 'does not contain %x'.format([rules.contains]) : ''" + }]; + + // `in` requires the field value to be equal to one of the specified + // values. If the field value doesn't match any of the specified values, an + // error message is generated. + // + // ```proto + // message MyBytes { + // // value must in ["\x01\x02", "\x02\x03", "\x03\x04"] + // optional bytes value = 1 [(buf.validate.field).bytes = { in: ["\x01\x02", "\x02\x03", "\x03\x04"] }]; + // } + // ``` + repeated bytes in = 8 [(predefined).cel = { + id: "bytes.in" + expression: "getField(rules, 'in').size() > 0 && !(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to be not equal to any of the specified + // values. + // If the field value matches any of the specified values, an error message is + // generated. + // + // ```proto + // message MyBytes { + // // value must not in ["\x01\x02", "\x02\x03", "\x03\x04"] + // optional bytes value = 1 [(buf.validate.field).bytes = { not_in: ["\x01\x02", "\x02\x03", "\x03\x04"] }]; + // } + // ``` + repeated bytes not_in = 9 [(predefined).cel = { + id: "bytes.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // WellKnown rules provide advanced rules against common byte + // patterns + oneof well_known { + // `ip` ensures that the field `value` is a valid IP address (v4 or v6) in byte format. + // If the field value doesn't meet this rule, an error message is generated. + // + // ```proto + // message MyBytes { + // // must be a valid IP address + // optional bytes value = 1 [(buf.validate.field).bytes.ip = true]; + // } + // ``` + bool ip = 10 [ + (predefined).cel = { + id: "bytes.ip" + message: "must be a valid IP address" + expression: "!rules.ip || this.size() == 0 || this.size() == 4 || this.size() == 16" + }, + (predefined).cel = { + id: "bytes.ip_empty" + message: "value is empty, which is not a valid IP address" + expression: "!rules.ip || this.size() != 0" + } + ]; + + // `ipv4` ensures that the field `value` is a valid IPv4 address in byte format. + // If the field value doesn't meet this rule, an error message is generated. + // + // ```proto + // message MyBytes { + // // must be a valid IPv4 address + // optional bytes value = 1 [(buf.validate.field).bytes.ipv4 = true]; + // } + // ``` + bool ipv4 = 11 [ + (predefined).cel = { + id: "bytes.ipv4" + message: "must be a valid IPv4 address" + expression: "!rules.ipv4 || this.size() == 0 || this.size() == 4" + }, + (predefined).cel = { + id: "bytes.ipv4_empty" + message: "value is empty, which is not a valid IPv4 address" + expression: "!rules.ipv4 || this.size() != 0" + } + ]; + + // `ipv6` ensures that the field `value` is a valid IPv6 address in byte format. + // If the field value doesn't meet this rule, an error message is generated. + // ```proto + // message MyBytes { + // // must be a valid IPv6 address + // optional bytes value = 1 [(buf.validate.field).bytes.ipv6 = true]; + // } + // ``` + bool ipv6 = 12 [ + (predefined).cel = { + id: "bytes.ipv6" + message: "must be a valid IPv6 address" + expression: "!rules.ipv6 || this.size() == 0 || this.size() == 16" + }, + (predefined).cel = { + id: "bytes.ipv6_empty" + message: "value is empty, which is not a valid IPv6 address" + expression: "!rules.ipv6 || this.size() != 0" + } + ]; + + // `uuid` ensures that the field value encodes 128-bit UUID data as defined + // by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2). + // The field must contain exactly 16 bytes representing the UUID. If the + // field value isn't a valid UUID, an error message will be generated. + // + // ```proto + // message MyBytes { + // // must be a valid UUID + // optional bytes value = 1 [(buf.validate.field).bytes.uuid = true]; + // } + // ``` + bool uuid = 15 [ + (predefined).cel = { + id: "bytes.uuid" + message: "must be a valid UUID" + expression: "!rules.uuid || this.size() == 0 || this.size() == 16" + }, + (predefined).cel = { + id: "bytes.uuid_empty" + message: "value is empty, which is not a valid UUID" + expression: "!rules.uuid || this.size() != 0" + } + ]; + } + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyBytes { + // bytes value = 1 [ + // (buf.validate.field).bytes.example = "\x01\x02", + // (buf.validate.field).bytes.example = "\x02\x03" + // ]; + // } + // ``` + repeated bytes example = 14 [(predefined).cel = { + id: "bytes.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// EnumRules describe the rules applied to `enum` values. +message EnumRules { + // `const` requires the field value to exactly match the specified enum value. + // If the field value doesn't match, an error message is generated. + // + // ```proto + // enum MyEnum { + // MY_ENUM_UNSPECIFIED = 0; + // MY_ENUM_VALUE1 = 1; + // MY_ENUM_VALUE2 = 2; + // } + // + // message MyMessage { + // // The field `value` must be exactly MY_ENUM_VALUE1. + // MyEnum value = 1 [(buf.validate.field).enum.const = 1]; + // } + // ``` + optional int32 const = 1 [(predefined).cel = { + id: "enum.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + + // `defined_only` requires the field value to be one of the defined values for + // this enum, failing on any undefined value. + // + // ```proto + // enum MyEnum { + // MY_ENUM_UNSPECIFIED = 0; + // MY_ENUM_VALUE1 = 1; + // MY_ENUM_VALUE2 = 2; + // } + // + // message MyMessage { + // // The field `value` must be a defined value of MyEnum. + // MyEnum value = 1 [(buf.validate.field).enum.defined_only = true]; + // } + // ``` + optional bool defined_only = 2; + + // `in` requires the field value to be equal to one of the + // specified enum values. If the field value doesn't match any of the + // specified values, an error message is generated. + // + // ```proto + // enum MyEnum { + // MY_ENUM_UNSPECIFIED = 0; + // MY_ENUM_VALUE1 = 1; + // MY_ENUM_VALUE2 = 2; + // } + // + // message MyMessage { + // // The field `value` must be equal to one of the specified values. + // MyEnum value = 1 [(buf.validate.field).enum = { in: [1, 2]}]; + // } + // ``` + repeated int32 in = 3 [(predefined).cel = { + id: "enum.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to be not equal to any of the + // specified enum values. If the field value matches one of the specified + // values, an error message is generated. + // + // ```proto + // enum MyEnum { + // MY_ENUM_UNSPECIFIED = 0; + // MY_ENUM_VALUE1 = 1; + // MY_ENUM_VALUE2 = 2; + // } + // + // message MyMessage { + // // The field `value` must not be equal to any of the specified values. + // MyEnum value = 1 [(buf.validate.field).enum = { not_in: [1, 2]}]; + // } + // ``` + repeated int32 not_in = 4 [(predefined).cel = { + id: "enum.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // enum MyEnum { + // MY_ENUM_UNSPECIFIED = 0; + // MY_ENUM_VALUE1 = 1; + // MY_ENUM_VALUE2 = 2; + // } + // + // message MyMessage { + // MyEnum value = 1 [ + // (buf.validate.field).enum.example = 1, + // (buf.validate.field).enum.example = 2 + // ]; + // } + // ``` + repeated int32 example = 5 [(predefined).cel = { + id: "enum.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// RepeatedRules describe the rules applied to `repeated` values. +message RepeatedRules { + // `min_items` requires that this field must contain at least the specified + // minimum number of items. + // + // Note that `min_items = 1` is equivalent to setting a field as `required`. + // + // ```proto + // message MyRepeated { + // // value must contain at least 2 items + // repeated string value = 1 [(buf.validate.field).repeated.min_items = 2]; + // } + // ``` + optional uint64 min_items = 1 [(predefined).cel = { + id: "repeated.min_items" + expression: "uint(this.size()) < rules.min_items ? 'must contain at least %d item(s)'.format([rules.min_items]) : ''" + }]; + + // `max_items` denotes that this field must not exceed a + // certain number of items as the upper limit. If the field contains more + // items than specified, an error message will be generated, requiring the + // field to maintain no more than the specified number of items. + // + // ```proto + // message MyRepeated { + // // value must contain no more than 3 item(s) + // repeated string value = 1 [(buf.validate.field).repeated.max_items = 3]; + // } + // ``` + optional uint64 max_items = 2 [(predefined).cel = { + id: "repeated.max_items" + expression: "uint(this.size()) > rules.max_items ? 'must contain no more than %s item(s)'.format([rules.max_items]) : ''" + }]; + + // `unique` indicates that all elements in this field must + // be unique. This rule is strictly applicable to scalar and enum + // types, with message types not being supported. + // + // ```proto + // message MyRepeated { + // // repeated value must contain unique items + // repeated string value = 1 [(buf.validate.field).repeated.unique = true]; + // } + // ``` + optional bool unique = 3 [(predefined).cel = { + id: "repeated.unique" + message: "repeated value must contain unique items" + expression: "!rules.unique || this.unique()" + }]; + + // `items` details the rules to be applied to each item + // in the field. Even for repeated message fields, validation is executed + // against each item unless `ignore` is specified. + // + // ```proto + // message MyRepeated { + // // The items in the field `value` must follow the specified rules. + // repeated string value = 1 [(buf.validate.field).repeated.items = { + // string: { + // min_len: 3 + // max_len: 10 + // } + // }]; + // } + // ``` + // + // Note that the `required` rule does not apply. Repeated items + // cannot be unset. + optional FieldRules items = 4; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// MapRules describe the rules applied to `map` values. +message MapRules { + // Specifies the minimum number of key-value pairs allowed. If the field has + // fewer key-value pairs than specified, an error message is generated. + // + // ```proto + // message MyMap { + // // The field `value` must have at least 2 key-value pairs. + // map value = 1 [(buf.validate.field).map.min_pairs = 2]; + // } + // ``` + optional uint64 min_pairs = 1 [(predefined).cel = { + id: "map.min_pairs" + expression: "uint(this.size()) < rules.min_pairs ? 'map must be at least %d entries'.format([rules.min_pairs]) : ''" + }]; + + // Specifies the maximum number of key-value pairs allowed. If the field has + // more key-value pairs than specified, an error message is generated. + // + // ```proto + // message MyMap { + // // The field `value` must have at most 3 key-value pairs. + // map value = 1 [(buf.validate.field).map.max_pairs = 3]; + // } + // ``` + optional uint64 max_pairs = 2 [(predefined).cel = { + id: "map.max_pairs" + expression: "uint(this.size()) > rules.max_pairs ? 'map must be at most %d entries'.format([rules.max_pairs]) : ''" + }]; + + // Specifies the rules to be applied to each key in the field. + // + // ```proto + // message MyMap { + // // The keys in the field `value` must follow the specified rules. + // map value = 1 [(buf.validate.field).map.keys = { + // string: { + // min_len: 3 + // max_len: 10 + // } + // }]; + // } + // ``` + // + // Note that the `required` rule does not apply. Map keys cannot be unset. + optional FieldRules keys = 4; + + // Specifies the rules to be applied to the value of each key in the + // field. Message values will still have their validations evaluated unless + // `ignore` is specified. + // + // ```proto + // message MyMap { + // // The values in the field `value` must follow the specified rules. + // map value = 1 [(buf.validate.field).map.values = { + // string: { + // min_len: 5 + // max_len: 20 + // } + // }]; + // } + // ``` + // Note that the `required` rule does not apply. Map values cannot be unset. + optional FieldRules values = 5; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// AnyRules describe rules applied exclusively to the `google.protobuf.Any` well-known type. +message AnyRules { + // `in` requires the field's `type_url` to be equal to one of the + // specified values. If it doesn't match any of the specified values, an error + // message is generated. + // + // ```proto + // message MyAny { + // // The `value` field must have a `type_url` equal to one of the specified values. + // google.protobuf.Any value = 1 [(buf.validate.field).any = { + // in: ["type.googleapis.com/MyType1", "type.googleapis.com/MyType2"] + // }]; + // } + // ``` + repeated string in = 2; + + // `not_in` requires the field's type_url to be not equal to any of the specified values. If it matches any of the specified values, an error message is generated. + // + // ```proto + // message MyAny { + // // The `value` field must not have a `type_url` equal to any of the specified values. + // google.protobuf.Any value = 1 [(buf.validate.field).any = { + // not_in: ["type.googleapis.com/ForbiddenType1", "type.googleapis.com/ForbiddenType2"] + // }]; + // } + // ``` + repeated string not_in = 3; +} + +// DurationRules describe the rules applied exclusively to the `google.protobuf.Duration` well-known type. +message DurationRules { + // `const` dictates that the field must match the specified value of the `google.protobuf.Duration` type exactly. + // If the field's value deviates from the specified value, an error message + // will be generated. + // + // ```proto + // message MyDuration { + // // value must equal 5s + // google.protobuf.Duration value = 1 [(buf.validate.field).duration.const = { seconds: 5 }]; + // } + // ``` + optional google.protobuf.Duration const = 2 [(predefined).cel = { + id: "duration.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` stipulates that the field must be less than the specified value of the `google.protobuf.Duration` type, + // exclusive. If the field's value is greater than or equal to the specified + // value, an error message will be generated. + // + // ```proto + // message MyDuration { + // // must be less than 5s + // google.protobuf.Duration value = 1 [(buf.validate.field).duration.lt = { seconds: 5 }]; + // } + // ``` + google.protobuf.Duration lt = 3 [(predefined).cel = { + id: "duration.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` indicates that the field must be less than or equal to the specified + // value of the `google.protobuf.Duration` type, inclusive. If the field's value is greater than the specified value, + // an error message will be generated. + // + // ```proto + // message MyDuration { + // // must be less than or equal to 10s + // google.protobuf.Duration value = 1 [(buf.validate.field).duration.lte = { seconds: 10 }]; + // } + // ``` + google.protobuf.Duration lte = 4 [(predefined).cel = { + id: "duration.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + } + oneof greater_than { + // `gt` requires the duration field value to be greater than the specified + // value (exclusive). If the value of `gt` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyDuration { + // // duration must be greater than 5s [duration.gt] + // google.protobuf.Duration value = 1 [(buf.validate.field).duration.gt = { seconds: 5 }]; + // + // // duration must be greater than 5s and less than 10s [duration.gt_lt] + // google.protobuf.Duration another_value = 2 [(buf.validate.field).duration = { gt: { seconds: 5 }, lt: { seconds: 10 } }]; + // + // // duration must be greater than 10s or less than 5s [duration.gt_lt_exclusive] + // google.protobuf.Duration other_value = 3 [(buf.validate.field).duration = { gt: { seconds: 10 }, lt: { seconds: 5 } }]; + // } + // ``` + google.protobuf.Duration gt = 5 [ + (predefined).cel = { + id: "duration.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "duration.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "duration.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "duration.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "duration.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the duration field value to be greater than or equal to the + // specified value (exclusive). If the value of `gte` is larger than a + // specified `lt` or `lte`, the range is reversed, and the field value must + // be outside the specified range. If the field value doesn't meet the + // required conditions, an error message is generated. + // + // ```proto + // message MyDuration { + // // duration must be greater than or equal to 5s [duration.gte] + // google.protobuf.Duration value = 1 [(buf.validate.field).duration.gte = { seconds: 5 }]; + // + // // duration must be greater than or equal to 5s and less than 10s [duration.gte_lt] + // google.protobuf.Duration another_value = 2 [(buf.validate.field).duration = { gte: { seconds: 5 }, lt: { seconds: 10 } }]; + // + // // duration must be greater than or equal to 10s or less than 5s [duration.gte_lt_exclusive] + // google.protobuf.Duration other_value = 3 [(buf.validate.field).duration = { gte: { seconds: 10 }, lt: { seconds: 5 } }]; + // } + // ``` + google.protobuf.Duration gte = 6 [ + (predefined).cel = { + id: "duration.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "duration.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "duration.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "duration.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "duration.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + } + + // `in` asserts that the field must be equal to one of the specified values of the `google.protobuf.Duration` type. + // If the field's value doesn't correspond to any of the specified values, + // an error message will be generated. + // + // ```proto + // message MyDuration { + // // must be in list [1s, 2s, 3s] + // google.protobuf.Duration value = 1 [(buf.validate.field).duration = { + // in: [{ seconds: 1 }, { seconds: 2 }, { seconds: 3 }] + // }]; + // } + // ``` + repeated google.protobuf.Duration in = 7 [(predefined).cel = { + id: "duration.in" + expression: "!(this in getField(rules, 'in')) ? 'must be in list %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` denotes that the field must not be equal to + // any of the specified values of the `google.protobuf.Duration` type. + // If the field's value matches any of these values, an error message will be + // generated. + // + // ```proto + // message MyDuration { + // // value must not be in list [1s, 2s, 3s] + // google.protobuf.Duration value = 1 [(buf.validate.field).duration = { + // not_in: [{ seconds: 1 }, { seconds: 2 }, { seconds: 3 }] + // }]; + // } + // ``` + repeated google.protobuf.Duration not_in = 8 [(predefined).cel = { + id: "duration.not_in" + expression: "this in rules.not_in ? 'must not be in list %s'.format([rules.not_in]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyDuration { + // google.protobuf.Duration value = 1 [ + // (buf.validate.field).duration.example = { seconds: 1 }, + // (buf.validate.field).duration.example = { seconds: 2 } + // ]; + // } + // ``` + repeated google.protobuf.Duration example = 9 [(predefined).cel = { + id: "duration.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// FieldMaskRules describe rules applied exclusively to the `google.protobuf.FieldMask` well-known type. +message FieldMaskRules { + // `const` dictates that the field must match the specified value of the `google.protobuf.FieldMask` type exactly. + // If the field's value deviates from the specified value, an error message + // will be generated. + // + // ```proto + // message MyFieldMask { + // // value must equal ["a"] + // google.protobuf.FieldMask value = 1 [(buf.validate.field).field_mask.const = { + // paths: ["a"] + // }]; + // } + // ``` + optional google.protobuf.FieldMask const = 1 [(predefined).cel = { + id: "field_mask.const" + expression: "this.paths != getField(rules, 'const').paths ? 'must equal paths %s'.format([getField(rules, 'const').paths]) : ''" + }]; + + // `in` requires the field value to only contain paths matching specified + // values or their subpaths. + // If any of the field value's paths doesn't match the rule, + // an error message is generated. + // See: https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask + // + // ```proto + // message MyFieldMask { + // // The `value` FieldMask must only contain paths listed in `in`. + // google.protobuf.FieldMask value = 1 [(buf.validate.field).field_mask = { + // in: ["a", "b", "c.a"] + // }]; + // } + // ``` + repeated string in = 2 [(predefined).cel = { + id: "field_mask.in" + expression: "!this.paths.all(p, p in getField(rules, 'in') || getField(rules, 'in').exists(f, p.startsWith(f+'.'))) ? 'must only contain paths in %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not contain paths matching specified + // values or their subpaths. + // If any of the field value's paths matches the rule, + // an error message is generated. + // See: https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask + // + // ```proto + // message MyFieldMask { + // // The `value` FieldMask shall not contain paths listed in `not_in`. + // google.protobuf.FieldMask value = 1 [(buf.validate.field).field_mask = { + // not_in: ["forbidden", "immutable", "c.a"] + // }]; + // } + // ``` + repeated string not_in = 3 [(predefined).cel = { + id: "field_mask.not_in" + expression: "!this.paths.all(p, !(p in getField(rules, 'not_in') || getField(rules, 'not_in').exists(f, p.startsWith(f+'.')))) ? 'must not contain any paths in %s'.format([getField(rules, 'not_in')]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyFieldMask { + // google.protobuf.FieldMask value = 1 [ + // (buf.validate.field).field_mask.example = { paths: ["a", "b"] }, + // (buf.validate.field).field_mask.example = { paths: ["c.a", "d"] } + // ]; + // } + // ``` + repeated google.protobuf.FieldMask example = 4 [(predefined).cel = { + id: "field_mask.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// TimestampRules describe the rules applied exclusively to the `google.protobuf.Timestamp` well-known type. +message TimestampRules { + // `const` dictates that this field, of the `google.protobuf.Timestamp` type, must exactly match the specified value. If the field value doesn't correspond to the specified timestamp, an error message will be generated. + // + // ```proto + // message MyTimestamp { + // // value must equal 2023-05-03T10:00:00Z + // google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.const = {seconds: 1727998800}]; + // } + // ``` + optional google.protobuf.Timestamp const = 2 [(predefined).cel = { + id: "timestamp.const" + expression: "this != getField(rules, 'const') ? 'must equal %s'.format([getField(rules, 'const')]) : ''" + }]; + oneof less_than { + // `lt` requires the timestamp field value to be less than the specified value (field < value). If the field value doesn't meet the required conditions, an error message is generated. + // + // ```proto + // message MyTimestamp { + // // timestamp must be less than '2023-01-01T00:00:00Z' [timestamp.lt] + // google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.lt = { seconds: 1672444800 }]; + // } + // ``` + google.protobuf.Timestamp lt = 3 [(predefined).cel = { + id: "timestamp.lt" + expression: + "!has(rules.gte) && !has(rules.gt) && this >= rules.lt" + "? 'must be less than %s'.format([rules.lt]) : ''" + }]; + + // `lte` requires the timestamp field value to be less than or equal to the specified value (field <= value). If the field value doesn't meet the required conditions, an error message is generated. + // + // ```proto + // message MyTimestamp { + // // timestamp must be less than or equal to '2023-05-14T00:00:00Z' [timestamp.lte] + // google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.lte = { seconds: 1678867200 }]; + // } + // ``` + google.protobuf.Timestamp lte = 4 [(predefined).cel = { + id: "timestamp.lte" + expression: + "!has(rules.gte) && !has(rules.gt) && this > rules.lte" + "? 'must be less than or equal to %s'.format([rules.lte]) : ''" + }]; + + // `lt_now` specifies that this field, of the `google.protobuf.Timestamp` type, must be less than the current time. `lt_now` can only be used with the `within` rule. + // + // ```proto + // message MyTimestamp { + // // must be less than now + // google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.lt_now = true]; + // } + // ``` + bool lt_now = 7 [(predefined).cel = { + id: "timestamp.lt_now" + expression: "(rules.lt_now && this > now) ? 'must be less than now' : ''" + }]; + } + oneof greater_than { + // `gt` requires the timestamp field value to be greater than the specified + // value (exclusive). If the value of `gt` is larger than a specified `lt` + // or `lte`, the range is reversed, and the field value must be outside the + // specified range. If the field value doesn't meet the required conditions, + // an error message is generated. + // + // ```proto + // message MyTimestamp { + // // timestamp must be greater than '2023-01-01T00:00:00Z' [timestamp.gt] + // google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.gt = { seconds: 1672444800 }]; + // + // // timestamp must be greater than '2023-01-01T00:00:00Z' and less than '2023-01-02T00:00:00Z' [timestamp.gt_lt] + // google.protobuf.Timestamp another_value = 2 [(buf.validate.field).timestamp = { gt: { seconds: 1672444800 }, lt: { seconds: 1672531200 } }]; + // + // // timestamp must be greater than '2023-01-02T00:00:00Z' or less than '2023-01-01T00:00:00Z' [timestamp.gt_lt_exclusive] + // google.protobuf.Timestamp other_value = 3 [(buf.validate.field).timestamp = { gt: { seconds: 1672531200 }, lt: { seconds: 1672444800 } }]; + // } + // ``` + google.protobuf.Timestamp gt = 5 [ + (predefined).cel = { + id: "timestamp.gt" + expression: + "!has(rules.lt) && !has(rules.lte) && this <= rules.gt" + "? 'must be greater than %s'.format([rules.gt]) : ''" + }, + (predefined).cel = { + id: "timestamp.gt_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gt && (this >= rules.lt || this <= rules.gt)" + "? 'must be greater than %s and less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "timestamp.gt_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gt && (rules.lt <= this && this <= rules.gt)" + "? 'must be greater than %s or less than %s'.format([rules.gt, rules.lt]) : ''" + }, + (predefined).cel = { + id: "timestamp.gt_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gt && (this > rules.lte || this <= rules.gt)" + "? 'must be greater than %s and less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + }, + (predefined).cel = { + id: "timestamp.gt_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gt && (rules.lte < this && this <= rules.gt)" + "? 'must be greater than %s or less than or equal to %s'.format([rules.gt, rules.lte]) : ''" + } + ]; + + // `gte` requires the timestamp field value to be greater than or equal to the + // specified value (exclusive). If the value of `gte` is larger than a + // specified `lt` or `lte`, the range is reversed, and the field value + // must be outside the specified range. If the field value doesn't meet + // the required conditions, an error message is generated. + // + // ```proto + // message MyTimestamp { + // // timestamp must be greater than or equal to '2023-01-01T00:00:00Z' [timestamp.gte] + // google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.gte = { seconds: 1672444800 }]; + // + // // timestamp must be greater than or equal to '2023-01-01T00:00:00Z' and less than '2023-01-02T00:00:00Z' [timestamp.gte_lt] + // google.protobuf.Timestamp another_value = 2 [(buf.validate.field).timestamp = { gte: { seconds: 1672444800 }, lt: { seconds: 1672531200 } }]; + // + // // timestamp must be greater than or equal to '2023-01-02T00:00:00Z' or less than '2023-01-01T00:00:00Z' [timestamp.gte_lt_exclusive] + // google.protobuf.Timestamp other_value = 3 [(buf.validate.field).timestamp = { gte: { seconds: 1672531200 }, lt: { seconds: 1672444800 } }]; + // } + // ``` + google.protobuf.Timestamp gte = 6 [ + (predefined).cel = { + id: "timestamp.gte" + expression: + "!has(rules.lt) && !has(rules.lte) && this < rules.gte" + "? 'must be greater than or equal to %s'.format([rules.gte]) : ''" + }, + (predefined).cel = { + id: "timestamp.gte_lt" + expression: + "has(rules.lt) && rules.lt >= rules.gte && (this >= rules.lt || this < rules.gte)" + "? 'must be greater than or equal to %s and less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "timestamp.gte_lt_exclusive" + expression: + "has(rules.lt) && rules.lt < rules.gte && (rules.lt <= this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than %s'.format([rules.gte, rules.lt]) : ''" + }, + (predefined).cel = { + id: "timestamp.gte_lte" + expression: + "has(rules.lte) && rules.lte >= rules.gte && (this > rules.lte || this < rules.gte)" + "? 'must be greater than or equal to %s and less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + }, + (predefined).cel = { + id: "timestamp.gte_lte_exclusive" + expression: + "has(rules.lte) && rules.lte < rules.gte && (rules.lte < this && this < rules.gte)" + "? 'must be greater than or equal to %s or less than or equal to %s'.format([rules.gte, rules.lte]) : ''" + } + ]; + + // `gt_now` specifies that this field, of the `google.protobuf.Timestamp` type, must be greater than the current time. `gt_now` can only be used with the `within` rule. + // + // ```proto + // message MyTimestamp { + // // must be greater than now + // google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.gt_now = true]; + // } + // ``` + bool gt_now = 8 [(predefined).cel = { + id: "timestamp.gt_now" + expression: "(rules.gt_now && this < now) ? 'must be greater than now' : ''" + }]; + } + + // `within` specifies that this field, of the `google.protobuf.Timestamp` type, must be within the specified duration of the current time. If the field value isn't within the duration, an error message is generated. + // + // ```proto + // message MyTimestamp { + // // must be within 1 hour of now + // google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.within = {seconds: 3600}]; + // } + // ``` + optional google.protobuf.Duration within = 9 [(predefined).cel = { + id: "timestamp.within" + expression: "this < now-rules.within || this > now+rules.within ? 'must be within %s of now'.format([rules.within]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyTimestamp { + // google.protobuf.Timestamp value = 1 [ + // (buf.validate.field).timestamp.example = { seconds: 1672444800 }, + // (buf.validate.field).timestamp.example = { seconds: 1672531200 } + // ]; + // } + // ``` + repeated google.protobuf.Timestamp example = 10 [(predefined).cel = { + id: "timestamp.example" + expression: "true" + }]; + + // Extension fields that have the (buf.validate.predefined) option set + // will be treated as predefined field rules. + // See https://protovalidate.com/schemas/predefined-rules/ + extensions 1000 to max; +} + +// `Violations` is a collection of `Violation` messages. This message type is returned by +// Protovalidate when a proto message fails to meet the requirements set by the `Rule` validation rules. +// Each individual violation is represented by a `Violation` message. +message Violations { + // `violations` is a repeated field that contains all the `Violation` messages corresponding to the violations detected. + repeated Violation violations = 1; +} + +// `Violation` represents a single instance where a validation rule, expressed +// as a `Rule`, was not met. It provides information about the field that +// caused the violation, the specific rule that wasn't fulfilled, and a +// human-readable error message. +// +// For example, consider the following message: +// +// ```proto +// message User { +// int32 age = 1 [(buf.validate.field).cel = { +// id: "user.age", +// expression: "this < 18 ? 'User must be at least 18 years old' : ''", +// }]; +// } +// ``` +// +// It could produce the following violation: +// +// ```json +// { +// "ruleId": "user.age", +// "message": "User must be at least 18 years old", +// "field": { +// "elements": [ +// { +// "fieldNumber": 1, +// "fieldName": "age", +// "fieldType": "TYPE_INT32" +// } +// ] +// }, +// "rule": { +// "elements": [ +// { +// "fieldNumber": 23, +// "fieldName": "cel", +// "fieldType": "TYPE_MESSAGE", +// "index": "0" +// } +// ] +// } +// } +// ``` +message Violation { + // `field` is a machine-readable path to the field that failed validation. + // This could be a nested field, in which case the path will include all the parent fields leading to the actual field that caused the violation. + // + // For example, consider the following message: + // + // ```proto + // message Message { + // bool a = 1 [(buf.validate.field).required = true]; + // } + // ``` + // + // It could produce the following violation: + // + // ```textproto + // violation { + // field { element { field_number: 1, field_name: "a", field_type: 8 } } + // ... + // } + // ``` + optional FieldPath field = 5; + + // `rule` is a machine-readable path that points to the specific rule that failed validation. + // This will be a nested field starting from the FieldRules of the field that failed validation. + // For custom rules, this will provide the path of the rule, e.g. `cel[0]`. + // + // For example, consider the following message: + // + // ```proto + // message Message { + // bool a = 1 [(buf.validate.field).required = true]; + // bool b = 2 [(buf.validate.field).cel = { + // id: "custom_rule", + // expression: "!this ? 'b must be true': ''" + // }]; + // } + // ``` + // + // It could produce the following violations: + // + // ```textproto + // violation { + // rule { element { field_number: 25, field_name: "required", field_type: 8 } } + // ... + // } + // violation { + // rule { element { field_number: 23, field_name: "cel", field_type: 11, index: 0 } } + // ... + // } + // ``` + optional FieldPath rule = 6; + + // `rule_id` is the unique identifier of the `Rule` that was not fulfilled. + // This is the same `id` that was specified in the `Rule` message, allowing easy tracing of which rule was violated. + optional string rule_id = 2; + + // `message` is a human-readable error message that describes the nature of the violation. + // This can be the default error message from the violated `Rule`, or it can be a custom message that gives more context about the violation. + optional string message = 3; + + // `for_key` indicates whether the violation was caused by a map key, rather than a value. + optional bool for_key = 4; + + reserved 1; + reserved "field_path"; +} + +// `FieldPath` provides a path to a nested protobuf field. +// +// This message provides enough information to render a dotted field path even without protobuf descriptors. +// It also provides enough information to resolve a nested field through unknown wire data. +message FieldPath { + // `elements` contains each element of the path, starting from the root and recursing downward. + repeated FieldPathElement elements = 1; +} + +// `FieldPathElement` provides enough information to nest through a single protobuf field. +// +// If the selected field is a map or repeated field, the `subscript` value selects a specific element from it. +// A path that refers to a value nested under a map key or repeated field index will have a `subscript` value. +// The `field_type` field allows unambiguous resolution of a field even if descriptors are not available. +message FieldPathElement { + // `field_number` is the field number this path element refers to. + optional int32 field_number = 1; + + // `field_name` contains the field name this path element refers to. + // This can be used to display a human-readable path even if the field number is unknown. + optional string field_name = 2; + + // `field_type` specifies the type of this field. When using reflection, this value is not needed. + // + // This value is provided to make it possible to traverse unknown fields through wire data. + // When traversing wire data, be mindful of both packed[1] and delimited[2] encoding schemes. + // + // [1]: https://protobuf.dev/programming-guides/encoding/#packed + // [2]: https://protobuf.dev/programming-guides/encoding/#groups + // + // N.B.: Although groups are deprecated, the corresponding delimited encoding scheme is not, and + // can be explicitly used in Protocol Buffers 2023 Edition. + optional google.protobuf.FieldDescriptorProto.Type field_type = 3; + + // `key_type` specifies the map key type of this field. This value is useful when traversing + // unknown fields through wire data: specifically, it allows handling the differences between + // different integer encodings. + optional google.protobuf.FieldDescriptorProto.Type key_type = 4; + + // `value_type` specifies map value type of this field. This is useful if you want to display a + // value inside unknown fields through wire data. + optional google.protobuf.FieldDescriptorProto.Type value_type = 5; + + // `subscript` contains a repeated index or map key, if this path element nests into a repeated or map field. + oneof subscript { + // `index` specifies a 0-based index into a repeated field. + uint64 index = 6; + + // `bool_key` specifies a map key of type bool. + bool bool_key = 7; + + // `int_key` specifies a map key of type int32, int64, sint32, sint64, sfixed32 or sfixed64. + int64 int_key = 8; + + // `uint_key` specifies a map key of type uint32, uint64, fixed32 or fixed64. + uint64 uint_key = 9; + + // `string_key` specifies a map key of type string. + string string_key = 10; + } +} diff --git a/proto/openshell.proto b/proto/openshell.proto index 18e43667f..395339045 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -959,7 +959,7 @@ message SandboxSpec { // Container or VM template used to provision the sandbox. SandboxTemplate template = 6; // Required sandbox policy configuration. - openshell.policy.v1.SandboxPolicy policy = 7; + openshell.policy.v1.PolicyDocument policy = 7; // Provider names to attach to this sandbox. repeated string providers = 8; // Portable resource requirements used by the gateway for driver selection @@ -2542,7 +2542,7 @@ message UpdateConfigRequest { // // Global scope (`global=true`): // - applies to all sandboxes in full (no merge). - openshell.policy.v1.SandboxPolicy policy = 2; + openshell.policy.v1.PolicyDocument policy = 2; // Optional single setting key to mutate. string setting_key = 3; // Setting value for upsert operations. @@ -2753,7 +2753,7 @@ message SandboxPolicyRevision { // Time when this revision was loaded by the sandbox. Absent if not loaded. google.protobuf.Timestamp loaded_time = 106; // The full policy (only populated when explicitly requested). - openshell.policy.v1.SandboxPolicy policy = 7; + openshell.policy.v1.PolicyDocument policy = 7; // Immutable provenance supplied with this policy revision. map provenance = 8; } @@ -3100,8 +3100,8 @@ message PolicyChunk { string candidate_effective_policy_hash = 22; // Complete effective policies used for review. These contain policy // configuration only; credential secret values are never materialized. - openshell.policy.v1.SandboxPolicy current_effective_policy = 23; - openshell.policy.v1.SandboxPolicy candidate_effective_policy = 24; + openshell.policy.v1.PolicyDocument current_effective_policy = 23; + openshell.policy.v1.PolicyDocument candidate_effective_policy = 24; } // Notification that the draft policy was updated. diff --git a/proto/policy.proto b/proto/policy.proto index b91b9c683..dc2ebe426 100644 --- a/proto/policy.proto +++ b/proto/policy.proto @@ -6,15 +6,16 @@ syntax = "proto3"; package openshell.policy.v1; import "google/protobuf/struct.proto"; +import "buf/validate/validate.proto"; // Author-controlled sandbox policy. // // This package is the public policy contract used by policy files and SDKs. // Runtime-derived authority and normalization state live in // openshell.sandbox.v1 and cannot be supplied through these messages. -message SandboxPolicy { +message PolicyDocument { // Authored policy schema version. The only supported value is 1. - uint32 version = 1; + uint32 version = 1 [(buf.validate.field).uint32.const = 1]; // Filesystem access policy. Absence enables the runtime workdir default; // a present empty message explicitly disables that default. FilesystemPolicy filesystem_policy = 2; @@ -23,9 +24,15 @@ message SandboxPolicy { // Process identity policy. ProcessPolicy process = 4; // Network rules keyed by stable policy-local name. - map network_policies = 5; + map network_policies = 5 [(buf.validate.field).map = { + max_pairs: 1024, + keys: {string: {min_len: 1, max_len: 256}} + }]; // Reusable middleware configs keyed by stable policy-local name. - map network_middlewares = 6; + map network_middlewares = 6 [(buf.validate.field).map = { + max_pairs: 1024, + keys: {string: {min_len: 1, max_len: 256}} + }]; } message FilesystemPolicy { @@ -47,22 +54,33 @@ message ProcessPolicy { message NetworkPolicyRule { // Display name. An empty value falls back to the surrounding map key. string name = 1; - repeated NetworkEndpoint endpoints = 2; - repeated NetworkBinary binaries = 3; + repeated NetworkEndpoint endpoints = 2 [(buf.validate.field).repeated.max_items = 4096]; + // Omitted and empty both match no process. + repeated NetworkBinary binaries = 3 [(buf.validate.field).repeated.max_items = 4096]; } message NetworkEndpoint { - string host = 1; - // Compatibility shorthand for a single port. Mutually exclusive with ports. - uint32 port = 2; + string host = 1 [(buf.validate.field).string.max_len = 4096]; + reserved 2; + reserved "port"; string protocol = 3; string tls = 4; string enforcement = 5; string access = 6; - repeated L7Rule rules = 7; - repeated string allowed_ips = 8; - repeated uint32 ports = 9; - repeated L7DenyRule deny_rules = 10; + repeated L7Rule rules = 7 [(buf.validate.field).repeated.max_items = 16384]; + repeated string allowed_ips = 8 [(buf.validate.field).repeated = { + max_items: 4096, + items: {string: {min_len: 1, max_len: 4096}} + }]; + // Every endpoint has one nonempty, duplicate-free port list. Tag 9 remains + // stable because earlier review builds exposed this repeated field. + repeated uint32 ports = 9 [(buf.validate.field).repeated = { + min_items: 1, + max_items: 65535, + unique: true, + items: {uint32: {gte: 1, lte: 65535}} + }]; + repeated L7DenyRule deny_rules = 10 [(buf.validate.field).repeated.max_items = 16384]; bool allow_encoded_slash = 11; string persisted_queries = 12; map graphql_persisted_queries = 13; @@ -109,7 +127,7 @@ message GraphqlOperation { } message L7Rule { - L7Allow allow = 1; + L7Allow allow = 1 [(buf.validate.field).required = true]; } message L7Allow { @@ -138,28 +156,39 @@ message L7DenyRule { message Matcher { oneof kind { - string glob = 1; + option (buf.validate.oneof).required = true; + string glob = 1 [(buf.validate.field).string = {min_len: 1, max_len: 4096}]; AnyMatcher any = 2; } } message AnyMatcher { - repeated string values = 1; + repeated string values = 1 [(buf.validate.field).repeated = { + min_items: 1, + max_items: 1024, + unique: true, + items: {string: {min_len: 1, max_len: 4096}} + }]; } message ParameterMatcher { oneof kind { + option (buf.validate.oneof).required = true; Matcher matcher = 1; ParameterObject object = 2; } } message ParameterObject { - map fields = 1; + map fields = 1 [(buf.validate.field).map = { + min_pairs: 1, + max_pairs: 1024, + keys: {string: {min_len: 1, max_len: 256}} + }]; } message NetworkBinary { - string path = 1; + string path = 1 [(buf.validate.field).string = {min_len: 1, max_len: 4096}]; } message NetworkMiddleware { diff --git a/providers/anthropic.yaml b/providers/anthropic.yaml index 53c591742..6d6ff6a2f 100644 --- a/providers/anthropic.yaml +++ b/providers/anthropic.yaml @@ -36,7 +36,7 @@ discovery: credentials: [api_key] endpoints: - host: api.anthropic.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/providers/aws-s3.yaml b/providers/aws-s3.yaml index a3f13625c..66f961170 100644 --- a/providers/aws-s3.yaml +++ b/providers/aws-s3.yaml @@ -91,7 +91,7 @@ endpoints: # wildcard does not cover extra labels like dualstack unless listed. # us-east-1 uses the global endpoint without a region label. - host: "*.s3.amazonaws.com" - port: 443 + ports: [443] protocol: rest tls: terminate access: read-write @@ -99,7 +99,7 @@ endpoints: credential_signing: sigv4 signing_service: s3 - host: "s3.amazonaws.com" - port: 443 + ports: [443] protocol: rest tls: terminate access: read-write @@ -107,7 +107,7 @@ endpoints: credential_signing: sigv4 signing_service: s3 - host: "*.s3.*.amazonaws.com" - port: 443 + ports: [443] protocol: rest tls: terminate access: read-write @@ -115,7 +115,7 @@ endpoints: credential_signing: sigv4 signing_service: s3 - host: "s3.*.amazonaws.com" - port: 443 + ports: [443] protocol: rest tls: terminate access: read-write @@ -123,7 +123,7 @@ endpoints: credential_signing: sigv4 signing_service: s3 - host: "*.s3.dualstack.*.amazonaws.com" - port: 443 + ports: [443] protocol: rest tls: terminate access: read-write @@ -131,7 +131,7 @@ endpoints: credential_signing: sigv4 signing_service: s3 - host: "s3.dualstack.*.amazonaws.com" - port: 443 + ports: [443] protocol: rest tls: terminate access: read-write diff --git a/providers/claude-code.yaml b/providers/claude-code.yaml index dcfde7f7e..9313812f8 100644 --- a/providers/claude-code.yaml +++ b/providers/claude-code.yaml @@ -38,17 +38,17 @@ discovery: credentials: [api_key] endpoints: - host: api.anthropic.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: statsig.anthropic.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: sentry.io - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/providers/codex.yaml b/providers/codex.yaml index c9572a0a1..7c466de3f 100644 --- a/providers/codex.yaml +++ b/providers/codex.yaml @@ -48,22 +48,22 @@ discovery: credentials: [access_token, refresh_token, account_id, id_token] endpoints: - host: api.openai.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: auth.openai.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: chatgpt.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: ab.chatgpt.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/providers/copilot.yaml b/providers/copilot.yaml index 7a9419a1f..7e17b0063 100644 --- a/providers/copilot.yaml +++ b/providers/copilot.yaml @@ -38,35 +38,35 @@ discovery: credentials: [api_token] endpoints: - host: api.githubcopilot.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: api.individual.githubcopilot.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: api.business.githubcopilot.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: api.enterprise.githubcopilot.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: copilot-proxy.githubusercontent.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: telemetry.enterprise.githubcopilot.com - port: 443 + ports: [443] allow_uninspected_credentials: true - host: default.exp-tas.com - port: 443 + ports: [443] allow_uninspected_credentials: true binaries: - /usr/bin/copilot diff --git a/providers/cursor.yaml b/providers/cursor.yaml index 8f6061938..7a501cb21 100644 --- a/providers/cursor.yaml +++ b/providers/cursor.yaml @@ -26,15 +26,15 @@ description: Cursor editor server bootstrap and update endpoints category: agent endpoints: - host: cursor.blob.core.windows.net - port: 443 + ports: [443] - host: api2.cursor.sh - port: 443 + ports: [443] - host: repo.cursor.sh - port: 443 + ports: [443] - host: download.cursor.sh - port: 443 + ports: [443] - host: cursor.download.prss.microsoft.com - port: 443 + ports: [443] binaries: - /usr/bin/curl - /usr/bin/wget diff --git a/providers/deepinfra.yaml b/providers/deepinfra.yaml index f4dfb669d..e3ebdf431 100644 --- a/providers/deepinfra.yaml +++ b/providers/deepinfra.yaml @@ -34,7 +34,7 @@ discovery: credentials: [api_key] endpoints: - host: api.deepinfra.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/providers/github.yaml b/providers/github.yaml index 3854d2cdc..2b107582b 100644 --- a/providers/github.yaml +++ b/providers/github.yaml @@ -37,12 +37,12 @@ endpoints: # writes require an explicit policy proposal so the agentic loop + # prover can audit each capability change. - host: api.github.com - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce - host: api.github.com - port: 443 + ports: [443] path: /graphql protocol: graphql access: read-only @@ -53,7 +53,7 @@ endpoints: # clone/fetch works while push (git-receive-pack) stays denied — enabling # push requires an explicit policy proposal. - host: github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: diff --git a/providers/google-vertex-ai.yaml b/providers/google-vertex-ai.yaml index a0994e413..75c016e69 100644 --- a/providers/google-vertex-ai.yaml +++ b/providers/google-vertex-ai.yaml @@ -86,22 +86,22 @@ discovery: credentials: [service_account_token, gcloud_adc_token] endpoints: - host: "*-aiplatform.googleapis.com" - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: "aiplatform.googleapis.com" - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: "aiplatform.us.rep.googleapis.com" - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: "aiplatform.eu.rep.googleapis.com" - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/providers/nvidia.yaml b/providers/nvidia.yaml index d334aec8a..42c05b3d2 100644 --- a/providers/nvidia.yaml +++ b/providers/nvidia.yaml @@ -34,7 +34,7 @@ discovery: credentials: [api_key] endpoints: - host: integrate.api.nvidia.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/providers/openai.yaml b/providers/openai.yaml index 552afd66a..78e8628c7 100644 --- a/providers/openai.yaml +++ b/providers/openai.yaml @@ -36,7 +36,7 @@ discovery: credentials: [api_key] endpoints: - host: api.openai.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/providers/pypi.yaml b/providers/pypi.yaml index 73c9573e1..45882015e 100644 --- a/providers/pypi.yaml +++ b/providers/pypi.yaml @@ -30,17 +30,17 @@ description: Python package installation from PyPI and related package sources category: data endpoints: - host: pypi.org - port: 443 + ports: [443] - host: files.pythonhosted.org - port: 443 + ports: [443] - host: github.com - port: 443 + ports: [443] - host: objects.githubusercontent.com - port: 443 + ports: [443] - host: api.github.com - port: 443 + ports: [443] - host: downloads.python.org - port: 443 + ports: [443] binaries: - /sandbox/.venv/bin/python - /sandbox/.venv/bin/python3 diff --git a/pyproject.toml b/pyproject.toml index 33ca25ddf..a71ce7805 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,10 @@ dependencies = [ "grpcio>=1.60", "googleapis-common-protos>=1.63", "httpx>=0.27", - "protobuf>=4.25", + # protovalidate 1.0 uses descriptor APIs removed in protobuf 7. The lower + # bound matches the protoc runtime version used for checked wheel stubs. + "protobuf>=6.31.1,<7", + "protovalidate==1.0.0", ] classifiers = [ "Development Status :: 3 - Alpha", @@ -62,7 +65,7 @@ include-package-data = false [tool.setuptools.packages.find] where = ["python"] -include = ["openshell*"] +include = ["openshell*", "buf*"] namespaces = false [tool.setuptools.package-data] diff --git a/python/buf/__init__.py b/python/buf/__init__.py new file mode 100644 index 000000000..9f9183436 --- /dev/null +++ b/python/buf/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Generated Buf protobuf dependencies for the OpenShell SDK.""" diff --git a/python/buf/validate/__init__.py b/python/buf/validate/__init__.py new file mode 100644 index 000000000..3a98866a0 --- /dev/null +++ b/python/buf/validate/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Generated Protovalidate annotation messages.""" diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 8cfc8cb33..e08dc0a8b 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations from .errors import ErrorInfo, FieldViolation, GatewayError, from_grpc_error from .mutations import DeletionOutcome, DeletionResult +from .policy import PolicyDocument, validate_policy_document from .sandbox import ( ClientCredentialsAuth, ExecChunk, @@ -44,6 +45,7 @@ __all__ = [ "GatewayError", "Page", "Pager", + "PolicyDocument", "Sandbox", "SandboxClient", "SandboxError", @@ -57,4 +59,5 @@ __all__ = [ "WorkspaceRef", "__version__", "from_grpc_error", + "validate_policy_document", ] diff --git a/python/openshell/policy.py b/python/openshell/policy.py new file mode 100644 index 000000000..ffd8f4d09 --- /dev/null +++ b/python/openshell/policy.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Validation helpers for the public authored policy contract.""" + +from __future__ import annotations + +import protovalidate + +from ._proto import policy_pb2 + +PolicyDocument = policy_pb2.PolicyDocument + + +def validate_policy_document(document: PolicyDocument) -> PolicyDocument: + """Validate a policy against the portable rules declared in policy.proto.""" + protovalidate.validate(document) + return document diff --git a/python/openshell/policy_test.py b/python/openshell/policy_test.py new file mode 100644 index 000000000..d154436f9 --- /dev/null +++ b/python/openshell/policy_test.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import protovalidate +import pytest + +from openshell.policy import PolicyDocument, validate_policy_document + + +def test_policy_document_validation_runs_portable_rules() -> None: + assert validate_policy_document(PolicyDocument(version=1)).version == 1 + + +def test_policy_document_validation_reports_portable_rule_ids() -> None: + documents: list[tuple[str, PolicyDocument, str]] = [] + + documents.append(("version", PolicyDocument(), "uint32.const")) + + missing_ports = PolicyDocument(version=1) + missing_ports.network_policies["api"].endpoints.add(host="api.example.com") + documents.append(("missing ports", missing_ports, "repeated.min_items")) + + duplicate_ports = PolicyDocument(version=1) + duplicate_ports.network_policies["api"].endpoints.add( + host="api.example.com", ports=[443, 443] + ) + documents.append(("duplicate ports", duplicate_ports, "repeated.unique")) + + port_range = PolicyDocument(version=1) + port_range.network_policies["api"].endpoints.add( + host="api.example.com", ports=[65536] + ) + documents.append(("port range", port_range, "uint32.gte_lte")) + + binary_path = PolicyDocument(version=1) + binary_path.network_policies["api"].binaries.add(path="") + documents.append(("binary path", binary_path, "string.min_len")) + + matcher_choice = PolicyDocument(version=1) + endpoint = matcher_choice.network_policies["api"].endpoints.add( + host="api.example.com", ports=[443] + ) + endpoint.rules.add().allow.query["owner"].SetInParent() + documents.append(("matcher choice", matcher_choice, "required")) + + for name, document, rule_id in documents: + with pytest.raises(protovalidate.ValidationError) as error: + validate_policy_document(document) + + assert rule_id in { + violation.proto.rule_id for violation in error.value.violations + }, name diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index ec309d87a..d2b3850e7 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -30,6 +30,7 @@ from ._proto import ( ) from .errors import _error_mapping_channel from .mutations import DeletionOutcome, DeletionResult +from .policy import validate_policy_document _ClientCallDetailsBase = namedtuple( "_ClientCallDetailsBase", @@ -766,6 +767,8 @@ class SandboxClient: labels: Mapping[str, str] | None = None, ) -> SandboxRef: request_spec = spec if spec is not None else _default_spec() + if request_spec.HasField("policy"): + validate_policy_document(request_spec.policy) response = self._stub.CreateSandbox( openshell_pb2.CreateSandboxRequest( workspace_scope=_workspace_scope(workspace), @@ -792,6 +795,8 @@ class SandboxClient: if not workload_template.strip(): raise SandboxError("workload_template is required") request_spec = spec if spec is not None else openshell_pb2.SandboxSpec() + if request_spec.HasField("policy"): + validate_policy_document(request_spec.policy) response = self._stub.CreateSandbox( openshell_pb2.CreateSandboxRequest( workspace_scope=_workspace_scope(workspace), diff --git a/python/wheel_verifier_test.py b/python/wheel_verifier_test.py index 873fb75fb..9f1c02e8f 100644 --- a/python/wheel_verifier_test.py +++ b/python/wheel_verifier_test.py @@ -27,12 +27,16 @@ verifier = _load_verifier_module() def _wheel_files() -> set[str]: files = { + "buf/__init__.py", + "buf/validate/__init__.py", + "buf/validate/validate_pb2.py", + "buf/validate/validate_pb2.pyi", "openshell/__init__.py", "openshell/sandbox.py", "openshell/py.typed", "openshell/_proto/__init__.py", } - for stem in ("datamodel", "openshell", "options", "sandbox"): + for stem in ("datamodel", "openshell", "options", "policy", "sandbox"): files.add(f"openshell/_proto/{stem}_pb2.py") files.add(f"openshell/_proto/{stem}_pb2.pyi") files.add(f"openshell/_proto/{stem}_pb2_grpc.py") @@ -87,6 +91,14 @@ def test_rejects_missing_generated_proto(tmp_path: Path) -> None: verifier.verify_wheel(wheel) +def test_rejects_missing_validation_descriptor(tmp_path: Path) -> None: + files = _wheel_files() - {"buf/validate/validate_pb2.py"} + wheel = _write_wheel(tmp_path, files=files) + + with pytest.raises(ValueError, match=r"buf/validate/validate_pb2\.py"): + verifier.verify_wheel(wheel) + + def test_rejects_bundled_cli(tmp_path: Path) -> None: files = _wheel_files() | {"openshell-1.2.3.data/scripts/openshell"} wheel = _write_wheel(tmp_path, files=files) diff --git a/rfc/0013-native-windows-mxc/README.md b/rfc/0013-native-windows-mxc/README.md index 955bb1e9d..235337150 100644 --- a/rfc/0013-native-windows-mxc/README.md +++ b/rfc/0013-native-windows-mxc/README.md @@ -295,12 +295,13 @@ backend already honors `network.proxy`, so this design requires no MXC changes. ### Part 3 - Policy translation between OpenShell and MXC -OpenShell policy is authored as YAML and parsed to the `SandboxPolicy` proto by -the shared cross-platform `openshell-policy` crate. The MXC driver does not -re-parse YAML; a dedicated Rust policy mapper (embedded in the driver and called -automatically) maps the proto IR to MXC `ContainerConfig` and **rejects rather -than silently drops** anything MXC cannot enforce. MXC imposes a provision-time -vs exec-time split. +OpenShell policy is authored as YAML, parsed as the public `PolicyDocument`, and +lowered to the internal `SandboxPolicy` proto by the shared cross-platform +`openshell-policy` crate. The MXC driver does not re-parse YAML; a dedicated +Rust policy mapper (embedded in the driver and called automatically) maps the +internal proto IR to MXC `ContainerConfig` and **rejects rather than silently +drops** anything MXC cannot enforce. MXC imposes a provision-time vs exec-time +split. | OpenShell policy | Where enforced | MXC mapping | When | |---|---|---|---| diff --git a/scripts/agents/gator/providers/codex-gator.yaml b/scripts/agents/gator/providers/codex-gator.yaml index c0820e5c5..5fa10f1de 100644 --- a/scripts/agents/gator/providers/codex-gator.yaml +++ b/scripts/agents/gator/providers/codex-gator.yaml @@ -34,27 +34,27 @@ discovery: credentials: [access_token, account_id] endpoints: - host: api.openai.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: auth.openai.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: chatgpt.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: ab.chatgpt.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce - host: files.openai.com - port: 443 + ports: [443] protocol: rest access: read-write enforcement: enforce diff --git a/scripts/agents/gator/providers/github-gator.yaml b/scripts/agents/gator/providers/github-gator.yaml index d0c1f32d4..539a957d1 100644 --- a/scripts/agents/gator/providers/github-gator.yaml +++ b/scripts/agents/gator/providers/github-gator.yaml @@ -16,7 +16,7 @@ discovery: credentials: [GITHUB_TOKEN] endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -58,7 +58,7 @@ endpoints: - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/pulls/*/reviews/*/comments } - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/statuses/* } - host: api.github.com - port: 443 + ports: [443] path: /graphql protocol: graphql enforcement: enforce @@ -70,7 +70,7 @@ endpoints: operation_name: ResolveGatorReviewThread fields: [resolveReviewThread] - host: github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -85,14 +85,14 @@ endpoints: - allow: { method: POST, path: /NVIDIA/OpenShell-Community/**/git-upload-pack } - allow: { method: POST, path: /NVIDIA/OpenShell-Community.git/**/git-upload-pack } - host: codeload.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: - allow: { method: GET, path: /NVIDIA/OpenShell/** } - allow: { method: GET, path: /NVIDIA/OpenShell-Community/** } - host: results-receiver.actions.githubusercontent.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: diff --git a/scripts/smoke-test-network-policy.sh b/scripts/smoke-test-network-policy.sh index 49376fd36..fec6e7175 100755 --- a/scripts/smoke-test-network-policy.sh +++ b/scripts/smoke-test-network-policy.sh @@ -51,7 +51,7 @@ # POLICY_L4 (allow api.github.com:443 with credential injection, deny everything else): # network_policies: # github_api: -# endpoints: [{ host: api.github.com, port: 443, protocol: rest, access: full }] +# endpoints: [{ host: api.github.com, ports: [443], protocol: rest, access: full }] # binaries: [{ path: /usr/bin/curl }] # # POLICY_L7_READONLY (L7 read-only enforcement): @@ -59,7 +59,7 @@ # github_api: # endpoints: # - host: api.github.com -# port: 443 +# ports: [443] # protocol: rest # enforcement: enforce # access: read-only @@ -71,7 +71,7 @@ # POLICY_TLS_SKIP (L4 with tls: skip — raw tunnel): # network_policies: # github_api: -# endpoints: [{ host: api.github.com, port: 443, tls: skip }] +# endpoints: [{ host: api.github.com, ports: [443], tls: skip }] # binaries: [{ path: /usr/bin/curl }] # # ============================================================================= @@ -205,7 +205,7 @@ network_policies: name: github-api-l4 endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: full @@ -230,7 +230,7 @@ network_policies: name: github-api-l7-readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only @@ -255,7 +255,7 @@ network_policies: name: github-api-cred-inject endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: full @@ -280,7 +280,7 @@ network_policies: name: github-api-skip endpoints: - host: api.github.com - port: 443 + ports: [443] tls: skip binaries: - { path: /usr/bin/curl } diff --git a/sdk/go/buf.gen.yaml b/sdk/go/buf.gen.yaml index 967a75f96..956b664ce 100644 --- a/sdk/go/buf.gen.yaml +++ b/sdk/go/buf.gen.yaml @@ -9,12 +9,17 @@ version: v2 inputs: - - proto_file: proto/openshell.proto + - directory: proto + paths: + - proto/openshell.proto + - proto/sandbox.proto + - proto/datamodel.proto + - proto/options.proto + - proto/policy.proto plugins: - local: protoc-gen-go out: sdk/go - include_imports: true opt: - module=github.com/NVIDIA/OpenShell/sdk/go - Mopenshell.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1 @@ -24,7 +29,6 @@ plugins: - Mpolicy.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1 - local: protoc-gen-go-grpc out: sdk/go - include_imports: true opt: - module=github.com/NVIDIA/OpenShell/sdk/go - Mopenshell.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1 diff --git a/sdk/go/go.mod b/sdk/go/go.mod index e5d3f921e..31f7d6df7 100644 --- a/sdk/go/go.mod +++ b/sdk/go/go.mod @@ -3,20 +3,28 @@ module github.com/NVIDIA/OpenShell/sdk/go go 1.25.13 require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 + buf.build/go/protovalidate v1.3.0 github.com/coder/websocket v1.8.15 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( + cel.dev/expr v0.25.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/cel-go v0.30.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/sdk/go/go.sum b/sdk/go/go.sum index 6f325c4d0..a72182692 100644 --- a/sdk/go/go.sum +++ b/sdk/go/go.sum @@ -1,3 +1,13 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/go/protovalidate v1.3.0 h1:8ITcnZGkAHx6TyhZvro+iET/AyqU8gEWQJK2WsT62ms= +buf.build/go/protovalidate v1.3.0/go.mod h1:82s5g+rFRj1CZPiLv6OTA31jBu2fpq7mLXHwa9mZfEs= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= @@ -10,12 +20,20 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo= +github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -30,6 +48,10 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -42,13 +64,16 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdk/go/openshell/v1/config_client_test.go b/sdk/go/openshell/v1/config_client_test.go index 39ccdcf3f..02f3a142c 100644 --- a/sdk/go/openshell/v1/config_client_test.go +++ b/sdk/go/openshell/v1/config_client_test.go @@ -154,7 +154,7 @@ func TestConfigGetSandbox(t *testing.T) { assert.Equal(t, uint32(1), sc.GlobalPolicyVersion) assert.Equal(t, uint64(7), sc.ProviderEnvRevision) - // Typed SandboxPolicy. + // Typed PolicyDocument. require.NotNil(t, sc.Policy) assert.Equal(t, uint32(4), sc.Policy.Version) require.NotNil(t, sc.Policy.Filesystem) @@ -442,8 +442,8 @@ func TestConfigUpdate_WithPolicy(t *testing.T) { update := &ConfigUpdate{ Name: "my-sandbox", - Policy: &types.SandboxPolicy{ - Version: 5, + Policy: &types.PolicyDocument{ + Version: 1, Filesystem: &types.FilesystemPolicy{ ReadOnly: []string{"/usr"}, }, @@ -462,7 +462,7 @@ func TestConfigUpdate_WithPolicy(t *testing.T) { mock.mu.Unlock() require.NotNil(t, req.GetPolicy()) - assert.Equal(t, uint32(5), req.GetPolicy().GetVersion()) + assert.Equal(t, uint32(1), req.GetPolicy().GetVersion()) require.NotNil(t, req.GetPolicy().GetFilesystemPolicy()) assert.Equal(t, []string{"/usr"}, req.GetPolicy().GetFilesystemPolicy().GetReadOnly()) } @@ -472,7 +472,7 @@ func TestConfigUpdate_RejectsUnrepresentableMiddlewareConfigBeforeRPC(t *testing client, cleanup := setupConfigTest(t, mock) defer cleanup() - _, err := client.Update(context.Background(), "default", &ConfigUpdate{Policy: &SandboxPolicy{ + _, err := client.Update(context.Background(), "default", &ConfigUpdate{Policy: &PolicyDocument{ NetworkMiddlewares: map[string]types.NetworkMiddlewareConfig{ "audit": {Config: map[string]any{"invalid": make(chan int)}}, }, diff --git a/sdk/go/openshell/v1/doc.go b/sdk/go/openshell/v1/doc.go index 558485f17..d5a8a4ac9 100644 --- a/sdk/go/openshell/v1/doc.go +++ b/sdk/go/openshell/v1/doc.go @@ -301,7 +301,7 @@ // // sandbox, err := client.Sandboxes().Create(ctx, "default", "secure-sandbox", &v1.SandboxSpec{ // Template: &v1.SandboxTemplate{Image: "python:3.12"}, -// Policy: &v1.SandboxPolicy{ +// Policy: &v1.PolicyDocument{ // Version: 1, // Filesystem: &v1.FilesystemPolicy{ // IncludeWorkdir: true, @@ -315,7 +315,7 @@ // "allow-api": { // Name: "allow-api", // Endpoints: []v1.PolicyNetworkEndpoint{ -// {Host: "api.example.com", Port: 443, Protocol: "tcp"}, +// {Host: "api.example.com", Ports: []uint32{443}, Protocol: "tcp"}, // }, // }, // }, @@ -326,7 +326,7 @@ // // result, err := client.Config().Update(ctx, "default", &v1.ConfigUpdate{ // Name: "secure-sandbox", -// Policy: &v1.SandboxPolicy{ +// Policy: &v1.PolicyDocument{ // Version: 2, // NetworkPolicies: map[string]v1.NetworkPolicyRule{ // "allow-all": {Name: "allow-all"}, diff --git a/sdk/go/openshell/v1/fake/policy.go b/sdk/go/openshell/v1/fake/policy.go index 49c62cda9..eba8d60fb 100644 --- a/sdk/go/openshell/v1/fake/policy.go +++ b/sdk/go/openshell/v1/fake/policy.go @@ -13,7 +13,7 @@ import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" ) -func copySandboxPolicyRevision(r types.SandboxPolicyRevision) types.SandboxPolicyRevision { +func copyPolicyDocumentRevision(r types.SandboxPolicyRevision) types.SandboxPolicyRevision { if r.Policy != nil { cp := *r.Policy if r.Policy.NetworkPolicies != nil { @@ -50,7 +50,7 @@ func newFakePolicyClient(closedFunc func() bool) *fakePolicyClient { func (c *fakePolicyClient) AddGlobalRevision(rev types.SandboxPolicyRevision) { c.mu.Lock() defer c.mu.Unlock() - c.globalRevisions = append(c.globalRevisions, copySandboxPolicyRevision(rev)) + c.globalRevisions = append(c.globalRevisions, copyPolicyDocumentRevision(rev)) } // AddRevision adds a sandbox-scoped policy revision for test seeding. @@ -58,7 +58,7 @@ func (c *fakePolicyClient) AddRevision(workspace, name string, rev types.Sandbox c.mu.Lock() defer c.mu.Unlock() key := workspace + "/" + name - c.sandboxRevisions[key] = append(c.sandboxRevisions[key], copySandboxPolicyRevision(rev)) + c.sandboxRevisions[key] = append(c.sandboxRevisions[key], copyPolicyDocumentRevision(rev)) } // GetDraft returns Unimplemented. @@ -156,13 +156,13 @@ func (c *fakePolicyClient) GetStatus(_ context.Context, workspace, sandboxName s targetVersion := cfg.Version() if targetVersion == 0 { // Latest revision (by highest version, not insertion order). - rev := copySandboxPolicyRevision(revisions[maxIdx]) + rev := copyPolicyDocumentRevision(revisions[maxIdx]) return &types.PolicyStatusResult{Revision: rev, ActiveVersion: activeVersion}, nil } for _, r := range revisions { if r.Version == targetVersion { - rev := copySandboxPolicyRevision(r) + rev := copyPolicyDocumentRevision(r) return &types.PolicyStatusResult{Revision: rev, ActiveVersion: activeVersion}, nil } } @@ -207,7 +207,7 @@ func (c *fakePolicyClient) List(workspace, sandboxName string, opts ...v1.ListPo result := make([]types.SandboxPolicyRevision, len(revisions)) for i, r := range revisions { - result[i] = copySandboxPolicyRevision(r) + result[i] = copyPolicyDocumentRevision(r) } return newSlicePager(result, int(cfg.PageSize()), cfg.PageToken()) } diff --git a/sdk/go/openshell/v1/fake/policy_test.go b/sdk/go/openshell/v1/fake/policy_test.go index 924bd2fab..ab271283d 100644 --- a/sdk/go/openshell/v1/fake/policy_test.go +++ b/sdk/go/openshell/v1/fake/policy_test.go @@ -210,7 +210,7 @@ func TestFakePolicy_DeepCopyWithPolicy(t *testing.T) { Version: 1, PolicyHash: "sha256:with-policy", Status: types.PolicyLoadStatusLoaded, - Policy: &types.SandboxPolicy{ + Policy: &types.PolicyDocument{ NetworkPolicies: map[string]types.NetworkPolicyRule{ "rule-1": {Name: "rule-1"}, }, @@ -221,7 +221,7 @@ func TestFakePolicy_DeepCopyWithPolicy(t *testing.T) { Version: 1, PolicyHash: "sha256:sb-policy", Status: types.PolicyLoadStatusLoaded, - Policy: &types.SandboxPolicy{ + Policy: &types.PolicyDocument{ NetworkPolicies: map[string]types.NetworkPolicyRule{ "rule-2": {Name: "rule-2"}, }, diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index cb2693313..47dabfbaf 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -54,13 +54,13 @@ func copySandboxSpec(s types.SandboxSpec) types.SandboxSpec { v := *s.GPUCount s.GPUCount = &v } - s.Policy = copySandboxPolicy(s.Policy) + s.Policy = copyPolicyDocument(s.Policy) return s } -// copySandboxPolicy returns a deep copy of a SandboxPolicy pointer. +// copyPolicyDocument returns a deep copy of a PolicyDocument pointer. // All sub-policies, slices, and map entries are duplicated. -func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { +func copyPolicyDocument(p *types.PolicyDocument) *types.PolicyDocument { if p == nil { return nil } @@ -384,7 +384,7 @@ func (c *fakeSandboxClient) CreateFromTemplate(_ context.Context, workspace, nam resolvedSpec := sandboxSpecFromWorkloadTemplate(template) resolvedSpec.Providers = copyStringSlice(spec.Providers) - resolvedSpec.Policy = copySandboxPolicy(spec.Policy) + resolvedSpec.Policy = copyPolicyDocument(spec.Policy) resolvedSpec.Command = copyStringSlice(spec.Command) resolvedSpec.TTY = spec.TTY diff --git a/sdk/go/openshell/v1/fake/sandbox_template_test.go b/sdk/go/openshell/v1/fake/sandbox_template_test.go index 2c84ecc11..df920871c 100644 --- a/sdk/go/openshell/v1/fake/sandbox_template_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_template_test.go @@ -199,7 +199,7 @@ func TestSandboxTemplate_CreateSandboxFromTemplateResolvesWorkloadAndGovernance( client := NewClient() ctx := context.Background() gpuCount := uint32(1) - policy := &types.SandboxPolicy{ + policy := &types.PolicyDocument{ Version: 1, NetworkPolicies: map[string]types.NetworkPolicyRule{ "api": {Name: "api"}, diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go index 5303cdfdf..9a1fb88a2 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -823,7 +823,7 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { spec := &types.SandboxSpec{ LogLevel: "debug", - Policy: &types.SandboxPolicy{ + Policy: &types.PolicyDocument{ Version: 3, Filesystem: &types.FilesystemPolicy{ IncludeWorkdir: true, @@ -841,7 +841,7 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { "web": { Name: "web", Endpoints: []types.PolicyNetworkEndpoint{ - {Host: "api.example.com", Port: 443, Protocol: "rest"}, + {Host: "api.example.com", Ports: []uint32{443}, Protocol: "rest"}, }, }, }, @@ -881,7 +881,7 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { assert.Equal(t, "web", webRule.Name) require.Len(t, webRule.Endpoints, 1) assert.Equal(t, "api.example.com", webRule.Endpoints[0].Host) - assert.Equal(t, uint32(443), webRule.Endpoints[0].Port) + assert.Equal(t, []uint32{443}, webRule.Endpoints[0].Ports) // Deep-copy isolation: mutate input spec, verify stored copy unchanged spec.Policy.Version = 99 diff --git a/sdk/go/openshell/v1/internal/converter/network_policy.go b/sdk/go/openshell/v1/internal/converter/network_policy.go index f0ab6fee2..459996e8b 100644 --- a/sdk/go/openshell/v1/internal/converter/network_policy.go +++ b/sdk/go/openshell/v1/internal/converter/network_policy.go @@ -65,7 +65,6 @@ func NetworkPolicyRuleToProto(r *types.NetworkPolicyRule) *policyv1.NetworkPolic func policyNetworkEndpointFromProto(ep *policyv1.NetworkEndpoint) types.PolicyNetworkEndpoint { result := types.PolicyNetworkEndpoint{ Host: ep.GetHost(), - Port: ep.GetPort(), Protocol: ep.GetProtocol(), TLS: types.NetworkTLSMode(ep.GetTls()), Enforcement: types.NetworkEnforcementMode(ep.GetEnforcement()), @@ -127,11 +126,10 @@ func policyNetworkEndpointFromProto(ep *policyv1.NetworkEndpoint) types.PolicyNe func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *policyv1.NetworkEndpoint { result := &policyv1.NetworkEndpoint{ Host: ep.Host, - Port: ep.Port, Protocol: ep.Protocol, - Tls: sbv1.NetworkTlsMode(ep.TLS), - Enforcement: sbv1.NetworkEnforcementMode(ep.Enforcement), - Access: sbv1.NetworkAccessPreset(ep.Access), + Tls: string(ep.TLS), + Enforcement: string(ep.Enforcement), + Access: string(ep.Access), AllowEncodedSlash: ep.AllowEncodedSlash, PersistedQueries: ep.PersistedQueries, GraphqlMaxBodyBytes: ep.GraphqlMaxBodyBytes, diff --git a/sdk/go/openshell/v1/internal/converter/network_policy_test.go b/sdk/go/openshell/v1/internal/converter/network_policy_test.go index 7c5b2c8de..a8a7a4253 100644 --- a/sdk/go/openshell/v1/internal/converter/network_policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/network_policy_test.go @@ -20,11 +20,10 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { Endpoints: []*policyv1.NetworkEndpoint{ { Host: "api.example.com", - Port: 443, Protocol: "rest", - Tls: sbv1.NetworkTlsMode_NETWORK_TLS_MODE_SKIP, - Enforcement: sbv1.NetworkEnforcementMode_NETWORK_ENFORCEMENT_MODE_ENFORCE, - Access: sbv1.NetworkAccessPreset_NETWORK_ACCESS_PRESET_READ_ONLY, + Tls: "skip", + Enforcement: "enforce", + Access: "read-only", Ports: []uint32{80, 443}, AllowedIps: []string{"10.0.0.1", "10.0.0.2"}, AllowEncodedSlash: true, @@ -97,7 +96,6 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { require.Len(t, rule.Endpoints, 1) ep := rule.Endpoints[0] assert.Equal(t, "api.example.com", ep.Host) - assert.Equal(t, uint32(443), ep.Port) assert.Equal(t, "rest", ep.Protocol) assert.Equal(t, v1.NetworkTLSModeSkip, ep.TLS) assert.Equal(t, v1.NetworkEnforcementModeEnforce, ep.Enforcement) @@ -177,7 +175,6 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { Endpoints: []v1.PolicyNetworkEndpoint{ { Host: "gql.example.com", - Port: 8080, Protocol: "graphql", TLS: v1.NetworkTLSModeSkip, Enforcement: v1.NetworkEnforcementModeAudit, @@ -250,7 +247,6 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { assert.Equal(t, original.Name, roundTrip.Name) require.Len(t, roundTrip.Endpoints, 1) assert.Equal(t, original.Endpoints[0].Host, roundTrip.Endpoints[0].Host) - assert.Equal(t, original.Endpoints[0].Port, roundTrip.Endpoints[0].Port) assert.Equal(t, original.Endpoints[0].Protocol, roundTrip.Endpoints[0].Protocol) assert.Equal(t, original.Endpoints[0].TLS, roundTrip.Endpoints[0].TLS) assert.Equal(t, original.Endpoints[0].Enforcement, roundTrip.Endpoints[0].Enforcement) diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 088d384b8..1fcb4bd9d 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -5,12 +5,13 @@ package converter import ( "fmt" + "strings" + "buf.build/go/protovalidate" "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" ) @@ -78,8 +79,8 @@ func PolicyChunkFromProto(c *pb.PolicyChunk) *types.PolicyChunk { ReviewToken: c.GetReviewToken(), CurrentEffectivePolicyHash: c.GetCurrentEffectivePolicyHash(), CandidateEffectivePolicyHash: c.GetCandidateEffectivePolicyHash(), - CurrentEffectivePolicy: SandboxPolicyFromProto(c.GetCurrentEffectivePolicy()), - CandidateEffectivePolicy: SandboxPolicyFromProto(c.GetCandidateEffectivePolicy()), + CurrentEffectivePolicy: PolicyDocumentFromProto(c.GetCurrentEffectivePolicy()), + CandidateEffectivePolicy: PolicyDocumentFromProto(c.GetCandidateEffectivePolicy()), } } @@ -106,15 +107,15 @@ func DraftPolicyFromProto(r *pb.GetDraftPolicyResponse) *types.DraftPolicy { return result } -// --- SandboxPolicy --- +// --- PolicyDocument --- -// SandboxPolicyFromProto converts a proto SandboxPolicy to an SDK SandboxPolicy. +// PolicyDocumentFromProto converts a proto PolicyDocument to an SDK PolicyDocument. // Returns nil for nil input. All slice and map fields are deep-copied. -func SandboxPolicyFromProto(p *policyv1.SandboxPolicy) *types.SandboxPolicy { +func PolicyDocumentFromProto(p *policyv1.PolicyDocument) *types.PolicyDocument { if p == nil { return nil } - result := &types.SandboxPolicy{ + result := &types.PolicyDocument{ Version: p.GetVersion(), Filesystem: filesystemPolicyFromProto(p.GetFilesystemPolicy()), Landlock: landlockPolicyFromProto(p.GetLandlock()), @@ -139,31 +140,357 @@ func SandboxPolicyFromProto(p *policyv1.SandboxPolicy) *types.SandboxPolicy { return result } -// SandboxPolicyFromInternalProto converts the supervisor's internal policy -// response through the wire-compatible authored schema. Runtime-only fields -// are discarded at this SDK boundary. -func SandboxPolicyFromInternalProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { +// PolicyDocumentFromInternalProto projects the supervisor's internal policy +// response onto the authored policy contract. The schemas deliberately are +// not wire-compatible: the internal message uses enums and normalized matcher +// fields, and it carries runtime-only authority that must not cross this SDK +// boundary. +func PolicyDocumentFromInternalProto(p *sbv1.SandboxPolicy) *types.PolicyDocument { if p == nil { return nil } - payload, err := proto.Marshal(p) - if err != nil { - return nil - } - public := &policyv1.SandboxPolicy{} - if err := proto.Unmarshal(payload, public); err != nil { - return nil - } - return SandboxPolicyFromProto(public) + return PolicyDocumentFromProto(publicPolicyFromInternalProto(p)) } -// SandboxPolicyToProto converts an SDK SandboxPolicy to a proto SandboxPolicy. +func publicPolicyFromInternalProto(p *sbv1.SandboxPolicy) *policyv1.PolicyDocument { + result := &policyv1.PolicyDocument{Version: p.GetVersion()} + if filesystem := p.GetFilesystem(); filesystem != nil { + result.FilesystemPolicy = &policyv1.FilesystemPolicy{ + IncludeWorkdir: filesystem.GetIncludeWorkdir(), + ReadOnly: CopyStringSlice(filesystem.GetReadOnly()), + ReadWrite: CopyStringSlice(filesystem.GetReadWrite()), + } + } + if landlock := p.GetLandlock(); landlock != nil { + result.Landlock = &policyv1.LandlockPolicy{Compatibility: landlock.GetCompatibility()} + } + if process := p.GetProcess(); process != nil { + result.Process = &policyv1.ProcessPolicy{ + RunAsUser: process.GetRunAsUser(), + RunAsGroup: process.GetRunAsGroup(), + } + } + if policies := p.GetNetworkPolicies(); policies != nil { + result.NetworkPolicies = make(map[string]*policyv1.NetworkPolicyRule, len(policies)) + for name, rule := range policies { + if rule != nil { + result.NetworkPolicies[name] = publicNetworkRuleFromInternalProto(rule) + } + } + } + if middlewares := p.GetNetworkMiddlewares(); middlewares != nil { + result.NetworkMiddlewares = make(map[string]*policyv1.NetworkMiddleware, len(middlewares)) + for name, middleware := range middlewares { + if middleware == nil { + continue + } + converted := &policyv1.NetworkMiddleware{ + Name: middleware.GetName(), + Middleware: middleware.GetMiddleware(), + Config: middleware.GetConfig(), + OnError: middleware.GetOnError(), + Order: middleware.GetOrder(), + } + if endpoints := middleware.GetEndpoints(); endpoints != nil { + converted.Endpoints = &policyv1.MiddlewareEndpointSelector{ + Include: CopyStringSlice(endpoints.GetInclude()), + Exclude: CopyStringSlice(endpoints.GetExclude()), + } + } + result.NetworkMiddlewares[name] = converted + } + } + return result +} + +func publicNetworkRuleFromInternalProto(rule *sbv1.NetworkPolicyRule) *policyv1.NetworkPolicyRule { + result := &policyv1.NetworkPolicyRule{Name: rule.GetName()} + if endpoints := rule.GetEndpoints(); len(endpoints) > 0 { + result.Endpoints = make([]*policyv1.NetworkEndpoint, 0, len(endpoints)) + for _, endpoint := range endpoints { + if endpoint != nil { + result.Endpoints = append(result.Endpoints, publicNetworkEndpointFromInternalProto(endpoint)) + } + } + } + if binaries := rule.GetBinaries(); len(binaries) > 0 { + result.Binaries = make([]*policyv1.NetworkBinary, 0, len(binaries)) + for _, binary := range binaries { + if binary != nil { + result.Binaries = append(result.Binaries, &policyv1.NetworkBinary{Path: binary.GetPath()}) + } + } + } + return result +} + +func publicNetworkEndpointFromInternalProto(endpoint *sbv1.NetworkEndpoint) *policyv1.NetworkEndpoint { + protocol := endpoint.GetProtocol() + result := &policyv1.NetworkEndpoint{ + Host: endpoint.GetHost(), + Protocol: protocol, + Tls: publicTLSMode(endpoint.GetTls()), + Enforcement: publicEnforcementMode(endpoint.GetEnforcement()), + Access: publicAccessPreset(endpoint.GetAccess()), + AllowedIps: CopyStringSlice(endpoint.GetAllowedIps()), + AllowEncodedSlash: endpoint.GetAllowEncodedSlash(), + PersistedQueries: endpoint.GetPersistedQueries(), + GraphqlMaxBodyBytes: endpoint.GetGraphqlMaxBodyBytes(), + Path: endpoint.GetPath(), + WebsocketCredentialRewrite: endpoint.GetWebsocketCredentialRewrite(), + RequestBodyCredentialRewrite: endpoint.GetRequestBodyCredentialRewrite(), + AllowUninspectedCredentials: endpoint.GetAllowUninspectedCredentials(), + CredentialSigning: endpoint.GetCredentialSigning(), + SigningService: endpoint.GetSigningService(), + SigningRegion: endpoint.GetSigningRegion(), + } + if ports := endpoint.GetPorts(); len(ports) > 0 { + result.Ports = append([]uint32(nil), ports...) + } else if port := endpoint.GetPort(); port != 0 { + // Older internal records may still use the legacy scalar port. The + // authored contract has only the canonical list representation. + result.Ports = []uint32{port} + } + if binding := endpoint.GetCredentialBinding(); binding != nil { + result.CredentialBinding = &policyv1.NetworkCredentialBinding{Provider: binding.GetProvider()} + } + if operations := endpoint.GetGraphqlPersistedQueries(); len(operations) > 0 { + result.GraphqlPersistedQueries = make(map[string]*policyv1.GraphqlOperation, len(operations)) + for name, operation := range operations { + if operation != nil { + result.GraphqlPersistedQueries[name] = &policyv1.GraphqlOperation{ + OperationType: operation.GetOperationType(), + OperationName: operation.GetOperationName(), + Fields: CopyStringSlice(operation.GetFields()), + } + } + } + } + if rules := endpoint.GetRules(); len(rules) > 0 { + result.Rules = make([]*policyv1.L7Rule, 0, len(rules)) + for _, rule := range rules { + if rule != nil { + result.Rules = append(result.Rules, publicL7RuleFromInternalProto(protocol, endpoint.GetMcp(), rule)) + } + } + } + if rules := endpoint.GetDenyRules(); len(rules) > 0 { + result.DenyRules = make([]*policyv1.L7DenyRule, 0, len(rules)) + for _, rule := range rules { + if rule != nil { + result.DenyRules = append(result.DenyRules, publicL7DenyRuleFromInternalProto(protocol, endpoint.GetMcp(), rule)) + } + } + } + if strings.EqualFold(protocol, "mcp") { + if options := endpoint.GetMcp(); options != nil || endpoint.GetJsonRpcMaxBodyBytes() != 0 { + result.Mcp = &policyv1.McpConfig{MaxBodyBytes: endpoint.GetJsonRpcMaxBodyBytes()} + if options != nil { + result.Mcp.Versions = CopyStringSlice(options.GetVersions()) + result.Mcp.StrictToolNames = CopyBoolPtr(options.StrictToolNames) + result.Mcp.AllowAllKnownMcpMethods = CopyBoolPtr(options.AllowAllKnownMcpMethods) + } + } + } else if endpoint.GetJsonRpcMaxBodyBytes() != 0 { + result.JsonRpc = &policyv1.JsonRpcConfig{MaxBodyBytes: endpoint.GetJsonRpcMaxBodyBytes()} + } + return result +} + +func publicL7RuleFromInternalProto(protocol string, options *sbv1.McpOptions, rule *sbv1.L7Rule) *policyv1.L7Rule { + result := &policyv1.L7Rule{} + if allow := rule.GetAllow(); allow != nil { + tool, params := publicParamsFromInternalProto(protocol, allow.GetParams()) + result.Allow = &policyv1.L7Allow{ + Method: publicMCPMethod(protocol, options, allow.GetMethod(), tool != nil), + Path: allow.GetPath(), + Command: allow.GetCommand(), + Query: publicMatcherMapFromInternalProto(allow.GetQuery()), + OperationType: allow.GetOperationType(), + OperationName: allow.GetOperationName(), + Fields: CopyStringSlice(allow.GetFields()), + Tool: tool, + Params: params, + } + } + return result +} + +func publicL7DenyRuleFromInternalProto(protocol string, options *sbv1.McpOptions, rule *sbv1.L7DenyRule) *policyv1.L7DenyRule { + tool, params := publicParamsFromInternalProto(protocol, rule.GetParams()) + return &policyv1.L7DenyRule{ + Method: publicMCPMethod(protocol, options, rule.GetMethod(), tool != nil), + Path: rule.GetPath(), + Command: rule.GetCommand(), + Query: publicMatcherMapFromInternalProto(rule.GetQuery()), + OperationType: rule.GetOperationType(), + OperationName: rule.GetOperationName(), + Fields: CopyStringSlice(rule.GetFields()), + Tool: tool, + Params: params, + } +} + +func publicMCPMethod(protocol string, options *sbv1.McpOptions, method string, hasTool bool) string { + if !strings.EqualFold(protocol, "mcp") { + return method + } + if !hasTool && method == "*" { + return "" + } + if hasTool && method == "tools/call" && options != nil && options.GetAllowAllKnownMcpMethods() { + return "" + } + return method +} + +func publicParamsFromInternalProto(protocol string, params map[string]*sbv1.L7QueryMatcher) (*policyv1.Matcher, map[string]*policyv1.ParameterMatcher) { + if len(params) == 0 { + return nil, nil + } + remaining := params + var tool *policyv1.Matcher + if strings.EqualFold(protocol, "mcp") { + remaining = make(map[string]*sbv1.L7QueryMatcher, len(params)) + for name, matcher := range params { + if name == "name" { + tool = publicMatcherFromInternalProto(matcher) + } else { + remaining[name] = matcher + } + } + if nested, ok := publicNestedParamsFromInternalProto(remaining); ok { + return tool, nested + } + } + return tool, publicFlatParamsFromInternalProto(remaining) +} + +func publicNestedParamsFromInternalProto(params map[string]*sbv1.L7QueryMatcher) (map[string]*policyv1.ParameterMatcher, bool) { + if len(params) == 0 { + return nil, true + } + result := make(map[string]*policyv1.ParameterMatcher, len(params)) + for name, matcher := range params { + parts := strings.Split(name, ".") + if len(parts) == 0 { + return nil, false + } + current := result + for index, part := range parts { + if part == "" { + return nil, false + } + last := index == len(parts)-1 + existing, found := current[part] + if last { + if found { + return nil, false + } + current[part] = &policyv1.ParameterMatcher{Kind: &policyv1.ParameterMatcher_Matcher{ + Matcher: publicMatcherFromInternalProto(matcher), + }} + continue + } + if !found { + existing = &policyv1.ParameterMatcher{Kind: &policyv1.ParameterMatcher_Object{ + Object: &policyv1.ParameterObject{Fields: make(map[string]*policyv1.ParameterMatcher)}, + }} + current[part] = existing + } + object := existing.GetObject() + if object == nil { + return nil, false + } + current = object.Fields + } + } + return result, true +} + +func publicFlatParamsFromInternalProto(params map[string]*sbv1.L7QueryMatcher) map[string]*policyv1.ParameterMatcher { + if len(params) == 0 { + return nil + } + result := make(map[string]*policyv1.ParameterMatcher, len(params)) + for name, matcher := range params { + result[name] = &policyv1.ParameterMatcher{Kind: &policyv1.ParameterMatcher_Matcher{ + Matcher: publicMatcherFromInternalProto(matcher), + }} + } + return result +} + +func publicMatcherMapFromInternalProto(matchers map[string]*sbv1.L7QueryMatcher) map[string]*policyv1.Matcher { + if len(matchers) == 0 { + return nil + } + result := make(map[string]*policyv1.Matcher, len(matchers)) + for name, matcher := range matchers { + result[name] = publicMatcherFromInternalProto(matcher) + } + return result +} + +func publicMatcherFromInternalProto(matcher *sbv1.L7QueryMatcher) *policyv1.Matcher { + if matcher != nil && len(matcher.GetAny()) > 0 { + return &policyv1.Matcher{Kind: &policyv1.Matcher_Any{Any: &policyv1.AnyMatcher{ + Values: CopyStringSlice(matcher.GetAny()), + }}} + } + glob := "" + if matcher != nil { + glob = matcher.GetGlob() + } + return &policyv1.Matcher{Kind: &policyv1.Matcher_Glob{Glob: glob}} +} + +func publicTLSMode(mode sbv1.NetworkTlsMode) string { + // Numeric cases keep the explicit projection compatible with deprecated + // internal enum values without making new SDK code depend on their names. + switch int32(mode) { + case 1: + return "skip" + case 2: + return "terminate" + case 3: + return "passthrough" + default: + return "" + } +} + +func publicEnforcementMode(mode sbv1.NetworkEnforcementMode) string { + switch mode { + case sbv1.NetworkEnforcementMode_NETWORK_ENFORCEMENT_MODE_ENFORCE: + return "enforce" + case sbv1.NetworkEnforcementMode_NETWORK_ENFORCEMENT_MODE_AUDIT: + return "audit" + default: + return "" + } +} + +func publicAccessPreset(preset sbv1.NetworkAccessPreset) string { + switch preset { + case sbv1.NetworkAccessPreset_NETWORK_ACCESS_PRESET_READ_ONLY: + return "read-only" + case sbv1.NetworkAccessPreset_NETWORK_ACCESS_PRESET_READ_WRITE: + return "read-write" + case sbv1.NetworkAccessPreset_NETWORK_ACCESS_PRESET_FULL: + return "full" + default: + return "" + } +} + +// PolicyDocumentToProto converts an SDK PolicyDocument to a proto PolicyDocument. // Returns nil for nil input. All slice and map fields are deep-copied. -func SandboxPolicyToProto(p *types.SandboxPolicy) *policyv1.SandboxPolicy { +func PolicyDocumentToProto(p *types.PolicyDocument) *policyv1.PolicyDocument { if p == nil { return nil } - result := &policyv1.SandboxPolicy{ + result := &policyv1.PolicyDocument{ Version: p.Version, FilesystemPolicy: filesystemPolicyToProto(p.Filesystem), Landlock: landlockPolicyToProto(p.Landlock), @@ -184,10 +511,10 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *policyv1.SandboxPolicy { return result } -// SandboxPolicyToProtoChecked converts middleware configuration without +// PolicyDocumentToProtoChecked converts middleware configuration without // silently discarding values unsupported by protobuf Struct. -func SandboxPolicyToProtoChecked(p *types.SandboxPolicy) (*policyv1.SandboxPolicy, error) { - result := SandboxPolicyToProto(p) +func PolicyDocumentToProtoChecked(p *types.PolicyDocument) (*policyv1.PolicyDocument, error) { + result := PolicyDocumentToProto(p) if p == nil { return result, nil } @@ -201,6 +528,9 @@ func SandboxPolicyToProtoChecked(p *types.SandboxPolicy) (*policyv1.SandboxPolic } result.NetworkMiddlewares[name].Config = config } + if err := protovalidate.Validate(result); err != nil { + return nil, fmt.Errorf("policy document validation: %w", err) + } return result, nil } @@ -321,7 +651,7 @@ func SandboxPolicyRevisionFromProto(r *pb.SandboxPolicyRevision) *types.SandboxP LoadError: r.GetLoadError(), CreatedAt: TimeFromProto(r.GetCreatedTime()), LoadedAt: TimeFromProto(r.GetLoadedTime()), - Policy: SandboxPolicyFromProto(r.GetPolicy()), + Policy: PolicyDocumentFromProto(r.GetPolicy()), Provenance: CopyStringMap(r.GetProvenance()), } } diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go index 8d0a650ab..351e26be0 100644 --- a/sdk/go/openshell/v1/internal/converter/policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -4,16 +4,85 @@ package converter import ( + "errors" "testing" + "buf.build/go/protovalidate" v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" ) +func TestPolicyDocumentValidationRuleIDs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy *policyv1.PolicyDocument + ruleID string + }{ + {name: "version", policy: &policyv1.PolicyDocument{}, ruleID: "uint32.const"}, + { + name: "missing ports", + policy: &policyv1.PolicyDocument{Version: 1, NetworkPolicies: map[string]*policyv1.NetworkPolicyRule{ + "api": {Endpoints: []*policyv1.NetworkEndpoint{{Host: "api.example.com"}}}, + }}, + ruleID: "repeated.min_items", + }, + { + name: "duplicate ports", + policy: &policyv1.PolicyDocument{Version: 1, NetworkPolicies: map[string]*policyv1.NetworkPolicyRule{ + "api": {Endpoints: []*policyv1.NetworkEndpoint{{Host: "api.example.com", Ports: []uint32{443, 443}}}}, + }}, + ruleID: "repeated.unique", + }, + { + name: "port range", + policy: &policyv1.PolicyDocument{Version: 1, NetworkPolicies: map[string]*policyv1.NetworkPolicyRule{ + "api": {Endpoints: []*policyv1.NetworkEndpoint{{Host: "api.example.com", Ports: []uint32{65536}}}}, + }}, + ruleID: "uint32.gte_lte", + }, + { + name: "binary path", + policy: &policyv1.PolicyDocument{Version: 1, NetworkPolicies: map[string]*policyv1.NetworkPolicyRule{ + "api": {Binaries: []*policyv1.NetworkBinary{{}}}, + }}, + ruleID: "string.min_len", + }, + { + name: "matcher choice", + policy: &policyv1.PolicyDocument{Version: 1, NetworkPolicies: map[string]*policyv1.NetworkPolicyRule{ + "api": {Endpoints: []*policyv1.NetworkEndpoint{{ + Host: "api.example.com", Ports: []uint32{443}, Rules: []*policyv1.L7Rule{{ + Allow: &policyv1.L7Allow{Query: map[string]*policyv1.Matcher{"owner": {}}}, + }}, + }}}, + }}, + ruleID: "required", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := protovalidate.Validate(test.policy) + require.Error(t, err) + var validationErr *protovalidate.ValidationError + require.True(t, errors.As(err, &validationErr), "unexpected error: %v", err) + for _, violation := range validationErr.Violations { + if violation.Proto.GetRuleId() == test.ruleID { + return + } + } + t.Fatalf("expected rule ID %q in %v", test.ruleID, err) + }) + } +} + // --- PolicyLoadStatus --- func TestPolicyLoadStatusFromProto(t *testing.T) { @@ -88,7 +157,7 @@ func TestPolicyChunkFromProto(t *testing.T) { ProposedRule: &policyv1.NetworkPolicyRule{ Name: "web-api", Endpoints: []*policyv1.NetworkEndpoint{ - {Host: "api.example.com", Port: 443, Protocol: "rest"}, + {Host: "api.example.com", Ports: []uint32{443}, Protocol: "rest"}, }, }, } @@ -175,18 +244,157 @@ func TestDraftPolicyFromProto_EmptyChunks(t *testing.T) { assert.Empty(t, draft.Chunks) } -// --- SandboxPolicy --- +// --- PolicyDocument --- -func TestSandboxPolicyFromProtoNil(t *testing.T) { - assert.Nil(t, SandboxPolicyFromProto(nil)) +func TestPolicyDocumentFromProtoNil(t *testing.T) { + assert.Nil(t, PolicyDocumentFromProto(nil)) } -func TestSandboxPolicyToProtoNil(t *testing.T) { - assert.Nil(t, SandboxPolicyToProto(nil)) +func TestPolicyDocumentToProtoNil(t *testing.T) { + assert.Nil(t, PolicyDocumentToProto(nil)) +} + +func TestPolicyDocumentFromInternalProtoExplicitProjection(t *testing.T) { + strictToolNames := false + allowAllMethods := true + middlewareConfig, err := structpb.NewStruct(map[string]any{"model": "guard-v1"}) + require.NoError(t, err) + + internal := &sbv1.SandboxPolicy{ + Version: 1, + Filesystem: &sbv1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/usr"}, + ReadWrite: []string{"/sandbox"}, + }, + Landlock: &sbv1.LandlockPolicy{Compatibility: "hard_requirement"}, + Process: &sbv1.ProcessPolicy{RunAsUser: "sandbox", RunAsGroup: "sandbox"}, + NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{ + "api": { + Name: "api", + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/curl"}, + }, + Endpoints: []*sbv1.NetworkEndpoint{ + { + Host: "mcp.example.com", + Port: 443, + Protocol: "mcp", + Tls: sbv1.NetworkTlsMode_NETWORK_TLS_MODE_SKIP, + Enforcement: sbv1.NetworkEnforcementMode_NETWORK_ENFORCEMENT_MODE_ENFORCE, + Access: sbv1.NetworkAccessPreset_NETWORK_ACCESS_PRESET_FULL, + Rules: []*sbv1.L7Rule{{Allow: &sbv1.L7Allow{ + Method: "tools/call", + Query: map[string]*sbv1.L7QueryMatcher{ + "tenant": {Glob: "team-*"}, + }, + Params: map[string]*sbv1.L7QueryMatcher{ + "name": {Glob: "weather.*"}, + "arguments.limit": {Any: []string{"10", "20"}}, + }, + }}}, + DenyRules: []*sbv1.L7DenyRule{{ + Method: "tools/call", + Params: map[string]*sbv1.L7QueryMatcher{ + "name": {Any: []string{"admin.delete", "admin.reset"}}, + }, + }}, + JsonRpcMaxBodyBytes: 12345, + Mcp: &sbv1.McpOptions{ + StrictToolNames: &strictToolNames, + AllowAllKnownMcpMethods: &allowAllMethods, + Versions: []string{"2025-11-25"}, + }, + AllowUninspectedCredentials: true, + AdvisorProposed: true, + ProviderCredentialed: true, + }, + { + Host: "rpc.example.com", + Ports: []uint32{8443, 9443}, + Protocol: "json-rpc", + JsonRpcMaxBodyBytes: 67890, + CredentialBinding: &sbv1.NetworkCredentialBinding{Provider: "static-provider"}, + GraphqlPersistedQueries: map[string]*sbv1.GraphqlOperation{"hash": {OperationType: "query", Fields: []string{"viewer"}}}, + }, + }, + }, + }, + NetworkMiddlewares: map[string]*sbv1.NetworkMiddlewareConfig{ + "guard": { + Name: "guard", + Middleware: "content_guard", + Config: middlewareConfig, + OnError: "fail_closed", + Order: 10, + Endpoints: &sbv1.MiddlewareEndpointSelector{Include: []string{"*.example.com"}}, + }, + }, + } + + policy := PolicyDocumentFromInternalProto(internal) + require.NotNil(t, policy) + assert.Equal(t, uint32(1), policy.Version) + require.NotNil(t, policy.Filesystem) + assert.Equal(t, []string{"/usr"}, policy.Filesystem.ReadOnly) + require.NotNil(t, policy.Landlock) + assert.Equal(t, "hard_requirement", policy.Landlock.Compatibility) + require.NotNil(t, policy.Process) + assert.Equal(t, "sandbox", policy.Process.RunAsUser) + + rule := policy.NetworkPolicies["api"] + assert.Equal(t, []v1.PolicyNetworkBinary{{Path: "/usr/bin/curl"}}, rule.Binaries) + require.Len(t, rule.Endpoints, 2) + mcp := rule.Endpoints[0] + assert.Equal(t, []uint32{443}, mcp.Ports, "legacy internal scalar port must become the public ports list") + assert.Equal(t, v1.NetworkTLSModeSkip, mcp.TLS) + assert.Equal(t, v1.NetworkEnforcementModeEnforce, mcp.Enforcement) + assert.Equal(t, v1.NetworkAccessPresetFull, mcp.Access) + assert.True(t, mcp.AllowUninspectedCredentials) + require.NotNil(t, mcp.Mcp) + assert.Equal(t, uint32(12345), mcp.Mcp.MaxBodyBytes) + assert.Equal(t, []string{"2025-11-25"}, mcp.Mcp.Versions) + require.NotNil(t, mcp.Mcp.StrictToolNames) + assert.False(t, *mcp.Mcp.StrictToolNames) + require.NotNil(t, mcp.Mcp.AllowAllKnownMcpMethods) + assert.True(t, *mcp.Mcp.AllowAllKnownMcpMethods) + assert.Zero(t, mcp.JSONRPCMaxBodyBytes, "the shared internal body limit belongs to the MCP stanza") + require.Len(t, mcp.Rules, 1) + require.NotNil(t, mcp.Rules[0].Allow) + assert.Empty(t, mcp.Rules[0].Allow.Method, "canonical MCP tool rules omit the implied method") + require.NotNil(t, mcp.Rules[0].Allow.Tool) + assert.Equal(t, "weather.*", mcp.Rules[0].Allow.Tool.Glob) + require.NotNil(t, mcp.Rules[0].Allow.Params["arguments"].Object) + require.NotNil(t, mcp.Rules[0].Allow.Params["arguments"].Object["limit"].Matcher) + assert.Equal(t, []string{"10", "20"}, mcp.Rules[0].Allow.Params["arguments"].Object["limit"].Matcher.Any) + require.Len(t, mcp.DenyRules, 1) + require.NotNil(t, mcp.DenyRules[0].Tool) + assert.Equal(t, []string{"admin.delete", "admin.reset"}, mcp.DenyRules[0].Tool.Any) + + rpc := rule.Endpoints[1] + assert.Equal(t, []uint32{8443, 9443}, rpc.Ports) + assert.Equal(t, uint32(67890), rpc.JSONRPCMaxBodyBytes) + assert.Nil(t, rpc.Mcp) + require.NotNil(t, rpc.CredentialBinding) + assert.Equal(t, "static-provider", rpc.CredentialBinding.Provider) + assert.Equal(t, []string{"viewer"}, rpc.GraphqlPersistedQueries["hash"].Fields) + + middleware := policy.NetworkMiddlewares["guard"] + assert.Equal(t, "guard-v1", middleware.Config["model"]) + require.NotNil(t, middleware.Endpoints) + assert.Equal(t, []string{"*.example.com"}, middleware.Endpoints.Include) + + // The public SDK result must not alias the supervisor response. + internal.Filesystem.ReadOnly[0] = "/mutated" + internal.NetworkPolicies["api"].Endpoints[0].Port = 80 + internal.NetworkPolicies["api"].Endpoints[0].Mcp.Versions[0] = "mutated" + assert.Equal(t, "/usr", policy.Filesystem.ReadOnly[0]) + assert.Equal(t, []uint32{443}, policy.NetworkPolicies["api"].Endpoints[0].Ports) + assert.Equal(t, []string{"2025-11-25"}, policy.NetworkPolicies["api"].Endpoints[0].Mcp.Versions) } func TestSandboxPolicyRoundTrip(t *testing.T) { - original := &v1.SandboxPolicy{ + original := &v1.PolicyDocument{ Version: 5, Filesystem: &v1.FilesystemPolicy{ IncludeWorkdir: true, @@ -204,22 +412,22 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { "web-api": { Name: "web-api", Endpoints: []v1.PolicyNetworkEndpoint{ - {Host: "api.example.com", Port: 443, Protocol: "rest"}, + {Host: "api.example.com", Ports: []uint32{443}, Protocol: "rest"}, }, }, "db": { Name: "db", Endpoints: []v1.PolicyNetworkEndpoint{ - {Host: "db.internal", Port: 5432, Protocol: "tcp"}, + {Host: "db.internal", Ports: []uint32{5432}, Protocol: "tcp"}, }, }, }, } - proto := SandboxPolicyToProto(original) + proto := PolicyDocumentToProto(original) require.NotNil(t, proto) - roundTrip := SandboxPolicyFromProto(proto) + roundTrip := PolicyDocumentFromProto(proto) require.NotNil(t, roundTrip) assert.Equal(t, original.Version, roundTrip.Version) @@ -254,7 +462,7 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { func TestSandboxPolicyDeepCopy(t *testing.T) { // Build a proto, convert to SDK, mutate proto, verify SDK is isolated. - proto := &policyv1.SandboxPolicy{ + proto := &policyv1.PolicyDocument{ Version: 1, FilesystemPolicy: &policyv1.FilesystemPolicy{ IncludeWorkdir: true, @@ -265,13 +473,13 @@ func TestSandboxPolicyDeepCopy(t *testing.T) { "rule1": { Name: "rule1", Endpoints: []*policyv1.NetworkEndpoint{ - {Host: "original.host", Port: 80}, + {Host: "original.host", Ports: []uint32{80}}, }, }, }, } - sdk := SandboxPolicyFromProto(proto) + sdk := PolicyDocumentFromProto(proto) require.NotNil(t, sdk) // Mutate proto source after conversion. @@ -289,7 +497,7 @@ func TestSandboxPolicyDeepCopy(t *testing.T) { assert.Equal(t, "original.host", sdk.NetworkPolicies["rule1"].Endpoints[0].Host) // Also test ToProto deep-copy isolation. - protoOut := SandboxPolicyToProto(sdk) + protoOut := PolicyDocumentToProto(sdk) require.NotNil(t, protoOut) // Mutate SDK after ToProto conversion. @@ -301,13 +509,13 @@ func TestSandboxPolicyDeepCopy(t *testing.T) { func TestSandboxPolicyPartialSubPolicies(t *testing.T) { t.Run("only filesystem", func(t *testing.T) { - original := &v1.SandboxPolicy{ + original := &v1.PolicyDocument{ Version: 1, Filesystem: &v1.FilesystemPolicy{ ReadOnly: []string{"/etc"}, }, } - roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + roundTrip := PolicyDocumentFromProto(PolicyDocumentToProto(original)) require.NotNil(t, roundTrip) require.NotNil(t, roundTrip.Filesystem) assert.Nil(t, roundTrip.Landlock) @@ -316,13 +524,13 @@ func TestSandboxPolicyPartialSubPolicies(t *testing.T) { }) t.Run("only landlock", func(t *testing.T) { - original := &v1.SandboxPolicy{ + original := &v1.PolicyDocument{ Version: 2, Landlock: &v1.LandlockPolicy{ Compatibility: "hard_requirement", }, } - roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + roundTrip := PolicyDocumentFromProto(PolicyDocumentToProto(original)) require.NotNil(t, roundTrip) assert.Nil(t, roundTrip.Filesystem) require.NotNil(t, roundTrip.Landlock) @@ -332,12 +540,12 @@ func TestSandboxPolicyPartialSubPolicies(t *testing.T) { }) t.Run("only process", func(t *testing.T) { - original := &v1.SandboxPolicy{ + original := &v1.PolicyDocument{ Process: &v1.ProcessPolicy{ RunAsUser: "nobody", }, } - roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + roundTrip := PolicyDocumentFromProto(PolicyDocumentToProto(original)) require.NotNil(t, roundTrip) assert.Nil(t, roundTrip.Filesystem) assert.Nil(t, roundTrip.Landlock) @@ -346,12 +554,12 @@ func TestSandboxPolicyPartialSubPolicies(t *testing.T) { }) t.Run("only network policies", func(t *testing.T) { - original := &v1.SandboxPolicy{ + original := &v1.PolicyDocument{ NetworkPolicies: map[string]v1.NetworkPolicyRule{ "r1": {Name: "r1"}, }, } - roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + roundTrip := PolicyDocumentFromProto(PolicyDocumentToProto(original)) require.NotNil(t, roundTrip) assert.Nil(t, roundTrip.Filesystem) assert.Nil(t, roundTrip.Landlock) @@ -360,11 +568,11 @@ func TestSandboxPolicyPartialSubPolicies(t *testing.T) { }) t.Run("empty network policies map preserved", func(t *testing.T) { - proto := &policyv1.SandboxPolicy{ + proto := &policyv1.PolicyDocument{ NetworkPolicies: map[string]*policyv1.NetworkPolicyRule{}, } // Proto empty map is non-nil, so converter creates an empty SDK map. - sdk := SandboxPolicyFromProto(proto) + sdk := PolicyDocumentFromProto(proto) require.NotNil(t, sdk) require.NotNil(t, sdk.NetworkPolicies) assert.Empty(t, sdk.NetworkPolicies) @@ -471,7 +679,7 @@ func TestSandboxPolicyRevisionFromProto_WithPolicy(t *testing.T) { Version: 1, PolicyHash: "sha256:def", Status: pb.PolicyStatus_POLICY_STATUS_LOADED, - Policy: &policyv1.SandboxPolicy{ + Policy: &policyv1.PolicyDocument{ Version: 2, FilesystemPolicy: &policyv1.FilesystemPolicy{ ReadOnly: []string{"/etc"}, @@ -481,7 +689,7 @@ func TestSandboxPolicyRevisionFromProto_WithPolicy(t *testing.T) { rev := SandboxPolicyRevisionFromProto(proto) require.NotNil(t, rev) - require.NotNil(t, rev.Policy, "typed SandboxPolicy should be populated when proto policy is set") + require.NotNil(t, rev.Policy, "typed PolicyDocument should be populated when proto policy is set") assert.Equal(t, uint32(2), rev.Policy.Version) require.NotNil(t, rev.Policy.Filesystem) assert.Equal(t, []string{"/etc"}, rev.Policy.Filesystem.ReadOnly) @@ -615,8 +823,8 @@ func TestDraftHistoryEntryFromProto_Nil(t *testing.T) { // --- NetworkMiddleware --- -func TestSandboxPolicyFromProto_WithMiddleware(t *testing.T) { - proto := &policyv1.SandboxPolicy{ +func TestPolicyDocumentFromProto_WithMiddleware(t *testing.T) { + proto := &policyv1.PolicyDocument{ Version: 3, NetworkMiddlewares: map[string]*policyv1.NetworkMiddleware{ "sigv4-rewriter": { @@ -639,7 +847,7 @@ func TestSandboxPolicyFromProto_WithMiddleware(t *testing.T) { }, } - policy := SandboxPolicyFromProto(proto) + policy := PolicyDocumentFromProto(proto) require.NotNil(t, policy) require.Contains(t, policy.NetworkMiddlewares, "sigv4-rewriter") @@ -657,7 +865,7 @@ func TestSandboxPolicyFromProto_WithMiddleware(t *testing.T) { } func TestSandboxPolicyMiddlewareRoundTrip(t *testing.T) { - original := &v1.SandboxPolicy{ + original := &v1.PolicyDocument{ Version: 5, NetworkMiddlewares: map[string]v1.NetworkMiddlewareConfig{ "rate-limiter": { @@ -675,10 +883,10 @@ func TestSandboxPolicyMiddlewareRoundTrip(t *testing.T) { }, } - proto := SandboxPolicyToProto(original) + proto := PolicyDocumentToProto(original) require.NotNil(t, proto) - roundTrip := SandboxPolicyFromProto(proto) + roundTrip := PolicyDocumentFromProto(proto) require.NotNil(t, roundTrip) require.Contains(t, roundTrip.NetworkMiddlewares, "rate-limiter") @@ -692,7 +900,7 @@ func TestSandboxPolicyMiddlewareRoundTrip(t *testing.T) { } func TestSandboxPolicyMiddlewareDeepCopy(t *testing.T) { - proto := &policyv1.SandboxPolicy{ + proto := &policyv1.PolicyDocument{ NetworkMiddlewares: map[string]*policyv1.NetworkMiddleware{ "test": { Endpoints: &policyv1.MiddlewareEndpointSelector{ @@ -702,7 +910,7 @@ func TestSandboxPolicyMiddlewareDeepCopy(t *testing.T) { }, } - policy := SandboxPolicyFromProto(proto) + policy := PolicyDocumentFromProto(proto) proto.NetworkMiddlewares["test"].Endpoints.Include[0] = "mutated.com" assert.Equal(t, "original.com", policy.NetworkMiddlewares["test"].Endpoints.Include[0]) diff --git a/sdk/go/openshell/v1/internal/converter/profile.go b/sdk/go/openshell/v1/internal/converter/profile.go index bc6b850c2..c39c76be7 100644 --- a/sdk/go/openshell/v1/internal/converter/profile.go +++ b/sdk/go/openshell/v1/internal/converter/profile.go @@ -83,16 +83,19 @@ func CredentialTokenGrantTypeToProto(t types.CredentialTokenGrantType) pb.Provid // --- NetworkEndpoint --- // NetworkEndpointFromProto converts a proto NetworkEndpoint to an SDK NetworkEndpoint. -// Only Host, Port, and Protocol are mapped; additional proto fields are ignored. +// Only Host, Ports, and Protocol are mapped; additional proto fields are ignored. func NetworkEndpointFromProto(ep *policyv1.NetworkEndpoint) *types.NetworkEndpoint { if ep == nil { return nil } - return &types.NetworkEndpoint{ + result := &types.NetworkEndpoint{ Host: ep.GetHost(), - Port: ep.GetPort(), Protocol: ep.GetProtocol(), } + if ports := ep.GetPorts(); len(ports) > 0 { + result.Ports = append([]uint32(nil), ports...) + } + return result } // NetworkEndpointToProto converts an SDK NetworkEndpoint to a proto NetworkEndpoint. @@ -102,7 +105,7 @@ func NetworkEndpointToProto(ep *types.NetworkEndpoint) *policyv1.NetworkEndpoint } return &policyv1.NetworkEndpoint{ Host: ep.Host, - Port: ep.Port, + Ports: append([]uint32(nil), ep.Ports...), Protocol: ep.Protocol, } } diff --git a/sdk/go/openshell/v1/internal/converter/profile_test.go b/sdk/go/openshell/v1/internal/converter/profile_test.go index 9f08fe369..8c3fa6a8b 100644 --- a/sdk/go/openshell/v1/internal/converter/profile_test.go +++ b/sdk/go/openshell/v1/internal/converter/profile_test.go @@ -64,7 +64,7 @@ func TestProfileCategoryToProto(t *testing.T) { func TestNetworkEndpointFromProto(t *testing.T) { proto := &policyv1.NetworkEndpoint{ Host: "api.example.com", - Port: 443, + Ports: []uint32{443}, Protocol: "rest", } @@ -72,7 +72,7 @@ func TestNetworkEndpointFromProto(t *testing.T) { require.NotNil(t, ep) assert.Equal(t, "api.example.com", ep.Host) - assert.Equal(t, uint32(443), ep.Port) + assert.Equal(t, []uint32{443}, ep.Ports) assert.Equal(t, "rest", ep.Protocol) } @@ -84,7 +84,7 @@ func TestNetworkEndpointFromProto_Nil(t *testing.T) { func TestNetworkEndpointToProto(t *testing.T) { ep := &v1.NetworkEndpoint{ Host: "api.example.com", - Port: 443, + Ports: []uint32{443}, Protocol: "rest", } @@ -92,7 +92,7 @@ func TestNetworkEndpointToProto(t *testing.T) { require.NotNil(t, proto) assert.Equal(t, "api.example.com", proto.Host) - assert.Equal(t, uint32(443), proto.Port) + assert.Equal(t, []uint32{443}, proto.Ports) assert.Equal(t, "rest", proto.Protocol) } @@ -471,7 +471,7 @@ func TestProviderProfileFromProto(t *testing.T) { {Name: "API_KEY", Description: "key", Required: true}, }, Endpoints: []*policyv1.NetworkEndpoint{ - {Host: "api.anthropic.com", Port: 443, Protocol: "rest"}, + {Host: "api.anthropic.com", Ports: []uint32{443}, Protocol: "rest"}, }, Binaries: []*policyv1.NetworkBinary{ {Path: "/usr/bin/claude"}, @@ -505,7 +505,7 @@ func TestProviderProfileFromProto(t *testing.T) { require.Len(t, profile.Endpoints, 1) assert.Equal(t, "api.anthropic.com", profile.Endpoints[0].Host) - assert.Equal(t, uint32(443), profile.Endpoints[0].Port) + assert.Equal(t, []uint32{443}, profile.Endpoints[0].Ports) require.Len(t, profile.Binaries, 1) assert.Equal(t, "/usr/bin/claude", profile.Binaries[0].Path) @@ -542,7 +542,7 @@ func TestProviderProfileToProto(t *testing.T) { {Name: "API_KEY", Description: "key", Required: true, Secret: true}, }, Endpoints: []v1.NetworkEndpoint{ - {Host: "api.anthropic.com", Port: 443, Protocol: "rest"}, + {Host: "api.anthropic.com", Ports: []uint32{443}, Protocol: "rest"}, }, Binaries: []v1.NetworkBinary{ {Path: "/usr/bin/claude"}, @@ -671,7 +671,7 @@ func TestProviderProfileRoundTrip(t *testing.T) { }, }, Endpoints: []v1.NetworkEndpoint{ - {Host: "agent.example.com", Port: 8080, Protocol: "websocket"}, + {Host: "agent.example.com", Ports: []uint32{8080}, Protocol: "websocket"}, }, Binaries: []v1.NetworkBinary{ {Path: "/bin/agent"}, @@ -721,7 +721,7 @@ func TestProviderProfileRoundTrip(t *testing.T) { require.Len(t, back.Endpoints, 1) assert.Equal(t, original.Endpoints[0].Host, back.Endpoints[0].Host) - assert.Equal(t, original.Endpoints[0].Port, back.Endpoints[0].Port) + assert.Equal(t, original.Endpoints[0].Ports, back.Endpoints[0].Ports) require.Len(t, back.Binaries, 1) assert.Equal(t, original.Binaries[0].Path, back.Binaries[0].Path) diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go index 1f773c100..e8e42b84e 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -59,7 +59,7 @@ func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec { LogLevel: spec.GetLogLevel(), Environment: CopyStringMap(spec.GetEnvironment()), Providers: CopyStringSlice(spec.GetProviders()), - Policy: SandboxPolicyFromProto(spec.GetPolicy()), + Policy: PolicyDocumentFromProto(spec.GetPolicy()), } if tmpl := spec.GetTemplate(); tmpl != nil { @@ -252,7 +252,7 @@ func SandboxSpecToProto(spec *types.SandboxSpec) *pb.SandboxSpec { LogLevel: spec.LogLevel, Environment: CopyStringMap(spec.Environment), Providers: CopyStringSlice(spec.Providers), - Policy: SandboxPolicyToProto(spec.Policy), + Policy: PolicyDocumentToProto(spec.Policy), } if spec.Template != nil { @@ -303,7 +303,7 @@ func SandboxSpecToProtoChecked(spec *types.SandboxSpec) (*pb.SandboxSpec, error) if spec == nil { return result, nil } - policy, err := SandboxPolicyToProtoChecked(spec.Policy) + policy, err := PolicyDocumentToProtoChecked(spec.Policy) if err != nil { return nil, fmt.Errorf("policy: %w", err) } diff --git a/sdk/go/openshell/v1/internal/converter/sandbox_test.go b/sdk/go/openshell/v1/internal/converter/sandbox_test.go index 0d751159d..0045a2757 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox_test.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -588,7 +588,7 @@ func TestSandboxRoundTrip(t *testing.T) { }, Providers: []string{"p1", "p2"}, GPUCount: &gpuCount, - Policy: &v1.SandboxPolicy{ + Policy: &v1.PolicyDocument{ Version: 3, Filesystem: &v1.FilesystemPolicy{ IncludeWorkdir: true, @@ -608,7 +608,7 @@ func TestSandboxRoundTrip(t *testing.T) { Endpoints: []v1.PolicyNetworkEndpoint{ { Host: "api.example.com", - Port: 443, + Ports: []uint32{443}, Protocol: "rest", CredentialBinding: &v1.NetworkCredentialBinding{ Provider: "api-credentials", @@ -710,7 +710,7 @@ func TestSandboxSpecToProto(t *testing.T) { }, Providers: []string{"prov"}, GPUCount: &gpuCount, - Policy: &v1.SandboxPolicy{ + Policy: &v1.PolicyDocument{ Version: 2, Filesystem: &v1.FilesystemPolicy{ ReadOnly: []string{"/etc"}, diff --git a/sdk/go/openshell/v1/internal/converter/setting.go b/sdk/go/openshell/v1/internal/converter/setting.go index 74824bfa4..c96292e11 100644 --- a/sdk/go/openshell/v1/internal/converter/setting.go +++ b/sdk/go/openshell/v1/internal/converter/setting.go @@ -7,10 +7,12 @@ import ( "fmt" "slices" + "buf.build/go/protovalidate" v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "google.golang.org/protobuf/reflect/protoreflect" ) // --- SettingValue oneof conversion --- @@ -134,8 +136,8 @@ func SandboxConfigFromProto(resp *sbv1.GetSandboxConfigResponse) *v1.SandboxConf PolicyValidationFailureMode: resp.GetPolicyValidationFailureMode(), } - // Convert proto SandboxPolicy to typed SDK SandboxPolicy. - sc.Policy = SandboxPolicyFromInternalProto(resp.GetPolicy()) + // Project the supervisor's internal effective policy onto the public SDK model. + sc.Policy = PolicyDocumentFromInternalProto(resp.GetPolicy()) // Deep-copy settings map. if m := resp.GetSettings(); len(m) > 0 { @@ -193,8 +195,8 @@ func ConfigUpdateToProto(cu *v1.ConfigUpdate) (*pb.UpdateConfigRequest, error) { req.Sandbox = cu.Name } - // Convert typed SDK SandboxPolicy to proto SandboxPolicy. - policy, err := SandboxPolicyToProtoChecked(cu.Policy) + // Convert typed SDK PolicyDocument to proto PolicyDocument. + policy, err := PolicyDocumentToProtoChecked(cu.Policy) if err != nil { return nil, err } @@ -288,9 +290,63 @@ func PolicyMergeOperationToProto(op *v1.PolicyMergeOperation) (*pb.PolicyMergeOp }, } } + if err := validatePolicyMergeOperationContract(pmo); err != nil { + return nil, err + } return pmo, nil } +func validatePolicyMergeOperationContract(operation *pb.PolicyMergeOperation) error { + validate := func(name string, message interface{ ProtoReflect() protoreflect.Message }) error { + if err := protovalidate.Validate(message); err != nil { + return fmt.Errorf("%s validation: %w", name, err) + } + return nil + } + + switch op := operation.GetOperation().(type) { + case *pb.PolicyMergeOperation_AddRule: + document := &policyv1.PolicyDocument{ + Version: 1, + NetworkPolicies: map[string]*policyv1.NetworkPolicyRule{ + op.AddRule.GetRuleName(): op.AddRule.GetRule(), + }, + } + return validate("add rule", document) + case *pb.PolicyMergeOperation_AddDenyRules: + for index, rule := range op.AddDenyRules.GetDenyRules() { + if err := validate(fmt.Sprintf("deny rule [%d]", index), rule); err != nil { + return err + } + } + return validateL7TargetBinaries(op.AddDenyRules.GetTarget(), validate) + case *pb.PolicyMergeOperation_AddAllowRules: + for index, rule := range op.AddAllowRules.GetRules() { + if err := validate(fmt.Sprintf("allow rule [%d]", index), rule); err != nil { + return err + } + } + return validateL7TargetBinaries(op.AddAllowRules.GetTarget(), validate) + default: + return nil + } +} + +func validateL7TargetBinaries( + target *pb.L7RuleTarget, + validate func(string, interface{ ProtoReflect() protoreflect.Message }) error, +) error { + if target == nil { + return nil + } + for index, binary := range target.GetBinaries() { + if err := validate(fmt.Sprintf("target binary [%d]", index), binary); err != nil { + return err + } + } + return nil +} + // l7RuleTargetToProto preserves the caller's declaration without inferring scope. // The gateway rejects missing or mismatched scope against the current policy. func l7RuleTargetToProto(target *v1.L7RuleTarget) *pb.L7RuleTarget { diff --git a/sdk/go/openshell/v1/internal/converter/setting_test.go b/sdk/go/openshell/v1/internal/converter/setting_test.go index 0da41be05..5afc3afeb 100644 --- a/sdk/go/openshell/v1/internal/converter/setting_test.go +++ b/sdk/go/openshell/v1/internal/converter/setting_test.go @@ -279,7 +279,7 @@ func TestSandboxConfigFromProto(t *testing.T) { sc := SandboxConfigFromProto(resp) require.NotNil(t, sc) - require.NotNil(t, sc.Policy, "typed SandboxPolicy must be populated") + require.NotNil(t, sc.Policy, "typed PolicyDocument must be populated") assert.Equal(t, uint32(7), sc.Policy.Version) require.NotNil(t, sc.Policy.Filesystem) assert.Equal(t, []string{"/etc"}, sc.Policy.Filesystem.ReadOnly) @@ -447,8 +447,8 @@ func TestConfigUpdateToProto(t *testing.T) { func TestConfigUpdateToProto_WithPolicy(t *testing.T) { cu := &v1.ConfigUpdate{ Name: "sb-policy", - Policy: &v1.SandboxPolicy{ - Version: 3, + Policy: &v1.PolicyDocument{ + Version: 1, Filesystem: &v1.FilesystemPolicy{ ReadOnly: []string{"/etc"}, }, @@ -459,8 +459,8 @@ func TestConfigUpdateToProto_WithPolicy(t *testing.T) { require.NoError(t, err) require.NotNil(t, req) - require.NotNil(t, req.Policy, "typed SandboxPolicy must be converted to proto") - assert.Equal(t, uint32(3), req.Policy.GetVersion()) + require.NotNil(t, req.Policy, "typed PolicyDocument must be converted to proto") + assert.Equal(t, uint32(1), req.Policy.GetVersion()) require.NotNil(t, req.Policy.GetFilesystemPolicy()) assert.Equal(t, []string{"/etc"}, req.Policy.GetFilesystemPolicy().GetReadOnly()) } @@ -603,7 +603,7 @@ func TestPolicyMergeOperationToProto_AddRule(t *testing.T) { RuleName: "allow-api", Rule: v1.NetworkPolicyRule{ Name: "allow-api", Endpoints: []v1.PolicyNetworkEndpoint{ - {Host: "api.example.com", Port: 443, Protocol: "tcp"}, + {Host: "api.example.com", Ports: []uint32{443}, Protocol: "tcp"}, }, Binaries: []v1.PolicyNetworkBinary{ {Path: "/usr/bin/curl"}, @@ -623,7 +623,7 @@ func TestPolicyMergeOperationToProto_AddRule(t *testing.T) { assert.Equal(t, "allow-api", ar.GetRule().GetName()) require.Len(t, ar.GetRule().GetEndpoints(), 1) assert.Equal(t, "api.example.com", ar.GetRule().GetEndpoints()[0].GetHost()) - assert.Equal(t, uint32(443), ar.GetRule().GetEndpoints()[0].GetPort()) + assert.Equal(t, []uint32{443}, ar.GetRule().GetEndpoints()[0].GetPorts()) require.Len(t, ar.GetRule().GetBinaries(), 1) assert.Equal(t, "/usr/bin/curl", ar.GetRule().GetBinaries()[0].GetPath()) } @@ -746,6 +746,38 @@ func TestPolicyMergeOperationToProto_AddAllowRules(t *testing.T) { assert.Equal(t, "/health", aar.GetRules()[0].GetAllow().GetPath()) } +func TestPolicyMergeOperationToProto_ValidatesPublicPolicyFragments(t *testing.T) { + tests := map[string]*v1.PolicyMergeOperation{ + "missing allow": { + AddAllowRules: &v1.AddAllowRules{ + Target: &v1.L7RuleTarget{AnyBinary: true}, + Rules: []v1.L7Rule{{}}, + }, + }, + "empty matcher": { + AddDenyRules: &v1.AddDenyRules{ + Target: &v1.L7RuleTarget{AnyBinary: true}, + DenyRules: []v1.L7DenyRule{{ + Query: map[string]v1.L7QueryMatcher{"owner": {}}, + }}, + }, + }, + "empty target binary": { + AddAllowRules: &v1.AddAllowRules{ + Target: &v1.L7RuleTarget{Binaries: []v1.PolicyNetworkBinary{{}}}, + }, + }, + } + + for name, operation := range tests { + t.Run(name, func(t *testing.T) { + _, err := PolicyMergeOperationToProto(operation) + require.Error(t, err) + assert.Contains(t, err.Error(), "validation") + }) + } +} + func TestPolicyMergeOperationToProto_L7TargetDeepCopy(t *testing.T) { for _, kind := range []string{"allow", "deny"} { t.Run(kind, func(t *testing.T) { @@ -858,7 +890,7 @@ func TestConfigUpdateToProto_WithMergeOperations(t *testing.T) { RuleName: "new-rule", Rule: v1.NetworkPolicyRule{ Name: "new-rule", Endpoints: []v1.PolicyNetworkEndpoint{ - {Host: "svc.local", Port: 8080}, + {Host: "svc.local", Ports: []uint32{8080}}, }, }, }, diff --git a/sdk/go/openshell/v1/policy.go b/sdk/go/openshell/v1/policy.go index c995eeaf7..01c523032 100644 --- a/sdk/go/openshell/v1/policy.go +++ b/sdk/go/openshell/v1/policy.go @@ -9,8 +9,8 @@ import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" ) -// SandboxPolicy is the top-level security policy configuration for a sandbox. -type SandboxPolicy = types.SandboxPolicy +// PolicyDocument is the top-level security policy configuration for a sandbox. +type PolicyDocument = types.PolicyDocument // FilesystemPolicy controls which directories the sandbox can access. type FilesystemPolicy = types.FilesystemPolicy diff --git a/sdk/go/openshell/v1/policy_client_test.go b/sdk/go/openshell/v1/policy_client_test.go index 074fa36bf..36ff60564 100644 --- a/sdk/go/openshell/v1/policy_client_test.go +++ b/sdk/go/openshell/v1/policy_client_test.go @@ -961,7 +961,7 @@ func TestPolicyEditDraftChunk(t *testing.T) { rule := &NetworkPolicyRule{ Name: "allow-https", Endpoints: []PolicyNetworkEndpoint{ - {Host: "example.com", Port: 443, Protocol: "tcp"}, + {Host: "example.com", Ports: []uint32{443}, Protocol: "tcp"}, }, } diff --git a/sdk/go/openshell/v1/profile_client_test.go b/sdk/go/openshell/v1/profile_client_test.go index 3650a8588..bd604ee86 100644 --- a/sdk/go/openshell/v1/profile_client_test.go +++ b/sdk/go/openshell/v1/profile_client_test.go @@ -202,7 +202,7 @@ func seedProfile(mock *mockProfileServer, id, displayName string, category pb.Pr {Name: "api-key", Description: "API Key", Required: true, Refresh: &pb.ProviderCredentialRefresh{}}, }, Endpoints: []*policyv1.NetworkEndpoint{ - {Host: "localhost", Port: 8080, Protocol: "http"}, + {Host: "localhost", Ports: []uint32{8080}, Protocol: "http"}, }, Binaries: []*policyv1.NetworkBinary{ {Path: "/usr/bin/provider"}, @@ -289,7 +289,7 @@ func TestProfileGet(t *testing.T) { // Verify endpoint deep copy require.Len(t, profile.Endpoints, 1) assert.Equal(t, "localhost", profile.Endpoints[0].Host) - assert.Equal(t, uint32(8080), profile.Endpoints[0].Port) + assert.Equal(t, []uint32{8080}, profile.Endpoints[0].Ports) // Verify binary deep copy require.Len(t, profile.Binaries, 1) assert.Equal(t, "/usr/bin/provider", profile.Binaries[0].Path) diff --git a/sdk/go/openshell/v1/types/network_policy.go b/sdk/go/openshell/v1/types/network_policy.go index a1a90c33f..ab7dc7f75 100644 --- a/sdk/go/openshell/v1/types/network_policy.go +++ b/sdk/go/openshell/v1/types/network_policy.go @@ -14,51 +14,50 @@ type NetworkPolicyRule struct { } // NetworkTLSMode controls TLS handling for a policy endpoint. -type NetworkTLSMode int32 +type NetworkTLSMode string const ( // NetworkTLSModeUnspecified uses automatic TLS handling. - NetworkTLSModeUnspecified NetworkTLSMode = 0 + NetworkTLSModeUnspecified NetworkTLSMode = "" // NetworkTLSModeSkip disables TLS inspection. - NetworkTLSModeSkip NetworkTLSMode = 1 + NetworkTLSModeSkip NetworkTLSMode = "skip" // NetworkTLSModeTerminate is retained for wire compatibility; prefer unspecified. - NetworkTLSModeTerminate NetworkTLSMode = 2 + NetworkTLSModeTerminate NetworkTLSMode = "terminate" // NetworkTLSModePassthrough is retained for wire compatibility; prefer unspecified. - NetworkTLSModePassthrough NetworkTLSMode = 3 + NetworkTLSModePassthrough NetworkTLSMode = "passthrough" ) // NetworkEnforcementMode controls whether an endpoint audits or enforces L7 rules. -type NetworkEnforcementMode int32 +type NetworkEnforcementMode string const ( // NetworkEnforcementModeUnspecified uses the documented audit default. - NetworkEnforcementModeUnspecified NetworkEnforcementMode = 0 + NetworkEnforcementModeUnspecified NetworkEnforcementMode = "" // NetworkEnforcementModeEnforce blocks policy violations. - NetworkEnforcementModeEnforce NetworkEnforcementMode = 1 + NetworkEnforcementModeEnforce NetworkEnforcementMode = "enforce" // NetworkEnforcementModeAudit logs policy violations without blocking them. - NetworkEnforcementModeAudit NetworkEnforcementMode = 2 + NetworkEnforcementModeAudit NetworkEnforcementMode = "audit" ) // NetworkAccessPreset selects a predefined endpoint access policy. -type NetworkAccessPreset int32 +type NetworkAccessPreset string const ( // NetworkAccessPresetUnspecified selects no access preset. - NetworkAccessPresetUnspecified NetworkAccessPreset = 0 + NetworkAccessPresetUnspecified NetworkAccessPreset = "" // NetworkAccessPresetReadOnly permits read operations. - NetworkAccessPresetReadOnly NetworkAccessPreset = 1 + NetworkAccessPresetReadOnly NetworkAccessPreset = "read-only" // NetworkAccessPresetReadWrite permits read and write operations. - NetworkAccessPresetReadWrite NetworkAccessPreset = 2 + NetworkAccessPresetReadWrite NetworkAccessPreset = "read-write" // NetworkAccessPresetFull permits every operation supported by the protocol. - NetworkAccessPresetFull NetworkAccessPreset = 3 + NetworkAccessPresetFull NetworkAccessPreset = "full" ) // PolicyNetworkEndpoint describes a full network endpoint with its access controls // as used in sandbox network policy rules. This is distinct from [NetworkEndpoint] -// which is the simplified profile-level endpoint (Host, Port, Protocol only). +// which is the simplified profile-level endpoint (Host, Ports, Protocol only). type PolicyNetworkEndpoint struct { Host string - Port uint32 Ports []uint32 Protocol string TLS NetworkTLSMode diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index 8fb0712b6..b959f2333 100644 --- a/sdk/go/openshell/v1/types/policy.go +++ b/sdk/go/openshell/v1/types/policy.go @@ -88,8 +88,8 @@ type PolicyChunk struct { ReviewToken string CurrentEffectivePolicyHash string CandidateEffectivePolicyHash string - CurrentEffectivePolicy *SandboxPolicy - CandidateEffectivePolicy *SandboxPolicy + CurrentEffectivePolicy *PolicyDocument + CandidateEffectivePolicy *PolicyDocument } // DraftChunkApproval binds one bulk approval decision to a reviewed chunk. @@ -110,11 +110,11 @@ type DraftPolicy struct { LastAnalyzedAt time.Time } -// SandboxPolicy is the top-level security policy configuration for a sandbox. +// PolicyDocument is the top-level security policy configuration for a sandbox. // It contains filesystem access rules, Landlock LSM configuration, process // identity rules, and named network access policies. -type SandboxPolicy struct { - // Version is the policy version number. The server may override this on write. +type PolicyDocument struct { + // Version is the authored schema version and must be 1. Version uint32 // Filesystem controls which directories the sandbox can access. // Nil means no filesystem policy is specified. @@ -193,7 +193,7 @@ type SandboxPolicyRevision struct { // LoadedAt is when this revision was loaded by the sandbox. LoadedAt time.Time // Policy is the typed security policy for this revision. Nil when not requested or absent. - Policy *SandboxPolicy + Policy *PolicyDocument // Provenance is immutable metadata supplied with this policy revision. Provenance map[string]string } diff --git a/sdk/go/openshell/v1/types/profile.go b/sdk/go/openshell/v1/types/profile.go index b7834d2ab..c9563f2c8 100644 --- a/sdk/go/openshell/v1/types/profile.go +++ b/sdk/go/openshell/v1/types/profile.go @@ -128,7 +128,7 @@ type TokenGrantAudienceOverride struct { // NetworkEndpoint describes a network endpoint provided by a profile. type NetworkEndpoint struct { Host string - Port uint32 + Ports []uint32 Protocol string } diff --git a/sdk/go/openshell/v1/types/sandbox.go b/sdk/go/openshell/v1/types/sandbox.go index 4b945a4ec..b2eafbbc5 100644 --- a/sdk/go/openshell/v1/types/sandbox.go +++ b/sdk/go/openshell/v1/types/sandbox.go @@ -31,7 +31,7 @@ type SandboxSpec struct { GPU bool GPUCount *uint32 // Policy is the security policy for the sandbox. Nil means no policy specified. - Policy *SandboxPolicy + Policy *PolicyDocument Command []string TTY bool } diff --git a/sdk/go/openshell/v1/types/setting.go b/sdk/go/openshell/v1/types/setting.go index 7dd8eff2f..156a9b234 100644 --- a/sdk/go/openshell/v1/types/setting.go +++ b/sdk/go/openshell/v1/types/setting.go @@ -54,7 +54,7 @@ type EffectiveSetting struct { // including policy, effective settings, and revision metadata. type SandboxConfig struct { // Policy is the typed security policy for this sandbox. Nil means no policy in the response. - Policy *SandboxPolicy + Policy *PolicyDocument // PolicyVersion is monotonically increasing per sandbox. PolicyVersion uint32 // PolicyHash is the SHA-256 of the serialized policy payload. @@ -89,7 +89,7 @@ type ConfigUpdate struct { // Name is the sandbox name (required for sandbox-scoped updates). Name string // Policy is the typed security policy for a full policy replacement. Nil means no policy change. - Policy *SandboxPolicy + Policy *PolicyDocument // SettingKey is a single setting key to mutate. SettingKey string // SettingValue is the setting value for upsert. Nil means no value change. diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 2a42a5524..6b40e4389 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -2065,7 +2065,7 @@ type SandboxSpec struct { // Container or VM template used to provision the sandbox. Template *SandboxTemplate `protobuf:"bytes,6,opt,name=template,proto3" json:"template,omitempty"` // Required sandbox policy configuration. - Policy *policyv1.SandboxPolicy `protobuf:"bytes,7,opt,name=policy,proto3" json:"policy,omitempty"` + Policy *policyv1.PolicyDocument `protobuf:"bytes,7,opt,name=policy,proto3" json:"policy,omitempty"` // Provider names to attach to this sandbox. Providers []string `protobuf:"bytes,8,rep,name=providers,proto3" json:"providers,omitempty"` // Portable resource requirements used by the gateway for driver selection @@ -2135,7 +2135,7 @@ func (x *SandboxSpec) GetTemplate() *SandboxTemplate { return nil } -func (x *SandboxSpec) GetPolicy() *policyv1.SandboxPolicy { +func (x *SandboxSpec) GetPolicy() *policyv1.PolicyDocument { if x != nil { return x.Policy } @@ -10914,7 +10914,7 @@ type UpdateConfigRequest struct { // // Global scope (`global=true`): // - applies to all sandboxes in full (no merge). - Policy *policyv1.SandboxPolicy `protobuf:"bytes,2,opt,name=policy,proto3" json:"policy,omitempty"` + Policy *policyv1.PolicyDocument `protobuf:"bytes,2,opt,name=policy,proto3" json:"policy,omitempty"` // Optional single setting key to mutate. SettingKey string `protobuf:"bytes,3,opt,name=setting_key,json=settingKey,proto3" json:"setting_key,omitempty"` // Setting value for upsert operations. @@ -10985,7 +10985,7 @@ func (x *UpdateConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector return nil } -func (x *UpdateConfigRequest) GetPolicy() *policyv1.SandboxPolicy { +func (x *UpdateConfigRequest) GetPolicy() *policyv1.PolicyDocument { if x != nil { return x.Policy } @@ -12270,7 +12270,7 @@ type SandboxPolicyRevision struct { // Time when this revision was loaded by the sandbox. Absent if not loaded. LoadedTime *timestamppb.Timestamp `protobuf:"bytes,106,opt,name=loaded_time,json=loadedTime,proto3" json:"loaded_time,omitempty"` // The full policy (only populated when explicitly requested). - Policy *policyv1.SandboxPolicy `protobuf:"bytes,7,opt,name=policy,proto3" json:"policy,omitempty"` + Policy *policyv1.PolicyDocument `protobuf:"bytes,7,opt,name=policy,proto3" json:"policy,omitempty"` // Immutable provenance supplied with this policy revision. Provenance map[string]string `protobuf:"bytes,8,rep,name=provenance,proto3" json:"provenance,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields @@ -12349,7 +12349,7 @@ func (x *SandboxPolicyRevision) GetLoadedTime() *timestamppb.Timestamp { return nil } -func (x *SandboxPolicyRevision) GetPolicy() *policyv1.SandboxPolicy { +func (x *SandboxPolicyRevision) GetPolicy() *policyv1.PolicyDocument { if x != nil { return x.Policy } @@ -14166,8 +14166,8 @@ type PolicyChunk struct { CandidateEffectivePolicyHash string `protobuf:"bytes,22,opt,name=candidate_effective_policy_hash,json=candidateEffectivePolicyHash,proto3" json:"candidate_effective_policy_hash,omitempty"` // Complete effective policies used for review. These contain policy // configuration only; credential secret values are never materialized. - CurrentEffectivePolicy *policyv1.SandboxPolicy `protobuf:"bytes,23,opt,name=current_effective_policy,json=currentEffectivePolicy,proto3" json:"current_effective_policy,omitempty"` - CandidateEffectivePolicy *policyv1.SandboxPolicy `protobuf:"bytes,24,opt,name=candidate_effective_policy,json=candidateEffectivePolicy,proto3" json:"candidate_effective_policy,omitempty"` + CurrentEffectivePolicy *policyv1.PolicyDocument `protobuf:"bytes,23,opt,name=current_effective_policy,json=currentEffectivePolicy,proto3" json:"current_effective_policy,omitempty"` + CandidateEffectivePolicy *policyv1.PolicyDocument `protobuf:"bytes,24,opt,name=candidate_effective_policy,json=candidateEffectivePolicy,proto3" json:"candidate_effective_policy,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -14356,14 +14356,14 @@ func (x *PolicyChunk) GetCandidateEffectivePolicyHash() string { return "" } -func (x *PolicyChunk) GetCurrentEffectivePolicy() *policyv1.SandboxPolicy { +func (x *PolicyChunk) GetCurrentEffectivePolicy() *policyv1.PolicyDocument { if x != nil { return x.CurrentEffectivePolicy } return nil } -func (x *PolicyChunk) GetCandidateEffectivePolicy() *policyv1.SandboxPolicy { +func (x *PolicyChunk) GetCandidateEffectivePolicy() *policyv1.PolicyDocument { if x != nil { return x.CandidateEffectivePolicy } @@ -17132,12 +17132,12 @@ const file_openshell_proto_rawDesc = "" + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12-\n" + "\x04spec\x18\x02 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x123\n" + "\x06status\x18\x03 \x01(\v2\x1b.openshell.v1.SandboxStatusR\x06status\x12t\n" + - "\x1ecreated_from_workload_template\x18\x14 \x01(\v2/.openshell.v1.SandboxWorkloadTemplateProvenanceR\x1bcreatedFromWorkloadTemplateJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x05phaseR\x16current_policy_version\"\xbe\x04\n" + + "\x1ecreated_from_workload_template\x18\x14 \x01(\v2/.openshell.v1.SandboxWorkloadTemplateProvenanceR\x1bcreatedFromWorkloadTemplateJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x05phaseR\x16current_policy_version\"\xbf\x04\n" + "\vSandboxSpec\x12\x1b\n" + "\tlog_level\x18\x01 \x01(\tR\blogLevel\x12L\n" + "\venvironment\x18\x05 \x03(\v2*.openshell.v1.SandboxSpec.EnvironmentEntryR\venvironment\x129\n" + - "\btemplate\x18\x06 \x01(\v2\x1d.openshell.v1.SandboxTemplateR\btemplate\x12:\n" + - "\x06policy\x18\a \x01(\v2\".openshell.policy.v1.SandboxPolicyR\x06policy\x12\x1c\n" + + "\btemplate\x18\x06 \x01(\v2\x1d.openshell.v1.SandboxTemplateR\btemplate\x12;\n" + + "\x06policy\x18\a \x01(\v2#.openshell.policy.v1.PolicyDocumentR\x06policy\x12\x1c\n" + "\tproviders\x18\b \x03(\tR\tproviders\x12W\n" + "\x15resource_requirements\x18\t \x01(\v2\".openshell.v1.ResourceRequirementsR\x14resourceRequirements\x12\x18\n" + "\acommand\x18\f \x03(\tR\acommand\x12\x10\n" + @@ -17872,11 +17872,11 @@ const file_openshell_proto_rawDesc = "" + "\rexpires_after\x18f \x01(\v2\x19.google.protobuf.DurationR\fexpiresAfter\x12\x1d\n" + "\n" + "token_type\x18\x03 \x01(\tR\ttokenTypeJ\x04\b\x02\x10\x03R\n" + - "expires_in\"\xa8\x05\n" + + "expires_in\"\xa9\x05\n" + "\x13UpdateConfigRequest\x12R\n" + "\x0fworkspace_scope\x18\n" + - " \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12:\n" + - "\x06policy\x18\x02 \x01(\v2\".openshell.policy.v1.SandboxPolicyR\x06policy\x12\x1f\n" + + " \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12;\n" + + "\x06policy\x18\x02 \x01(\v2#.openshell.policy.v1.PolicyDocumentR\x06policy\x12\x1f\n" + "\vsetting_key\x18\x03 \x01(\tR\n" + "settingKey\x12G\n" + "\rsetting_value\x18\x04 \x01(\v2\".openshell.sandbox.v1.SettingValueR\fsettingValue\x12%\n" + @@ -17980,7 +17980,7 @@ const file_openshell_proto_rawDesc = "" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12I\n" + "\tadmission\x18\x02 \x01(\v2+.openshell.v1.SandboxConfigurationAdmissionR\tadmission\x120\n" + "\x14expected_instance_id\x18\x03 \x01(\tR\x12expectedInstanceId\"$\n" + - "\"ReportSandboxConfigurationResponse\"\x9a\x04\n" + + "\"ReportSandboxConfigurationResponse\"\x9b\x04\n" + "\x15SandboxPolicyRevision\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + @@ -17990,8 +17990,8 @@ const file_openshell_proto_rawDesc = "" + "load_error\x18\x04 \x01(\tR\tloadError\x12=\n" + "\fcreated_time\x18i \x01(\v2\x1a.google.protobuf.TimestampR\vcreatedTime\x12;\n" + "\vloaded_time\x18j \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "loadedTime\x12:\n" + - "\x06policy\x18\a \x01(\v2\".openshell.policy.v1.SandboxPolicyR\x06policy\x12S\n" + + "loadedTime\x12;\n" + + "\x06policy\x18\a \x01(\v2#.openshell.policy.v1.PolicyDocumentR\x06policy\x12S\n" + "\n" + "provenance\x18\b \x03(\v23.openshell.v1.SandboxPolicyRevision.ProvenanceEntryR\n" + "provenance\x1a=\n" + @@ -18123,7 +18123,7 @@ const file_openshell_proto_rawDesc = "" + "\x16NetworkActivitySummary\x124\n" + "\x16network_activity_count\x18\x01 \x01(\rR\x14networkActivityCount\x12.\n" + "\x13denied_action_count\x18\x02 \x01(\rR\x11deniedActionCount\x12H\n" + - "\x10denials_by_group\x18\x03 \x03(\v2\x1e.openshell.v1.DenialGroupCountR\x0edenialsByGroup\"\xf6\t\n" + + "\x10denials_by_group\x18\x03 \x03(\v2\x1e.openshell.v1.DenialGroupCountR\x0edenialsByGroup\"\xf8\t\n" + "\vPolicyChunk\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1b\n" + @@ -18148,9 +18148,9 @@ const file_openshell_proto_rawDesc = "" + "\x11application_error\x18\x13 \x01(\tR\x10applicationError\x12!\n" + "\freview_token\x18\x14 \x01(\tR\vreviewToken\x12A\n" + "\x1dcurrent_effective_policy_hash\x18\x15 \x01(\tR\x1acurrentEffectivePolicyHash\x12E\n" + - "\x1fcandidate_effective_policy_hash\x18\x16 \x01(\tR\x1ccandidateEffectivePolicyHash\x12\\\n" + - "\x18current_effective_policy\x18\x17 \x01(\v2\".openshell.policy.v1.SandboxPolicyR\x16currentEffectivePolicy\x12`\n" + - "\x1acandidate_effective_policy\x18\x18 \x01(\v2\".openshell.policy.v1.SandboxPolicyR\x18candidateEffectivePolicyJ\x04\b\t\x10\n" + + "\x1fcandidate_effective_policy_hash\x18\x16 \x01(\tR\x1ccandidateEffectivePolicyHash\x12]\n" + + "\x18current_effective_policy\x18\x17 \x01(\v2#.openshell.policy.v1.PolicyDocumentR\x16currentEffectivePolicy\x12a\n" + + "\x1acandidate_effective_policy\x18\x18 \x01(\v2#.openshell.policy.v1.PolicyDocumentR\x18candidateEffectivePolicyJ\x04\b\t\x10\n" + "J\x04\b\n" + "\x10\vJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10R\rcreated_at_msR\rdecided_at_msR\rfirst_seen_msR\flast_seen_ms\"\x96\x01\n" + "\x11DraftPolicyUpdate\x12#\n" + @@ -18923,7 +18923,7 @@ var file_openshell_proto_goTypes = []any{ nil, // 264: openshell.v1.CreateWorkspaceRequest.LabelsEntry (*timestamppb.Timestamp)(nil), // 265: google.protobuf.Timestamp (*datamodelv1.ObjectMeta)(nil), // 266: openshell.datamodel.v1.ObjectMeta - (*policyv1.SandboxPolicy)(nil), // 267: openshell.policy.v1.SandboxPolicy + (*policyv1.PolicyDocument)(nil), // 267: openshell.policy.v1.PolicyDocument (*structpb.Struct)(nil), // 268: google.protobuf.Struct (*durationpb.Duration)(nil), // 269: google.protobuf.Duration (*datamodelv1.WorkspaceSelector)(nil), // 270: openshell.datamodel.v1.WorkspaceSelector @@ -18960,7 +18960,7 @@ var file_openshell_proto_depIdxs = []int32{ 44, // 15: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance 244, // 16: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry 37, // 17: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 267, // 18: openshell.v1.SandboxSpec.policy:type_name -> openshell.policy.v1.SandboxPolicy + 267, // 18: openshell.v1.SandboxSpec.policy:type_name -> openshell.policy.v1.PolicyDocument 35, // 19: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements 36, // 20: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements 245, // 21: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry @@ -19147,7 +19147,7 @@ var file_openshell_proto_depIdxs = []int32{ 3, // 202: openshell.v1.GetSandboxProviderEnvironmentResponse.readiness_reason:type_name -> openshell.v1.ProviderReadinessReason 269, // 203: openshell.v1.ExchangeProviderSubjectTokenResponse.expires_after:type_name -> google.protobuf.Duration 270, // 204: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 267, // 205: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.policy.v1.SandboxPolicy + 267, // 205: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.policy.v1.PolicyDocument 275, // 206: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue 156, // 207: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation 261, // 208: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry @@ -19174,7 +19174,7 @@ var file_openshell_proto_depIdxs = []int32{ 11, // 229: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus 265, // 230: openshell.v1.SandboxPolicyRevision.created_time:type_name -> google.protobuf.Timestamp 265, // 231: openshell.v1.SandboxPolicyRevision.loaded_time:type_name -> google.protobuf.Timestamp - 267, // 232: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.policy.v1.SandboxPolicy + 267, // 232: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.policy.v1.PolicyDocument 263, // 233: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry 270, // 234: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 265, // 235: openshell.v1.GetSandboxLogsRequest.since_time:type_name -> google.protobuf.Timestamp @@ -19202,8 +19202,8 @@ var file_openshell_proto_depIdxs = []int32{ 265, // 257: openshell.v1.PolicyChunk.decided_time:type_name -> google.protobuf.Timestamp 265, // 258: openshell.v1.PolicyChunk.first_seen_time:type_name -> google.protobuf.Timestamp 265, // 259: openshell.v1.PolicyChunk.last_seen_time:type_name -> google.protobuf.Timestamp - 267, // 260: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.policy.v1.SandboxPolicy - 267, // 261: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.policy.v1.SandboxPolicy + 267, // 260: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.policy.v1.PolicyDocument + 267, // 261: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.policy.v1.PolicyDocument 270, // 262: openshell.v1.SubmitPolicyAnalysisRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 198, // 263: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary 201, // 264: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk diff --git a/sdk/go/proto/policyv1/policy.pb.go b/sdk/go/proto/policyv1/policy.pb.go index 68c0eaef5..db3765bdd 100644 --- a/sdk/go/proto/policyv1/policy.pb.go +++ b/sdk/go/proto/policyv1/policy.pb.go @@ -10,6 +10,7 @@ package policyv1 import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" structpb "google.golang.org/protobuf/types/known/structpb" @@ -30,7 +31,7 @@ const ( // This package is the public policy contract used by policy files and SDKs. // Runtime-derived authority and normalization state live in // openshell.sandbox.v1 and cannot be supplied through these messages. -type SandboxPolicy struct { +type PolicyDocument struct { state protoimpl.MessageState `protogen:"open.v1"` // Authored policy schema version. The only supported value is 1. Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` @@ -49,20 +50,20 @@ type SandboxPolicy struct { sizeCache protoimpl.SizeCache } -func (x *SandboxPolicy) Reset() { - *x = SandboxPolicy{} +func (x *PolicyDocument) Reset() { + *x = PolicyDocument{} mi := &file_policy_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SandboxPolicy) String() string { +func (x *PolicyDocument) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SandboxPolicy) ProtoMessage() {} +func (*PolicyDocument) ProtoMessage() {} -func (x *SandboxPolicy) ProtoReflect() protoreflect.Message { +func (x *PolicyDocument) ProtoReflect() protoreflect.Message { mi := &file_policy_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -74,47 +75,47 @@ func (x *SandboxPolicy) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SandboxPolicy.ProtoReflect.Descriptor instead. -func (*SandboxPolicy) Descriptor() ([]byte, []int) { +// Deprecated: Use PolicyDocument.ProtoReflect.Descriptor instead. +func (*PolicyDocument) Descriptor() ([]byte, []int) { return file_policy_proto_rawDescGZIP(), []int{0} } -func (x *SandboxPolicy) GetVersion() uint32 { +func (x *PolicyDocument) GetVersion() uint32 { if x != nil { return x.Version } return 0 } -func (x *SandboxPolicy) GetFilesystemPolicy() *FilesystemPolicy { +func (x *PolicyDocument) GetFilesystemPolicy() *FilesystemPolicy { if x != nil { return x.FilesystemPolicy } return nil } -func (x *SandboxPolicy) GetLandlock() *LandlockPolicy { +func (x *PolicyDocument) GetLandlock() *LandlockPolicy { if x != nil { return x.Landlock } return nil } -func (x *SandboxPolicy) GetProcess() *ProcessPolicy { +func (x *PolicyDocument) GetProcess() *ProcessPolicy { if x != nil { return x.Process } return nil } -func (x *SandboxPolicy) GetNetworkPolicies() map[string]*NetworkPolicyRule { +func (x *PolicyDocument) GetNetworkPolicies() map[string]*NetworkPolicyRule { if x != nil { return x.NetworkPolicies } return nil } -func (x *SandboxPolicy) GetNetworkMiddlewares() map[string]*NetworkMiddleware { +func (x *PolicyDocument) GetNetworkMiddlewares() map[string]*NetworkMiddleware { if x != nil { return x.NetworkMiddlewares } @@ -281,9 +282,10 @@ func (x *ProcessPolicy) GetRunAsGroup() string { type NetworkPolicyRule struct { state protoimpl.MessageState `protogen:"open.v1"` // Display name. An empty value falls back to the surrounding map key. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Endpoints []*NetworkEndpoint `protobuf:"bytes,2,rep,name=endpoints,proto3" json:"endpoints,omitempty"` - Binaries []*NetworkBinary `protobuf:"bytes,3,rep,name=binaries,proto3" json:"binaries,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Endpoints []*NetworkEndpoint `protobuf:"bytes,2,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + // Omitted and empty both match no process. + Binaries []*NetworkBinary `protobuf:"bytes,3,rep,name=binaries,proto3" json:"binaries,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -340,16 +342,16 @@ func (x *NetworkPolicyRule) GetBinaries() []*NetworkBinary { } type NetworkEndpoint struct { - state protoimpl.MessageState `protogen:"open.v1"` - Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` - // Compatibility shorthand for a single port. Mutually exclusive with ports. - Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` - Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` - Tls string `protobuf:"bytes,4,opt,name=tls,proto3" json:"tls,omitempty"` - Enforcement string `protobuf:"bytes,5,opt,name=enforcement,proto3" json:"enforcement,omitempty"` - Access string `protobuf:"bytes,6,opt,name=access,proto3" json:"access,omitempty"` - Rules []*L7Rule `protobuf:"bytes,7,rep,name=rules,proto3" json:"rules,omitempty"` - AllowedIps []string `protobuf:"bytes,8,rep,name=allowed_ips,json=allowedIps,proto3" json:"allowed_ips,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` + Tls string `protobuf:"bytes,4,opt,name=tls,proto3" json:"tls,omitempty"` + Enforcement string `protobuf:"bytes,5,opt,name=enforcement,proto3" json:"enforcement,omitempty"` + Access string `protobuf:"bytes,6,opt,name=access,proto3" json:"access,omitempty"` + Rules []*L7Rule `protobuf:"bytes,7,rep,name=rules,proto3" json:"rules,omitempty"` + AllowedIps []string `protobuf:"bytes,8,rep,name=allowed_ips,json=allowedIps,proto3" json:"allowed_ips,omitempty"` + // Every endpoint has one nonempty, duplicate-free port list. Tag 9 remains + // stable because earlier review builds exposed this repeated field. Ports []uint32 `protobuf:"varint,9,rep,packed,name=ports,proto3" json:"ports,omitempty"` DenyRules []*L7DenyRule `protobuf:"bytes,10,rep,name=deny_rules,json=denyRules,proto3" json:"deny_rules,omitempty"` AllowEncodedSlash bool `protobuf:"varint,11,opt,name=allow_encoded_slash,json=allowEncodedSlash,proto3" json:"allow_encoded_slash,omitempty"` @@ -407,13 +409,6 @@ func (x *NetworkEndpoint) GetHost() string { return "" } -func (x *NetworkEndpoint) GetPort() uint32 { - if x != nil { - return x.Port - } - return 0 -} - func (x *NetworkEndpoint) GetProtocol() string { if x != nil { return x.Protocol @@ -1483,14 +1478,14 @@ var File_policy_proto protoreflect.FileDescriptor const file_policy_proto_rawDesc = "" + "\n" + - "\fpolicy.proto\x12\x13openshell.policy.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xa8\x05\n" + - "\rSandboxPolicy\x12\x18\n" + - "\aversion\x18\x01 \x01(\rR\aversion\x12R\n" + + "\fpolicy.proto\x12\x13openshell.policy.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1bbuf/validate/validate.proto\"\xdd\x05\n" + + "\x0ePolicyDocument\x12!\n" + + "\aversion\x18\x01 \x01(\rB\a\xbaH\x04*\x02\b\x01R\aversion\x12R\n" + "\x11filesystem_policy\x18\x02 \x01(\v2%.openshell.policy.v1.FilesystemPolicyR\x10filesystemPolicy\x12?\n" + "\blandlock\x18\x03 \x01(\v2#.openshell.policy.v1.LandlockPolicyR\blandlock\x12<\n" + - "\aprocess\x18\x04 \x01(\v2\".openshell.policy.v1.ProcessPolicyR\aprocess\x12b\n" + - "\x10network_policies\x18\x05 \x03(\v27.openshell.policy.v1.SandboxPolicy.NetworkPoliciesEntryR\x0fnetworkPolicies\x12k\n" + - "\x13network_middlewares\x18\x06 \x03(\v2:.openshell.policy.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x1aj\n" + + "\aprocess\x18\x04 \x01(\v2\".openshell.policy.v1.ProcessPolicyR\aprocess\x12w\n" + + "\x10network_policies\x18\x05 \x03(\v28.openshell.policy.v1.PolicyDocument.NetworkPoliciesEntryB\x12\xbaH\x0f\x9a\x01\f\x10\x80\b\"\ar\x05\x10\x01\x18\x80\x02R\x0fnetworkPolicies\x12\x80\x01\n" + + "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.policy.v1.PolicyDocument.NetworkMiddlewaresEntryB\x12\xbaH\x0f\x9a\x01\f\x10\x80\b\"\ar\x05\x10\x01\x18\x80\x02R\x12networkMiddlewares\x1aj\n" + "\x14NetworkPoliciesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + "\x05value\x18\x02 \x01(\v2&.openshell.policy.v1.NetworkPolicyRuleR\x05value:\x028\x01\x1am\n" + @@ -1507,26 +1502,27 @@ const file_policy_proto_rawDesc = "" + "\rProcessPolicy\x12\x1e\n" + "\vrun_as_user\x18\x01 \x01(\tR\trunAsUser\x12 \n" + "\frun_as_group\x18\x02 \x01(\tR\n" + - "runAsGroup\"\xab\x01\n" + + "runAsGroup\"\xc1\x01\n" + "\x11NetworkPolicyRule\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12B\n" + - "\tendpoints\x18\x02 \x03(\v2$.openshell.policy.v1.NetworkEndpointR\tendpoints\x12>\n" + - "\bbinaries\x18\x03 \x03(\v2\".openshell.policy.v1.NetworkBinaryR\bbinaries\"\xb3\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12M\n" + + "\tendpoints\x18\x02 \x03(\v2$.openshell.policy.v1.NetworkEndpointB\t\xbaH\x06\x92\x01\x03\x10\x80 R\tendpoints\x12I\n" + + "\bbinaries\x18\x03 \x03(\v2\".openshell.policy.v1.NetworkBinaryB\t\xbaH\x06\x92\x01\x03\x10\x80 R\bbinaries\"\xfb\n" + "\n" + - "\x0fNetworkEndpoint\x12\x12\n" + - "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + - "\x04port\x18\x02 \x01(\rR\x04port\x12\x1a\n" + + "\x0fNetworkEndpoint\x12\x1c\n" + + "\x04host\x18\x01 \x01(\tB\b\xbaH\x05r\x03\x18\x80 R\x04host\x12\x1a\n" + "\bprotocol\x18\x03 \x01(\tR\bprotocol\x12\x10\n" + "\x03tls\x18\x04 \x01(\tR\x03tls\x12 \n" + "\venforcement\x18\x05 \x01(\tR\venforcement\x12\x16\n" + - "\x06access\x18\x06 \x01(\tR\x06access\x121\n" + - "\x05rules\x18\a \x03(\v2\x1b.openshell.policy.v1.L7RuleR\x05rules\x12\x1f\n" + - "\vallowed_ips\x18\b \x03(\tR\n" + - "allowedIps\x12\x14\n" + - "\x05ports\x18\t \x03(\rR\x05ports\x12>\n" + + "\x06access\x18\x06 \x01(\tR\x06access\x12=\n" + + "\x05rules\x18\a \x03(\v2\x1b.openshell.policy.v1.L7RuleB\n" + + "\xbaH\a\x92\x01\x04\x10\x80\x80\x01R\x05rules\x123\n" + + "\vallowed_ips\x18\b \x03(\tB\x12\xbaH\x0f\x92\x01\f\x10\x80 \"\ar\x05\x10\x01\x18\x80 R\n" + + "allowedIps\x12.\n" + + "\x05ports\x18\t \x03(\rB\x18\xbaH\x15\x92\x01\x12\b\x01\x10\xff\xff\x03\x18\x01\"\b*\x06\x18\xff\xff\x03(\x01R\x05ports\x12J\n" + "\n" + "deny_rules\x18\n" + - " \x03(\v2\x1f.openshell.policy.v1.L7DenyRuleR\tdenyRules\x12.\n" + + " \x03(\v2\x1f.openshell.policy.v1.L7DenyRuleB\n" + + "\xbaH\a\x92\x01\x04\x10\x80\x80\x01R\tdenyRules\x12.\n" + "\x13allow_encoded_slash\x18\v \x01(\bR\x11allowEncodedSlash\x12+\n" + "\x11persisted_queries\x18\f \x01(\tR\x10persistedQueries\x12}\n" + "\x19graphql_persisted_queries\x18\r \x03(\v2A.openshell.policy.v1.NetworkEndpoint.GraphqlPersistedQueriesEntryR\x17graphqlPersistedQueries\x123\n" + @@ -1543,7 +1539,7 @@ const file_policy_proto_rawDesc = "" + "\x1dallow_uninspected_credentials\x18\x19 \x01(\bR\x1ballowUninspectedCredentials\x1aq\n" + "\x1cGraphqlPersistedQueriesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12;\n" + - "\x05value\x18\x02 \x01(\v2%.openshell.policy.v1.GraphqlOperationR\x05value:\x028\x01J\x04\b\x12\x10\x13J\x04\b\x1a\x10\x1bR\x10advisor_proposedR\x15provider_credentialed\"6\n" + + "\x05value\x18\x02 \x01(\v2%.openshell.policy.v1.GraphqlOperationR\x05value:\x028\x01J\x04\b\x02\x10\x03J\x04\b\x12\x10\x13J\x04\b\x1a\x10\x1bR\x04portR\x10advisor_proposedR\x15provider_credentialed\"6\n" + "\x18NetworkCredentialBinding\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\"5\n" + "\rJsonRpcConfig\x12$\n" + @@ -1558,9 +1554,9 @@ const file_policy_proto_rawDesc = "" + "\x10GraphqlOperation\x12%\n" + "\x0eoperation_type\x18\x01 \x01(\tR\roperationType\x12%\n" + "\x0eoperation_name\x18\x02 \x01(\tR\roperationName\x12\x16\n" + - "\x06fields\x18\x03 \x03(\tR\x06fields\"<\n" + - "\x06L7Rule\x122\n" + - "\x05allow\x18\x01 \x01(\v2\x1c.openshell.policy.v1.L7AllowR\x05allow\"\xa2\x04\n" + + "\x06fields\x18\x03 \x03(\tR\x06fields\"D\n" + + "\x06L7Rule\x12:\n" + + "\x05allow\x18\x01 \x01(\v2\x1c.openshell.policy.v1.L7AllowB\x06\xbaH\x03\xc8\x01\x01R\x05allow\"\xa2\x04\n" + "\aL7Allow\x12\x16\n" + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x18\n" + @@ -1595,25 +1591,27 @@ const file_policy_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\v2\x1c.openshell.policy.v1.MatcherR\x05value:\x028\x01\x1a`\n" + "\vParamsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12;\n" + - "\x05value\x18\x02 \x01(\v2%.openshell.policy.v1.ParameterMatcherR\x05value:\x028\x01\"\\\n" + - "\aMatcher\x12\x14\n" + - "\x04glob\x18\x01 \x01(\tH\x00R\x04glob\x123\n" + - "\x03any\x18\x02 \x01(\v2\x1f.openshell.policy.v1.AnyMatcherH\x00R\x03anyB\x06\n" + - "\x04kind\"$\n" + + "\x05value\x18\x02 \x01(\v2%.openshell.policy.v1.ParameterMatcherR\x05value:\x028\x01\"o\n" + + "\aMatcher\x12 \n" + + "\x04glob\x18\x01 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80 H\x00R\x04glob\x123\n" + + "\x03any\x18\x02 \x01(\v2\x1f.openshell.policy.v1.AnyMatcherH\x00R\x03anyB\r\n" + + "\x04kind\x12\x05\xbaH\x02\b\x01\"<\n" + "\n" + - "AnyMatcher\x12\x16\n" + - "\x06values\x18\x01 \x03(\tR\x06values\"\x94\x01\n" + + "AnyMatcher\x12.\n" + + "\x06values\x18\x01 \x03(\tB\x16\xbaH\x13\x92\x01\x10\b\x01\x10\x80\b\x18\x01\"\ar\x05\x10\x01\x18\x80 R\x06values\"\x9b\x01\n" + "\x10ParameterMatcher\x128\n" + "\amatcher\x18\x01 \x01(\v2\x1c.openshell.policy.v1.MatcherH\x00R\amatcher\x12>\n" + - "\x06object\x18\x02 \x01(\v2$.openshell.policy.v1.ParameterObjectH\x00R\x06objectB\x06\n" + - "\x04kind\"\xbd\x01\n" + - "\x0fParameterObject\x12H\n" + - "\x06fields\x18\x01 \x03(\v20.openshell.policy.v1.ParameterObject.FieldsEntryR\x06fields\x1a`\n" + + "\x06object\x18\x02 \x01(\v2$.openshell.policy.v1.ParameterObjectH\x00R\x06objectB\r\n" + + "\x04kind\x12\x05\xbaH\x02\b\x01\"\xd3\x01\n" + + "\x0fParameterObject\x12^\n" + + "\x06fields\x18\x01 \x03(\v20.openshell.policy.v1.ParameterObject.FieldsEntryB\x14\xbaH\x11\x9a\x01\x0e\b\x01\x10\x80\b\"\ar\x05\x10\x01\x18\x80\x02R\x06fields\x1a`\n" + "\vFieldsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12;\n" + - "\x05value\x18\x02 \x01(\v2%.openshell.policy.v1.ParameterMatcherR\x05value:\x028\x01\"#\n" + - "\rNetworkBinary\x12\x12\n" + - "\x04path\x18\x01 \x01(\tR\x04path\"\xf8\x01\n" + + "\x05value\x18\x02 \x01(\v2%.openshell.policy.v1.ParameterMatcherR\x05value:\x028\x01\"/\n" + + "\rNetworkBinary\x12\x1e\n" + + "\x04path\x18\x01 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80 R\x04path\"\xf8\x01\n" + "\x11NetworkMiddleware\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1e\n" + "\n" + @@ -1641,7 +1639,7 @@ func file_policy_proto_rawDescGZIP() []byte { var file_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 28) var file_policy_proto_goTypes = []any{ - (*SandboxPolicy)(nil), // 0: openshell.policy.v1.SandboxPolicy + (*PolicyDocument)(nil), // 0: openshell.policy.v1.PolicyDocument (*FilesystemPolicy)(nil), // 1: openshell.policy.v1.FilesystemPolicy (*LandlockPolicy)(nil), // 2: openshell.policy.v1.LandlockPolicy (*ProcessPolicy)(nil), // 3: openshell.policy.v1.ProcessPolicy @@ -1661,8 +1659,8 @@ var file_policy_proto_goTypes = []any{ (*NetworkBinary)(nil), // 17: openshell.policy.v1.NetworkBinary (*NetworkMiddleware)(nil), // 18: openshell.policy.v1.NetworkMiddleware (*MiddlewareEndpointSelector)(nil), // 19: openshell.policy.v1.MiddlewareEndpointSelector - nil, // 20: openshell.policy.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 21: openshell.policy.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 20: openshell.policy.v1.PolicyDocument.NetworkPoliciesEntry + nil, // 21: openshell.policy.v1.PolicyDocument.NetworkMiddlewaresEntry nil, // 22: openshell.policy.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry nil, // 23: openshell.policy.v1.L7Allow.QueryEntry nil, // 24: openshell.policy.v1.L7Allow.ParamsEntry @@ -1672,11 +1670,11 @@ var file_policy_proto_goTypes = []any{ (*structpb.Struct)(nil), // 28: google.protobuf.Struct } var file_policy_proto_depIdxs = []int32{ - 1, // 0: openshell.policy.v1.SandboxPolicy.filesystem_policy:type_name -> openshell.policy.v1.FilesystemPolicy - 2, // 1: openshell.policy.v1.SandboxPolicy.landlock:type_name -> openshell.policy.v1.LandlockPolicy - 3, // 2: openshell.policy.v1.SandboxPolicy.process:type_name -> openshell.policy.v1.ProcessPolicy - 20, // 3: openshell.policy.v1.SandboxPolicy.network_policies:type_name -> openshell.policy.v1.SandboxPolicy.NetworkPoliciesEntry - 21, // 4: openshell.policy.v1.SandboxPolicy.network_middlewares:type_name -> openshell.policy.v1.SandboxPolicy.NetworkMiddlewaresEntry + 1, // 0: openshell.policy.v1.PolicyDocument.filesystem_policy:type_name -> openshell.policy.v1.FilesystemPolicy + 2, // 1: openshell.policy.v1.PolicyDocument.landlock:type_name -> openshell.policy.v1.LandlockPolicy + 3, // 2: openshell.policy.v1.PolicyDocument.process:type_name -> openshell.policy.v1.ProcessPolicy + 20, // 3: openshell.policy.v1.PolicyDocument.network_policies:type_name -> openshell.policy.v1.PolicyDocument.NetworkPoliciesEntry + 21, // 4: openshell.policy.v1.PolicyDocument.network_middlewares:type_name -> openshell.policy.v1.PolicyDocument.NetworkMiddlewaresEntry 5, // 5: openshell.policy.v1.NetworkPolicyRule.endpoints:type_name -> openshell.policy.v1.NetworkEndpoint 17, // 6: openshell.policy.v1.NetworkPolicyRule.binaries:type_name -> openshell.policy.v1.NetworkBinary 10, // 7: openshell.policy.v1.NetworkEndpoint.rules:type_name -> openshell.policy.v1.L7Rule @@ -1698,8 +1696,8 @@ var file_policy_proto_depIdxs = []int32{ 27, // 23: openshell.policy.v1.ParameterObject.fields:type_name -> openshell.policy.v1.ParameterObject.FieldsEntry 28, // 24: openshell.policy.v1.NetworkMiddleware.config:type_name -> google.protobuf.Struct 19, // 25: openshell.policy.v1.NetworkMiddleware.endpoints:type_name -> openshell.policy.v1.MiddlewareEndpointSelector - 4, // 26: openshell.policy.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.policy.v1.NetworkPolicyRule - 18, // 27: openshell.policy.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.policy.v1.NetworkMiddleware + 4, // 26: openshell.policy.v1.PolicyDocument.NetworkPoliciesEntry.value:type_name -> openshell.policy.v1.NetworkPolicyRule + 18, // 27: openshell.policy.v1.PolicyDocument.NetworkMiddlewaresEntry.value:type_name -> openshell.policy.v1.NetworkMiddleware 9, // 28: openshell.policy.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.policy.v1.GraphqlOperation 13, // 29: openshell.policy.v1.L7Allow.QueryEntry.value:type_name -> openshell.policy.v1.Matcher 15, // 30: openshell.policy.v1.L7Allow.ParamsEntry.value:type_name -> openshell.policy.v1.ParameterMatcher diff --git a/sdk/typescript/buf.gen.yaml b/sdk/typescript/buf.gen.yaml index b4e8e3b25..f6cd3e580 100644 --- a/sdk/typescript/buf.gen.yaml +++ b/sdk/typescript/buf.gen.yaml @@ -18,6 +18,7 @@ inputs: - ../../proto/datamodel.proto - ../../proto/options.proto - ../../proto/policy.proto + - ../../proto/buf/validate/validate.proto # Standard rich gRPC error messages, pinned to an immutable Google APIs commit. - module: buf.build/googleapis/googleapis:c17df5b2beca46928cc87d5656bd5343 paths: diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 6c3664ced..ad25b5a88 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.2.3", + "@bufbuild/protovalidate": "^1.3.0", "@connectrpc/connect": "^2.0.0", "@connectrpc/connect-node": "^2.0.0" }, @@ -388,6 +389,28 @@ "node": ">=12" } }, + "node_modules/@bufbuild/cel": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@bufbuild/cel/-/cel-0.6.1.tgz", + "integrity": "sha512-tW7cvf5byb4zrVKsdkVk/UhD3dH6Q0hnfmeRyKaAGXx0pwebk7NjUgdR2GSwf74GXxkC7StMfgRLA2Ea/+zPKw==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/cel-spec": "0.6.1", + "@bufbuild/re2": "0.6.1" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.6.2" + } + }, + "node_modules/@bufbuild/cel-spec": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@bufbuild/cel-spec/-/cel-spec-0.6.1.tgz", + "integrity": "sha512-m0Ac9qA669yxxC0Hy45qGsUZemjImVfxPl6r9jLbkJviEvv3R9hSYyH2ItGRbDguekX6eId0cb62ulNBK97ezg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.6.2" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", @@ -445,6 +468,25 @@ "node": ">=14.17" } }, + "node_modules/@bufbuild/protovalidate": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protovalidate/-/protovalidate-1.3.0.tgz", + "integrity": "sha512-3GU0+0J/+vCk9/7Zply9XWjGLk08DcJsVNI9/e2FiPl+dIv6shBaRimkXouMs1GnPt7PueNW1vPJFGfh7O6VCA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/cel": "0.6.1", + "@bufbuild/re2": "0.6.1" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.8.0" + } + }, + "node_modules/@bufbuild/re2": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@bufbuild/re2/-/re2-0.6.1.tgz", + "integrity": "sha512-VY7zV7bWKdAsf46epowVKidolEfMKw/FoHo2xYVDVzLj/hiP5qDL3nXqJt6seZClRYfG+uzAPFQBZBlj07wO5A==", + "license": "MIT" + }, "node_modules/@connectrpc/connect": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.2.tgz", diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 20e1da18b..733f4b01f 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "@bufbuild/protobuf": "^2.2.3", + "@bufbuild/protovalidate": "^1.3.0", "@connectrpc/connect": "^2.0.0", "@connectrpc/connect-node": "^2.0.0" }, diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 356ce0d59..af4e1fb79 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -8,7 +8,8 @@ // forward() byte relay without a running gateway. import * as net from 'node:net'; -import type { MessageInitShape } from '@bufbuild/protobuf'; +import { create, type MessageInitShape } from '@bufbuild/protobuf'; +import { createValidator } from '@bufbuild/protovalidate'; import { Code, ConnectError, createRouterTransport, type ServiceImpl, type Transport } from '@connectrpc/connect'; import { describe, expect, it } from 'vitest'; import { @@ -21,8 +22,10 @@ import { SandboxTemplateClient, SCOPE_NAMES, STATUS_NAMES, + validatePolicyDocument, } from './client.js'; import { OpenShell, SandboxPhase, ServiceStatus } from './gen/openshell_pb.js'; +import { PolicyDocumentSchema } from './gen/policy_pb.js'; import { PolicySource, SettingScope } from './gen/sandbox_pb.js'; function client(impl: Partial>): SandboxClient { @@ -39,6 +42,73 @@ function templateClient(impl: Partial>): SandboxTe return new SandboxTemplateClient(transport); } +describe('policy document validation', () => { + it('runs the portable protobuf rules before an SDK request', () => { + expect(validatePolicyDocument({ version: 1 }).version).toBe(1); + expect(() => validatePolicyDocument({ version: 0 })).toThrow(/policy document validation failed/); + expect(() => + validatePolicyDocument({ + version: 1, + networkPolicies: { + api: { endpoints: [{ host: 'api.example.com', ports: [443, 443] }] }, + }, + }), + ).toThrow(/policy document validation failed/); + }); + + it('reports the portable validation rule IDs', () => { + const validator = createValidator(); + const cases: Array<[string, MessageInitShape, string]> = [ + ['version', { version: 0 }, 'uint32.const'], + [ + 'missing ports', + { version: 1, networkPolicies: { api: { endpoints: [{ host: 'api.example.com' }] } } }, + 'repeated.min_items', + ], + [ + 'duplicate ports', + { version: 1, networkPolicies: { api: { endpoints: [{ host: 'api.example.com', ports: [443, 443] }] } } }, + 'repeated.unique', + ], + [ + 'port range', + { version: 1, networkPolicies: { api: { endpoints: [{ host: 'api.example.com', ports: [65536] }] } } }, + 'uint32.gte_lte', + ], + ['binary path', { version: 1, networkPolicies: { api: { binaries: [{ path: '' }] } } }, 'string.min_len'], + [ + 'matcher choice', + { + version: 1, + networkPolicies: { + api: { + endpoints: [ + { + host: 'api.example.com', + ports: [443], + rules: [{ allow: { query: { owner: {} } } }], + }, + ], + }, + }, + }, + 'required', + ], + ]; + + for (const [name, policy, ruleId] of cases) { + const result = validator.validate(PolicyDocumentSchema, create(PolicyDocumentSchema, policy)); + expect(result.kind, name).toBe('invalid'); + if (result.kind === 'invalid') { + expect( + result.violations.some((violation) => violation.ruleId === ruleId), + `${name} should report ${ruleId}`, + ).toBe(true); + } + } + }); +}); + function readySandbox( name: string, id: string, @@ -1132,6 +1202,91 @@ describe('config / policy', () => { }); }); + it('getConfig projects the runtime policy onto the public authored contract', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + getSandboxConfig: () => ({ + policy: { + version: 1, + networkPolicies: { + api: { + name: 'api', + binaries: [{ path: '/usr/bin/curl' }], + endpoints: [ + { + host: 'mcp.example.com', + port: 443, + protocol: 'mcp', + tls: 1, + enforcement: 1, + access: 3, + rules: [ + { + allow: { + method: 'tools/call', + params: { + name: { glob: 'weather.*' }, + 'arguments.limit': { any: ['10', '20'] }, + 'arguments.__proto__.limit': { glob: '30' }, + 'arguments.constructor.limit': { glob: '40' }, + }, + }, + }, + ], + jsonRpcMaxBodyBytes: 12345, + mcp: { allowAllKnownMcpMethods: true, versions: ['2025-11-25'] }, + advisorProposed: true, + providerCredentialed: true, + }, + ], + }, + }, + }, + settings: {}, + }), + }); + + const config = await sandbox.getConfig('sb'); + expect(config.policy?.$typeName).toBe('openshell.policy.v1.PolicyDocument'); + const endpoint = config.policy?.networkPolicies.api?.endpoints[0]; + expect(endpoint?.ports).toEqual([443]); + expect(endpoint?.tls).toBe('skip'); + expect(endpoint?.enforcement).toBe('enforce'); + expect(endpoint?.access).toBe('full'); + expect(endpoint?.mcp?.maxBodyBytes).toBe(12345); + expect(endpoint?.jsonRpc).toBeUndefined(); + expect(endpoint).not.toHaveProperty('advisorProposed'); + expect(endpoint).not.toHaveProperty('providerCredentialed'); + const allow = endpoint?.rules[0]?.allow; + expect(allow?.method).toBe(''); + expect(allow?.tool?.kind).toEqual({ case: 'glob', value: 'weather.*' }); + const limit = allow?.params.arguments?.kind; + expect(limit?.case).toBe('object'); + if (limit?.case === 'object') { + expect(limit.value.fields.limit?.kind).toEqual({ + case: 'matcher', + value: expect.objectContaining({ + kind: { case: 'any', value: expect.objectContaining({ values: ['10', '20'] }) }, + }), + }); + for (const [field, value] of [ + ['__proto__', '30'], + ['constructor', '40'], + ] as const) { + const nested = limit.value.fields[field]?.kind; + expect(nested?.case).toBe('object'); + if (nested?.case === 'object') { + expect(nested.value.fields.limit?.kind).toEqual({ + case: 'matcher', + value: expect.objectContaining({ kind: { case: 'glob', value } }), + }); + } + } + } + expect(Object.hasOwn(Object.prototype, 'children')).toBe(false); + expect(Object.hasOwn(Object, 'children')).toBe(false); + }); + it('setPolicy sends global=false + version pin and (wait) polls until the hash matches', async () => { let updateReq: { sandbox?: string; diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 7cf250678..43f6fc5e5 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -13,8 +13,9 @@ import type { AddressInfo } from 'node:net'; import * as net from 'node:net'; -import type { MessageInitShape } from '@bufbuild/protobuf'; +import { create, type MessageInitShape } from '@bufbuild/protobuf'; import { durationFromMs } from '@bufbuild/protobuf/wkt'; +import { createValidator } from '@bufbuild/protovalidate'; import { type CallOptions, type Client, createClient, type Transport } from '@connectrpc/connect'; import { errorCode, fromConnect, SdkError } from './errors.js'; import type { Provider, WorkspaceSelectorSchema } from './gen/datamodel_pb.js'; @@ -28,10 +29,23 @@ import { ServiceStatus, type TcpForwardFrameSchema, } from './gen/openshell_pb.js'; -import type { SandboxPolicySchema } from './gen/policy_pb.js'; +import { + type L7AllowSchema, + type L7DenyRuleSchema, + type MatcherSchema, + type ParameterMatcherSchema, + type PolicyDocument, + PolicyDocumentSchema, + type NetworkEndpointSchema as PolicyNetworkEndpointSchema, +} from './gen/policy_pb.js'; import type { EffectiveSetting, GetSandboxConfigResponse, + L7Allow as RuntimeL7Allow, + L7DenyRule as RuntimeL7DenyRule, + L7QueryMatcher as RuntimeMatcher, + McpOptions as RuntimeMcpOptions, + NetworkEndpoint as RuntimeNetworkEndpoint, SandboxPolicy as RuntimeSandboxPolicy, SettingValue, } from './gen/sandbox_pb.js'; @@ -46,6 +60,17 @@ function durationFromSeconds(seconds: number) { return seconds === 0 ? undefined : durationFromMs(seconds * 1000); } +const POLICY_VALIDATOR = createValidator(); + +export function validatePolicyDocument(policy: MessageInitShape) { + const document = create(PolicyDocumentSchema, policy); + const result = POLICY_VALIDATOR.validate(PolicyDocumentSchema, document); + if (result.kind !== 'valid') { + throw new SdkError('invalid_config', `policy document validation failed: ${result.error.message}`); + } + return document; +} + function timestampMillis(timestamp: { seconds: bigint; nanos: number } | undefined): string | undefined { if (!timestamp) return undefined; const millis = timestamp.seconds * 1000n + BigInt(Math.trunc(timestamp.nanos / 1_000_000)); @@ -62,7 +87,7 @@ export type { SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, } from './gen/openshell_pb.js'; -export type { SandboxPolicy } from './gen/policy_pb.js'; +export type { PolicyDocument } from './gen/policy_pb.js'; export type { SettingValue } from './gen/sandbox_pb.js'; export type { ConnectOptions }; export { errorCode }; @@ -151,7 +176,7 @@ export interface SandboxSpec { * `setPolicy` cannot introduce static fields later, so express filesystem, * landlock, process, and initial network policy here. */ - policy?: MessageInitShape; + policy?: MessageInitShape; /** * Advanced escape hatch: the full generated proto spec. Curated fields build * the base spec, then `rawSpec` shallow-overrides at the top spec level, so @@ -177,7 +202,7 @@ export interface SandboxFromTemplateSpec { * Create-time sandbox policy (the safety boundary). The named workload * template supplies runtime workload fields. */ - policy?: MessageInitShape; + policy?: MessageInitShape; } export interface SandboxRef { @@ -380,7 +405,7 @@ export interface EffectiveSettingView { } export interface SandboxConfig { - policy?: RuntimeSandboxPolicy; + policy?: PolicyDocument; version: number; policyHash: string; settings: Record; @@ -502,7 +527,7 @@ function sandboxConfig(resp: GetSandboxConfigResponse): SandboxConfig { settings[key] = effectiveSetting(setting); } return { - ...(resp.policy ? { policy: resp.policy } : {}), + ...(resp.policy ? { policy: policyDocumentFromRuntime(resp.policy) } : {}), version: resp.version, policyHash: resp.policyHash, settings, @@ -513,6 +538,271 @@ function sandboxConfig(resp: GetSandboxConfigResponse): SandboxConfig { }; } +type MatcherInit = MessageInitShape; +type ParameterMatcherInit = MessageInitShape; + +function policyDocumentFromRuntime(policy: RuntimeSandboxPolicy): PolicyDocument { + return create(PolicyDocumentSchema, { + version: policy.version, + filesystemPolicy: policy.filesystem + ? { + includeWorkdir: policy.filesystem.includeWorkdir, + readOnly: [...policy.filesystem.readOnly], + readWrite: [...policy.filesystem.readWrite], + } + : undefined, + landlock: policy.landlock ? { compatibility: policy.landlock.compatibility } : undefined, + process: policy.process + ? { runAsUser: policy.process.runAsUser, runAsGroup: policy.process.runAsGroup } + : undefined, + networkPolicies: Object.fromEntries( + Object.entries(policy.networkPolicies).map(([name, rule]) => [ + name, + { + name: rule.name, + endpoints: rule.endpoints.map(policyEndpointFromRuntime), + binaries: rule.binaries.map((binary) => ({ path: binary.path })), + }, + ]), + ), + networkMiddlewares: Object.fromEntries( + Object.entries(policy.networkMiddlewares).map(([name, middleware]) => [ + name, + { + name: middleware.name, + middleware: middleware.middleware, + config: middleware.config, + onError: middleware.onError, + order: middleware.order, + endpoints: middleware.endpoints + ? { + include: [...middleware.endpoints.include], + exclude: [...middleware.endpoints.exclude], + } + : undefined, + }, + ]), + ), + }); +} + +function policyEndpointFromRuntime( + endpoint: RuntimeNetworkEndpoint, +): MessageInitShape { + const mcpProtocol = endpoint.protocol.toLowerCase() === 'mcp'; + return { + host: endpoint.host, + ports: endpoint.ports.length > 0 ? [...endpoint.ports] : endpoint.port === 0 ? [] : [endpoint.port], + protocol: endpoint.protocol, + tls: runtimeTLSMode(endpoint.tls), + enforcement: runtimeEnforcementMode(endpoint.enforcement), + access: runtimeAccessPreset(endpoint.access), + rules: endpoint.rules.map((rule) => ({ + allow: rule.allow ? policyAllowFromRuntime(endpoint.protocol, endpoint.mcp, rule.allow) : undefined, + })), + allowedIps: [...endpoint.allowedIps], + denyRules: endpoint.denyRules.map((rule) => policyDenyRuleFromRuntime(endpoint.protocol, endpoint.mcp, rule)), + allowEncodedSlash: endpoint.allowEncodedSlash, + persistedQueries: endpoint.persistedQueries, + graphqlPersistedQueries: Object.fromEntries( + Object.entries(endpoint.graphqlPersistedQueries).map(([name, operation]) => [ + name, + { + operationType: operation.operationType, + operationName: operation.operationName, + fields: [...operation.fields], + }, + ]), + ), + graphqlMaxBodyBytes: endpoint.graphqlMaxBodyBytes, + path: endpoint.path, + websocketCredentialRewrite: endpoint.websocketCredentialRewrite, + requestBodyCredentialRewrite: endpoint.requestBodyCredentialRewrite, + credentialSigning: endpoint.credentialSigning, + signingService: endpoint.signingService, + signingRegion: endpoint.signingRegion, + jsonRpc: + !mcpProtocol && endpoint.jsonRpcMaxBodyBytes !== 0 ? { maxBodyBytes: endpoint.jsonRpcMaxBodyBytes } : undefined, + mcp: + mcpProtocol && (endpoint.mcp !== undefined || endpoint.jsonRpcMaxBodyBytes !== 0) + ? { + versions: [...(endpoint.mcp?.versions ?? [])], + maxBodyBytes: endpoint.jsonRpcMaxBodyBytes, + strictToolNames: endpoint.mcp?.strictToolNames, + allowAllKnownMcpMethods: endpoint.mcp?.allowAllKnownMcpMethods, + } + : undefined, + credentialBinding: endpoint.credentialBinding ? { provider: endpoint.credentialBinding.provider } : undefined, + allowUninspectedCredentials: endpoint.allowUninspectedCredentials, + }; +} + +function policyAllowFromRuntime( + protocol: string, + options: RuntimeMcpOptions | undefined, + allow: RuntimeL7Allow, +): MessageInitShape { + const { tool, params } = policyParamsFromRuntime(protocol, allow.params); + return { + method: policyMCPMethod(protocol, options, allow.method, tool !== undefined), + path: allow.path, + command: allow.command, + query: policyMatcherMapFromRuntime(allow.query), + operationType: allow.operationType, + operationName: allow.operationName, + fields: [...allow.fields], + tool, + params, + }; +} + +function policyDenyRuleFromRuntime( + protocol: string, + options: RuntimeMcpOptions | undefined, + rule: RuntimeL7DenyRule, +): MessageInitShape { + const { tool, params } = policyParamsFromRuntime(protocol, rule.params); + return { + method: policyMCPMethod(protocol, options, rule.method, tool !== undefined), + path: rule.path, + command: rule.command, + query: policyMatcherMapFromRuntime(rule.query), + operationType: rule.operationType, + operationName: rule.operationName, + fields: [...rule.fields], + tool, + params, + }; +} + +function policyMCPMethod( + protocol: string, + options: RuntimeMcpOptions | undefined, + method: string, + hasTool: boolean, +): string { + if (protocol.toLowerCase() !== 'mcp') return method; + if (!hasTool && method === '*') return ''; + if (hasTool && method === 'tools/call' && options?.allowAllKnownMcpMethods === true) return ''; + return method; +} + +function policyMatcherFromRuntime(matcher: RuntimeMatcher | undefined): MatcherInit { + if (matcher && matcher.any.length > 0) { + return { kind: { case: 'any', value: { values: [...matcher.any] } } }; + } + return { kind: { case: 'glob', value: matcher?.glob ?? '' } }; +} + +function policyMatcherMapFromRuntime(matchers: Record): Record { + return Object.fromEntries( + Object.entries(matchers).map(([name, matcher]) => [name, policyMatcherFromRuntime(matcher)]), + ); +} + +type ParameterTree = { + matcher?: RuntimeMatcher; + children?: Record; +}; + +function nullPrototypeRecord(): Record { + return Object.create(null) as Record; +} + +function policyParamsFromRuntime( + protocol: string, + params: Record, +): { tool?: MatcherInit; params: Record } { + let tool: MatcherInit | undefined; + let remaining = params; + if (protocol.toLowerCase() === 'mcp') { + remaining = nullPrototypeRecord(); + for (const [name, matcher] of Object.entries(params)) { + if (name === 'name') tool = policyMatcherFromRuntime(matcher); + else remaining[name] = matcher; + } + const nested = policyNestedParamsFromRuntime(remaining); + if (nested !== undefined) return { tool, params: nested }; + } + return { tool, params: policyFlatParamsFromRuntime(remaining) }; +} + +function policyNestedParamsFromRuntime( + params: Record, +): Record | undefined { + // Parameter names are untrusted map keys. Null-prototype dictionaries keep + // names such as "__proto__" and "constructor" in the authored policy tree + // instead of resolving them through JavaScript's object prototype chain. + const root = nullPrototypeRecord(); + for (const [name, matcher] of Object.entries(params)) { + const parts = name.split('.'); + if (parts.some((part) => part.length === 0)) return undefined; + let current = root; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (part === undefined) return undefined; + const last = index === parts.length - 1; + const existing = Object.hasOwn(current, part) ? current[part] : undefined; + if (last) { + if (existing !== undefined) return undefined; + current[part] = { matcher }; + } else { + if (existing?.matcher !== undefined) return undefined; + const node = existing ?? { children: nullPrototypeRecord() }; + node.children ??= nullPrototypeRecord(); + current[part] = node; + current = node.children; + } + } + } + return Object.fromEntries(Object.entries(root).map(([name, node]) => [name, policyParameterTree(node)])); +} + +function policyParameterTree(node: ParameterTree): ParameterMatcherInit { + if (node.matcher !== undefined) { + return { kind: { case: 'matcher', value: policyMatcherFromRuntime(node.matcher) } }; + } + return { + kind: { + case: 'object', + value: { + fields: Object.fromEntries( + Object.entries(node.children ?? {}).map(([name, child]) => [name, policyParameterTree(child)]), + ), + }, + }, + }; +} + +function policyFlatParamsFromRuntime(params: Record): Record { + return Object.fromEntries( + Object.entries(params).map(([name, matcher]) => [ + name, + { kind: { case: 'matcher', value: policyMatcherFromRuntime(matcher) } }, + ]), + ); +} + +function runtimeTLSMode(mode: number): string { + if (mode === 1) return 'skip'; + if (mode === 2) return 'terminate'; + if (mode === 3) return 'passthrough'; + return ''; +} + +function runtimeEnforcementMode(mode: number): string { + if (mode === 1) return 'enforce'; + if (mode === 2) return 'audit'; + return ''; +} + +function runtimeAccessPreset(preset: number): string { + if (preset === 1) return 'read-only'; + if (preset === 2) return 'read-write'; + if (preset === 3) return 'full'; + return ''; +} + function effectiveSetting(setting: EffectiveSetting): EffectiveSettingView { return { ...(setting.value ? { value: setting.value } : {}), @@ -878,6 +1168,7 @@ export class SandboxClient { tty: spec.tty ?? false, }; if (spec.rawSpec) Object.assign(specInit, spec.rawSpec); + if (specInit.policy) specInit.policy = validatePolicyDocument(specInit.policy); const resp = await this.grpc.createSandbox({ workspaceScope: workspaceScope(spec), @@ -902,7 +1193,7 @@ export class SandboxClient { providers: spec.providers ?? [], command: spec.command ?? [], tty: spec.tty ?? false, - policy: spec.policy, + policy: spec.policy ? validatePolicyDocument(spec.policy) : undefined, }, workloadTemplate: spec.workloadTemplate, }); @@ -1497,13 +1788,13 @@ export class SandboxClient { // applied policy hash is observed. async setPolicy( name: string, - policy: MessageInitShape, + policy: MessageInitShape, options?: SetPolicyOptions | null, ): Promise { try { const resp = await this.grpc.updateConfig({ ...sandboxTarget(name, options), - policy, + policy: validatePolicyDocument(policy), global: false, expectedResourceVersion: versionPin(options?.expectedResourceVersion), }); diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index d858cd75b..d8c41a262 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -22,6 +22,7 @@ export type { HealthStatus, ListOptions, Page, + PolicyDocument, PolicySourceName, ProviderChange, ProviderChangeOptions, @@ -29,7 +30,6 @@ export type { SandboxConfig, SandboxFromTemplateSpec, SandboxPhaseName, - SandboxPolicy, SandboxRef, SandboxResources, SandboxServiceLevel, @@ -50,7 +50,14 @@ export type { WaitOptions, WorkspaceListScope, } from './client.js'; -export { errorCode, OpenShellClient, Pager, SandboxClient, SandboxTemplateClient } from './client.js'; +export { + errorCode, + OpenShellClient, + Pager, + SandboxClient, + SandboxTemplateClient, + validatePolicyDocument, +} from './client.js'; export type { ErrorInfo, FieldViolation, SdkErrorCode } from './errors.js'; export { fromConnect, SdkError } from './errors.js'; export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js'; diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 89e6223ac..49a29eeaf 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -162,7 +162,7 @@ Read the published [policy schema reference](https://docs.nvidia.com/openshell/l Key sections to reference: - **Policy Schema Reference** — top-level structure - **`network_policies`** — rule structure -- **`NetworkEndpoint`** fields — host, port, protocol, tls, enforcement, access, rules, allowed_ips +- **`NetworkEndpoint`** fields — host, ports, protocol, tls, enforcement, access, rules, allowed_ips - **`L7Rule` / `L7Allow`** — method + path matching - **Access Presets** — `read-only`, `read-write`, `full` - **Private IP Access via `allowed_ips`** — CIDR allowlist for private IP space @@ -272,7 +272,7 @@ network_policies: name: endpoints: - host: - port: + ports: [] protocol: rest # Required for L7 inspection tls: terminate # Required for HTTPS + L7 enforcement: enforce # or audit @@ -317,7 +317,7 @@ github_api: name: github_api endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-write @@ -349,7 +349,7 @@ internal_api: name: internal_api endpoints: - host: api.internal.corp - port: 8080 + ports: [8080] allowed_ips: - "10.0.5.0/24" binaries: @@ -399,7 +399,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt ### Structural Checks - [ ] Every policy has `name`, `endpoints`, and `binaries` -- [ ] Every endpoint has `host` and `port` +- [ ] Every endpoint has `host` and nonempty `ports` - [ ] Every binary has `path` - [ ] Policy key matches `name` field - [ ] Every middleware selector has at most 32 combined `include` and `exclude` patterns @@ -450,7 +450,7 @@ The policy needs to go somewhere. Determine which mode applies: 1. **Read the existing file** to understand current state: - What policies already exist under `network_policies` - What the `filesystem_policy`, `landlock`, and `process` sections look like - - Whether the file uses compact (`{ host: ..., port: ... }`) or expanded YAML style + - Whether the file uses compact (`{ host: ..., ports: [...] }`) or expanded YAML style 2. **Check for conflicts**: - Does a policy with the same key already exist? If so, ask the user whether to **replace** it, **merge** new endpoints/binaries into it, or use a different key. @@ -465,7 +465,7 @@ The policy needs to go somewhere. Determine which mode applies: 3. **Apply the change**: - **Adding a new policy**: Insert the new policy block under `network_policies`, maintaining the file's existing indentation and style. - - **Modifying an existing policy**: Edit the specific policy in place — add/remove endpoints, change access presets, update rules, add binaries, etc. A rule authorizes every binary it lists to reach every endpoint and port it lists, so adding one binary grants it all of that rule's endpoints, and adding one endpoint grants it to all of that rule's binaries. State the resulting pairs to the user before writing them. When the user wants a binary to reach only part of a rule's endpoints, put that binary and those endpoints in a separate rule instead of extending the existing one. An empty `binaries` list means any binary, so leaving it off widens the rule to every process. + - **Modifying an existing policy**: Edit the specific policy in place — add/remove endpoints, change access presets, update rules, add binaries, etc. A rule authorizes every binary it lists to reach every endpoint and port it lists, so adding one binary grants it all of that rule's endpoints, and adding one endpoint grants it to all of that rule's binaries. State the resulting pairs to the user before writing them. When the user wants a binary to reach only part of a rule's endpoints, put that binary and those endpoints in a separate rule instead of extending the existing one. An omitted or empty `binaries` list matches no executable and grants no network access. - **Removing a policy**: Delete the policy block if the user asks. 4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, or other policies unless the user explicitly asks. @@ -543,7 +543,7 @@ After presenting or applying the policy, ask if the user wants to: my_api: name: my_api endpoints: - - { host: api.example.com, port: 443 } + - { host: api.example.com, ports: [443] } binaries: - { path: /usr/bin/curl } ``` @@ -555,7 +555,7 @@ my_api_readonly: name: my_api_readonly endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest tls: terminate enforcement: enforce @@ -571,7 +571,7 @@ my_api_custom: name: my_api_custom endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest tls: terminate enforcement: enforce @@ -594,7 +594,7 @@ internal_svc: name: internal_svc endpoints: - host: api.internal.svc - port: 8080 + ports: [8080] protocol: rest enforcement: enforce rules: @@ -615,7 +615,7 @@ internal_db: name: internal_db endpoints: - host: db.internal.corp - port: 5432 + ports: [5432] allowed_ips: - "10.0.5.0/24" binaries: @@ -628,7 +628,7 @@ internal_db: private_services: name: private_services endpoints: - - port: 8080 + - ports: [8080] allowed_ips: - "10.0.5.0/24" - "10.0.6.0/24" diff --git a/skills/generate-sandbox-policy/examples.md b/skills/generate-sandbox-policy/examples.md index 2cbc21b6b..9aaa976bb 100644 --- a/skills/generate-sandbox-policy/examples.md +++ b/skills/generate-sandbox-policy/examples.md @@ -24,8 +24,8 @@ network_policies: claude_anthropic: name: claude_anthropic endpoints: - - { host: api.anthropic.com, port: 443 } - - { host: statsig.anthropic.com, port: 443 } + - { host: api.anthropic.com, ports: [443] } + - { host: statsig.anthropic.com, ports: [443] } binaries: - { path: /usr/local/bin/claude } ``` @@ -46,7 +46,7 @@ network_policies: name: github_readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only @@ -68,7 +68,7 @@ network_policies: name: nvidia_api endpoints: - host: integrate.api.nvidia.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: full @@ -90,7 +90,7 @@ network_policies: name: data_service endpoints: - host: data-svc.internal - port: 8080 + ports: [8080] protocol: rest enforcement: audit access: read-write @@ -112,12 +112,12 @@ network_policies: name: code_hosting_readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only - host: api.gitlab.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only @@ -156,7 +156,7 @@ network_policies: name: openai_inference endpoints: - host: api.openai.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -202,7 +202,7 @@ network_policies: name: nvidia_inference endpoints: - host: integrate.api.nvidia.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -235,7 +235,7 @@ network_policies: name: github_with_issues endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -300,7 +300,7 @@ network_policies: name: github_api_readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only @@ -351,7 +351,7 @@ network_policies: name: nvidia_inference endpoints: - host: integrate.api.nvidia.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -411,7 +411,7 @@ network_policies: name: data_service endpoints: - host: data-svc.internal - port: 8080 + ports: [8080] protocol: rest enforcement: audit access: read-write @@ -495,7 +495,7 @@ network_policies: name: project_management endpoints: - host: pm-api.example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -563,8 +563,8 @@ network_policies: claude_anthropic: name: claude_anthropic endpoints: - - { host: api.anthropic.com, port: 443 } - - { host: statsig.anthropic.com, port: 443 } + - { host: api.anthropic.com, ports: [443] } + - { host: statsig.anthropic.com, ports: [443] } binaries: - { path: /usr/local/bin/claude } @@ -572,7 +572,7 @@ network_policies: name: internal_docs_readonly endpoints: - host: docs.internal - port: 8080 + ports: [8080] protocol: rest enforcement: enforce access: read-only @@ -598,7 +598,7 @@ network_policies: name: corp_metrics endpoints: - host: metrics.corp.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: @@ -632,7 +632,7 @@ network_policies: name: internal_api endpoints: - host: api.internal.corp - port: 8080 + ports: [8080] allowed_ips: - "10.0.5.0/24" binaries: @@ -655,7 +655,7 @@ network_policies: private_services: name: private_services endpoints: - - port: 8080 + - ports: [8080] allowed_ips: - "10.0.5.0/24" - "10.0.6.0/24" @@ -682,7 +682,7 @@ network_policies: name: db_proxy_readonly endpoints: - host: db-proxy.internal - port: 3128 + ports: [3128] protocol: rest enforcement: enforce access: read-only @@ -707,7 +707,7 @@ network_policies: name: metrics_internal endpoints: - host: metrics.internal - port: 9090 + ports: [9090] allowed_ips: - "10.0.5.20" binaries: @@ -740,7 +740,7 @@ An exact IP is treated as `/32` — only that specific address is permitted. name: github_readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only @@ -768,8 +768,8 @@ Before: claude_code: name: claude_code endpoints: - - { host: api.anthropic.com, port: 443 } - - { host: statsig.anthropic.com, port: 443 } + - { host: api.anthropic.com, ports: [443] } + - { host: statsig.anthropic.com, ports: [443] } binaries: - { path: /usr/local/bin/claude } ``` @@ -779,9 +779,9 @@ After: claude_code: name: claude_code endpoints: - - { host: api.anthropic.com, port: 443 } - - { host: statsig.anthropic.com, port: 443 } - - { host: sentry.io, port: 443 } + - { host: api.anthropic.com, ports: [443] } + - { host: statsig.anthropic.com, ports: [443] } + - { host: sentry.io, ports: [443] } binaries: - { path: /usr/local/bin/claude } ``` @@ -836,7 +836,7 @@ network_policies: name: github_readonly endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-only @@ -847,7 +847,7 @@ network_policies: name: anthropic_full endpoints: - host: api.anthropic.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: full diff --git a/tasks/scripts/generate_python_proto.py b/tasks/scripts/generate_python_proto.py index a1b61a066..03ef7380b 100644 --- a/tasks/scripts/generate_python_proto.py +++ b/tasks/scripts/generate_python_proto.py @@ -77,6 +77,18 @@ LINE_REWRITES = { def main() -> None: + subprocess.run( + [ + sys.executable, + "-m", + "grpc_tools.protoc", + "-Iproto", + "--python_out=python", + "--pyi_out=python", + "proto/buf/validate/validate.proto", + ], + check=True, + ) subprocess.run( [ sys.executable, diff --git a/tasks/scripts/verify-python-wheel.py b/tasks/scripts/verify-python-wheel.py index 0539bd88a..41e3e10c8 100644 --- a/tasks/scripts/verify-python-wheel.py +++ b/tasks/scripts/verify-python-wheel.py @@ -13,7 +13,7 @@ from email.parser import Parser from pathlib import Path from zipfile import BadZipFile, ZipFile -PROTO_STEMS = ("datamodel", "openshell", "options", "sandbox") +PROTO_STEMS = ("datamodel", "openshell", "options", "policy", "sandbox") BYTECODE_SUFFIXES = (".pyc", ".pyo") NATIVE_SUFFIXES = (".dll", ".dylib", ".exe", ".pyd", ".so") @@ -30,6 +30,10 @@ def _find_wheel(path: Path) -> Path: def _required_files() -> set[str]: files = { + "buf/__init__.py", + "buf/validate/__init__.py", + "buf/validate/validate_pb2.py", + "buf/validate/validate_pb2.pyi", "openshell/__init__.py", "openshell/sandbox.py", "openshell/py.typed", diff --git a/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs b/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs index cddc5b7bb..29a99694a 100644 --- a/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs +++ b/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs @@ -180,7 +180,7 @@ credentials: secret: true endpoints: - host: keycloak.test.invalid - port: 443 + ports: [443] protocol: rest access: read-only enforcement: enforce diff --git a/uv.lock b/uv.lock index 063ef739e..6ac22bcb9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12' or python_full_version >= '3.14'", +] [[package]] name = "anyio" @@ -15,6 +20,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "cel-python" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "lark" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "types-python-dateutil" }, + { name = "types-pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/87/85a1b99b98f6466bb87d40df636626385945ae82348e82cd97d44313f612/cel_python-0.2.0.tar.gz", hash = "sha256:75de72a5cf223ec690b236f0cc24da267219e667bd3e7f8f4f20595fcc1c0c0f", size = 67185, upload-time = "2025-02-14T11:42:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/28/08871462a0347b3e707658a8308be6f979167488a2196f93b402c2ea7170/cel_python-0.2.0-py3-none-any.whl", hash = "sha256:478ff73def7b39d51e6982f95d937a57c2b088c491c578fe5cecdbd79f476f60", size = 71337, upload-time = "2025-02-14T11:42:19.996Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -143,6 +165,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "google-re2" +version = "1.1.20251105" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/60/805c654ba53d685513df955ee745f71920fe8e6a284faf0f9b9dc19b659c/google_re2-1.1.20251105.tar.gz", hash = "sha256:1db14a292ee8303b91e91e7c37e05ac17d3c467f29416c79ac70a78be3e65bda", size = 11676, upload-time = "2025-11-05T14:58:07.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/4d/203a08dab1bdb5c83b46dd424c01a789ecb5a37dbc80f33d016bd116a9d7/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:329efa209ea7baa44f0facf0402fa34e655dc97fdeb10d0b83fc06354f5575fd", size = 483717, upload-time = "2025-11-05T14:57:04.808Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/466026b43ff5c7d740f5ede090992ec63b60d1810ab14fe35dfc00677e0a/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:aa2ad5f6f48921ec137a7b7f1b1da903ddef8627a2dc30bc878a9a69d9925719", size = 515547, upload-time = "2025-11-05T14:57:06.013Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6a/c6c9fdb00c98990e4f7a6cd650e209d7b5d2754ca0404b72c69ac9909a69/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ac1cb2526cc88f050a0661fc7245ad009ee454bddc541b2e653f1d007585000d", size = 485396, upload-time = "2025-11-05T14:57:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f6/529c44f607c47f96cfa29c1fe3a690fe75b2fdb48e9b0d6b54e5f0a75e59/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:50c7205182ad66c23c07abe8072f720ca2f7d595b61e28fd9b63623614f9afd6", size = 517150, upload-time = "2025-11-05T14:57:09.376Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/ccc07860e31ab81965c63f9ed4eb69ea0d3449a9b4e1610f71883694bbe8/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:4cb5acee61e35772503b8b1db3c592a46b8e6a9bc0ab54d7d6233654ea2bf93d", size = 482807, upload-time = "2025-11-05T14:57:11.057Z" }, + { url = "https://files.pythonhosted.org/packages/bd/43/5fb20d16664457f61670bdd95f39039d43ee8b7732511c688e2f322a4317/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:1617097d63620c2d46bdfc0e48f24f66cd341664fc75718636d234f67473fe7f", size = 508839, upload-time = "2025-11-05T14:57:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f2/6e470338271e164dd3c5e508876f99aec3ed23bf419c7d54a5672fd5b05f/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a5610b26742b90cb1d64ead2b16fe0e3bd7e67add03fd3779cd1b85e401661", size = 573718, upload-time = "2025-11-05T14:57:13.635Z" }, + { url = "https://files.pythonhosted.org/packages/91/21/4566fc344c21cf3c49082d13ddab785994b5e3b8b7fd4631242538f698a2/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03156291269f145eccddff63118f2df02d395792f51fc039f09955818943815a", size = 590749, upload-time = "2025-11-05T14:57:14.864Z" }, + { url = "https://files.pythonhosted.org/packages/94/19/5981fb798bb8d08933b815b1fd9e55d179c380b9d8c21a49197b9b7c5967/google_re2-1.1.20251105-1-cp311-cp311-win32.whl", hash = "sha256:54f51762b51dc238eceddf49b56cc2b64594fe72d9328c1c39d615aa990e1f87", size = 434066, upload-time = "2025-11-05T14:57:16.22Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/f83053a36cfc4762d843748e4f7a9c1141937dcf74cd6fc3f4598292dda3/google_re2-1.1.20251105-1-cp311-cp311-win_amd64.whl", hash = "sha256:f5f856ff5036a8f22b3bad57f376d4e3b97b59b64f311bdb1f83c8dabded2492", size = 491025, upload-time = "2025-11-05T14:57:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/4315c3b38f42f9a2888fa76260545c98547502f1c35aa63a672d39011b2e/google_re2-1.1.20251105-1-cp311-cp311-win_arm64.whl", hash = "sha256:913864f97de4151eaa8bb7746ca230fd193656501e07fb658ce2cd46d4f6efcc", size = 642194, upload-time = "2025-11-05T14:57:19.374Z" }, + { url = "https://files.pythonhosted.org/packages/67/20/73b487538e9107c2fd96aed737e3f3890dfce3e292622e4ffb2f9c810ee5/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b30f09b4d63249c72e65ccae4cbf6b331b48c22fc7cb439f1d85f347b9d07ceb", size = 485591, upload-time = "2025-11-05T14:57:20.961Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9a/ca3a993bdb5dc6d5b2616b9657b2872a83d1827f8bd3ab50cd629eb751c7/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9a77892c524b8bdf3d47d7cad1cc2ac3a0108bdd65007ef4c02888fa46baf8ee", size = 518780, upload-time = "2025-11-05T14:57:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/df/37/b2e367987371514253ec9e514637f457deaacb7acc1c900814f3a6421e0f/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a3ac51b28cbf25c100dfd8849212d878d7005d1d4a7e129a10789043c56b6021", size = 486966, upload-time = "2025-11-05T14:57:24.575Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/1db6742943c0ac254bfb7d8a37a5d3f73f016a65cfa1f84fe3a0451820f6/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:9f7158afc9825ac2654c6561aea94a1f7edb5b5b88e6e3639bb80bb817d102ac", size = 520225, upload-time = "2025-11-05T14:57:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0a/0747c92dbebe2c09a26bd7386d372b5c5a9926236b4f3d69bb8f15db05cb/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:5320da07dc3b7ac7f407514f42ac17d67e771ac7c7562d449571185e6fb601b2", size = 482943, upload-time = "2025-11-05T14:57:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/6bfc6838bb6cb561824ac03deeab2bd11d5d9a93505f536c8fa2f6bd46c4/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:5a4e5785bc30d52ce655d805b07ad2d8a4905429a5f690ae9c2f1caa76665709", size = 510384, upload-time = "2025-11-05T14:57:29.139Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/6add090c917ee39f6f0be753037cafceb3bad904b424efc155fb38082635/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b7a3b90f747130310d4b3b8e19ebb845d0d97c1deb63b36f76c7242dacbd736", size = 572446, upload-time = "2025-11-05T14:57:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/8b1ccbeade96a21435d55b5185cd6d9b2ceab5a9af998a4d9099e0540759/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:809c5fa5d08279413b29c2e2c5c528e85cd94a0e0fd897db595a0c09eeee2782", size = 591348, upload-time = "2025-11-05T14:57:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/7bdd7a1ae7828b613011da808eafec4da3132f43c3be6af5e0bd670ebe8b/google_re2-1.1.20251105-1-cp312-cp312-win32.whl", hash = "sha256:d8424e63a9ec0fe5bde03d97876b2431f8a746af33eb475fa1ae39144bd05b2a", size = 433787, upload-time = "2025-11-05T14:57:33.071Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/5dd951c35acaabfe87c67228b9af2cdcd7779d9167edbe6b9094b8a8e529/google_re2-1.1.20251105-1-cp312-cp312-win_amd64.whl", hash = "sha256:062313c309f93dfeb6966372f4c446580e98879133ec155522eea8aaf568a5cd", size = 491726, upload-time = "2025-11-05T14:57:34.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/8d/c1afd29fc2cb475fd4c634f3d3c8099c0efb662362c10b27a9eaf11c9357/google_re2-1.1.20251105-1-cp312-cp312-win_arm64.whl", hash = "sha256:558f144b26a9555ae4e9467cc3aa3299a8ce13217f328b21ae326ca0633be19b", size = 642673, upload-time = "2025-11-05T14:57:35.693Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/c441722196598fc3de0f654606ad9975a968c71dc27f516b5a4c9ebb94fd/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:9f3cf610e857a7d6f02916cf2b7fc159a5429b8bcb23164500d46e5e233f2924", size = 485549, upload-time = "2025-11-05T14:57:36.939Z" }, + { url = "https://files.pythonhosted.org/packages/ea/87/cf588255e5ada1dfb555cc96de35be78438bb0b6faba64df5fe91cecc224/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a21c2807bf4d5d00f206a4ecb3b043aad674e28c451b697b740280f608872078", size = 518840, upload-time = "2025-11-05T14:57:38.115Z" }, + { url = "https://files.pythonhosted.org/packages/0d/39/da66e4ca9be0c51546efc6fb39cf1683c4be8245d8199cb54a9808e8d5fa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8314144eefeee7b88b742081c2038418f677e63901039ca9dbfbc0c5bb6d2911", size = 487037, upload-time = "2025-11-05T14:57:39.467Z" }, + { url = "https://files.pythonhosted.org/packages/75/dd/24ba65692dd58dca6ff178428551f4e9b776d1489a1251f5c8539e598baa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:28a46be978e53c772139d0f5c9ba69f53563fcdd4225407e4d34d51208b828f1", size = 520285, upload-time = "2025-11-05T14:57:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/61/12/cfdbb92bed24af6474970a75a26145c424f98cfbcc633fdd185985f0efe0/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:83292e23963aa1b219d5f64a65365b0880448a6a060276027b55270bc5b18c7e", size = 482981, upload-time = "2025-11-05T14:57:41.928Z" }, + { url = "https://files.pythonhosted.org/packages/97/bf/5fc32ded9279e69a87b88d7261e7e77e2e26325d4e27ca1303a3215e430a/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1920b15dc9b1bdfeca5aa2c60900373c6f27cd1056d53cd299456ea5540a6fff", size = 510366, upload-time = "2025-11-05T14:57:43.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/f927ddc7aef1b8d7ccc8a649c335d311f29f3dea658209e30e37720e4891/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b1458d9ca588124cd61aa1bf5388a216e1247e7d474f8e5e1530498044f5c87", size = 572390, upload-time = "2025-11-05T14:57:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8c/23075e589038284c9487f41cde531d35873f9da622fb4ac7d1d97bd9086e/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a52cb204e49d20cdbb66faf394d57f476e96c39c23a328442ab0194fc6bd1a2b", size = 591386, upload-time = "2025-11-05T14:57:45.713Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/858453ef689f6b9895cd02b466836a9d1a6e4ba535d1a275b01bf73baa1d/google_re2-1.1.20251105-1-cp313-cp313-win32.whl", hash = "sha256:67c5c73d7ebcf3f0e0a3b528b41bd8c6c04900f1598aebf05bbdf15a06cf5f9a", size = 433807, upload-time = "2025-11-05T14:57:46.92Z" }, + { url = "https://files.pythonhosted.org/packages/08/24/6ea87fe682e115ffd296e91eb5c5a266349d1ee8414ce8ece3f99ec1ac84/google_re2-1.1.20251105-1-cp313-cp313-win_amd64.whl", hash = "sha256:0bcba63ad3ea8926fb0c71bb5044e33d405bb9395f5b5444393cd5f28f0bf6d3", size = 491734, upload-time = "2025-11-05T14:57:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/34/85/32ba71b06f3cf5f9856ae95b3d6463b971742453631a5ae2c5be338ea377/google_re2-1.1.20251105-1-cp313-cp313-win_arm64.whl", hash = "sha256:64ee189ea857f2126c5e42073cfa9b03e9f4cbaf073edbedb575059074841aa0", size = 642654, upload-time = "2025-11-05T14:57:49.602Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7f/7eb238bdcd06182b5f427afd305cf413b7cf4ea71047308bbf35912cf923/google_re2-1.1.20251105-1-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:cc151cf6a585d9ebe711da32b23683fcff40f78db8c8587c7f4b209ef4658809", size = 484719, upload-time = "2025-11-05T14:57:51.326Z" }, + { url = "https://files.pythonhosted.org/packages/6d/62/eed28eab67f939f4b9383c47b1db11638ade6ac30785c15cb960de85ba43/google_re2-1.1.20251105-1-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:7e2186d2c90488c1e11895343941f35ca2f58e9ba6c6b034fd531abe22ef77cc", size = 517698, upload-time = "2025-11-05T14:57:52.597Z" }, + { url = "https://files.pythonhosted.org/packages/f7/16/a1e6768513f788bf9c67a1cfe379ef34a793983eee46e4b653e42b558b78/google_re2-1.1.20251105-1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:41be22359c3dceb582937739b4365dd8e279de24ad0a5b10e653503abaff2ed7", size = 486421, upload-time = "2025-11-05T14:57:53.852Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fc/7a97ffd36d451e5a8bfaff2f9022b14807795d588f98227ff96e8da99856/google_re2-1.1.20251105-1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f3168d7bbac247c862ea85b2f3c011d3a04bedcb6892b37f14d488f4133b206e", size = 519037, upload-time = "2025-11-05T14:57:55.078Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ee/8b6f7d94bb689dafdf60de8dd8f8f6296ad40d4d15c933fcda4da7a3a06b/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:79ce664038194a31bbcf422137f9607ae3d9946a5cff98cf0efbeb7f9411e64b", size = 483373, upload-time = "2025-11-05T14:57:56.297Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a6/16a09e03d1de128f821869e4252688c21319f5017d9209f4d0e71ea5c951/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:0476b07421b8882b279d5ceb5b760c15c62d581ded95274697fc1227e3869ee6", size = 510167, upload-time = "2025-11-05T14:57:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9d/213dce5de401527369fb5af11096b18c06001d9eb71f3318fe5eba1ec706/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85feec3161ffdc12f6b144e37a2f91f80b771c72ffadde60191e89a49f6d7e81", size = 573176, upload-time = "2025-11-05T14:57:59.211Z" }, + { url = "https://files.pythonhosted.org/packages/03/be/a8def96aa4a80b233e105767d22e3de961dcde5a04f0a05cb4f3ddb4df78/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7bfaa2cf55daf0c5c650e68526bb20b61e37d7f3ae53f6893013acc1c91c116", size = 591483, upload-time = "2025-11-05T14:58:00.416Z" }, + { url = "https://files.pythonhosted.org/packages/14/ea/144bbc4b9359da89aec07b4c2a91a6bfe7119914885386577c665b07bb01/google_re2-1.1.20251105-1-cp314-cp314-win32.whl", hash = "sha256:214c1accdc60fff9ce1bf812b157147ca361844f496ed9e0d5f357b0e562ced8", size = 433773, upload-time = "2025-11-05T14:58:01.594Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/74e301211699f1b650ba7690a3e4e52146ac4266fcd62f3ea0a945b9eda4/google_re2-1.1.20251105-1-cp314-cp314-win_amd64.whl", hash = "sha256:6d4d5fdadd329a2ed193463899d00ef2fd126172f36a4c01c9def271f19801b6", size = 491893, upload-time = "2025-11-05T14:58:02.969Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d1/4adcfcb9c95e3d064c9f7aaf6cb3a4fc842d86115014b9d4094db4d465b5/google_re2-1.1.20251105-1-cp314-cp314-win_arm64.whl", hash = "sha256:1d27f3a2a947ec1f721d0f14f661108acfd4f4d34f357ce28db951cc036656e5", size = 643093, upload-time = "2025-11-05T14:58:05.761Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.75.3" @@ -314,6 +388,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "lark" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/1d/29681d27b84e384ea50b5546e9f0089126afbc91754db4ca89593fcfd0e8/lark-0.12.0.tar.gz", hash = "sha256:7da76fcfddadabbbbfd949bbae221efd33938451d90b1fefbbc423c3cccf48ef", size = 235168, upload-time = "2021-11-12T11:15:32.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/39/cef2ccdfd984ae3cf93878d050c1b7c9354dd9493ce83fd9bb33a41f7a33/lark-0.12.0-py2.py3-none-any.whl", hash = "sha256:ed1d891cbcf5151ead1c1d14663bf542443e579e63a76ae175b01b899bd854ca", size = 103540, upload-time = "2021-11-12T11:15:34.408Z" }, +] + [[package]] name = "openshell" source = { editable = "." } @@ -323,6 +415,7 @@ dependencies = [ { name = "grpcio" }, { name = "httpx" }, { name = "protobuf" }, + { name = "protovalidate" }, ] [package.dev-dependencies] @@ -345,7 +438,8 @@ requires-dist = [ { name = "googleapis-common-protos", specifier = ">=1.63" }, { name = "grpcio", specifier = ">=1.60" }, { name = "httpx", specifier = ">=0.27" }, - { name = "protobuf", specifier = ">=4.25" }, + { name = "protobuf", specifier = ">=6.31.1,<7" }, + { name = "protovalidate", specifier = "==1.0.0" }, ] [package.metadata.requires-dev] @@ -395,6 +489,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] +[[package]] +name = "protovalidate" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cel-python" }, + { name = "google-re2" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/98/1595ae90c4a29c625580ee84415bb0c752e09c6c7aa13595e8ea94a7c929/protovalidate-1.0.0.tar.gz", hash = "sha256:926f7a212fed9190d00cc076fa24ef5e48a404b5577465028697f4dea8c4a507", size = 215286, upload-time = "2025-09-12T16:28:02.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/1d/30a86726b317593469eb526c8ca25dd8ce7f7b9f4237137fedb1f352ffff/protovalidate-1.0.0-py3-none-any.whl", hash = "sha256:933818942700c85d4a47f1030e61f59d7bd9a8c1572e9dc822f98eef45a39d9e", size = 29478, upload-time = "2025-09-12T16:28:01.201Z" }, +] + [[package]] name = "pyelftools" version = "0.32" @@ -469,6 +577,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -539,6 +659,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -617,6 +746,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" }, ] +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20260807" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/4e/b3fa538f9cb38dfece0d6ccf6d3d0d925bdedb144fb9c8129dfc007cd003/types_python_dateutil-2.9.0.20260807.tar.gz", hash = "sha256:e0b8a90d464c8684c66b7b8e4556d9074afdddcc56ca45323f0987134f9e7034", size = 17618, upload-time = "2026-08-07T04:17:13.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/5e/3715867caea2f4cea56ccb04c851cde23ed063449c3b004c7a047f20dd48/types_python_dateutil-2.9.0.20260807-py3-none-any.whl", hash = "sha256:54aa3707350ed7a9cc0776fd2f6739679d6967d11b40150985e81edcb86df4db", size = 18486, upload-time = "2026-08-07T04:17:12.504Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260518"