diff --git a/.audit/3388-public-policy-proto.tsv b/.audit/3388-public-policy-proto.tsv new file mode 100644 index 0000000000..0bad73a74b --- /dev/null +++ b/.audit/3388-public-policy-proto.tsv @@ -0,0 +1,49 @@ +ts phase decision why evidence result +2026-09-16T00:00:00Z frame Treat issue #3388 as a staged implementation and make the Rust API path work before expanding every SDK YAML helper The issue spans several SDKs and many public messages; a compiling vertical slice exposes the schema and ownership decisions for review GitHub issue #3388 open +2026-09-16T23:12:10Z audit Supersede the placeholder timestamp in the first row with this actual start time The log is append-only, so the incorrect placeholder remains visible and this row corrects it .audit/3388-public-policy-proto.tsv corrected +2026-09-16T23:12:59Z baseline Base the implementation branch on current origin/main after PR #3334 merged The public proto work should extend the canonical authored schema crate instead of recreating its parser origin/main at c502be9fd, merge commit 2ccef9776 branch feat/3388-public-policy-proto/pimlock created +2026-09-16T23:24:18Z contract Keep documented policy choices as validated strings and use message presence for MCP versions This preserves current YAML vocabulary and SDK forward compatibility while fixing the repeated-field presence gap proto/policy.proto public package compiles; schema tests pass +2026-09-16T23:24:18Z migration Retain the #3334 Serde DTO as a temporary compatibility codec around the generated message A working public API boundary can land before the generic-tree-only codec rewrite without changing the wire contract crates/openshell-policy-schema/src/generated.rs generated-to-YAML round trip works +2026-09-17T00:08:00Z boundary Persist public policy in SandboxSpec but keep revision rows, compute-driver messages, and supervisor configuration internal The public contract must exclude derived authority while enforcement still needs normalized provenance-bearing state cargo check --workspace production Rust workspace compiles across the public/internal boundary +2026-09-17T00:11:00Z providers Reuse the public endpoint and binary messages in ProviderProfile and lower them through openshell-policy at the gateway boundary Provider profiles author the same policy language and should not maintain a second generated schema crates/openshell-providers/src/profiles.rs provider profile API uses openshell.policy.v1 while its YAML DTO remains provider-owned +2026-09-17T00:15:00Z validation Do not add generated proto validation annotations in this slice Most security invariants are contextual across protocol, endpoint, provider attachment, and effective-policy composition; generated field checks would supplement but cannot replace the existing validators architecture/security-policy.md intrinsic schema validation remains in openshell-policy-schema and contextual validation remains in openshell-policy/gateway +2026-09-17T00:18:00Z compatibility Align public field numbers with the existing runtime messages wherever semantics and wire types match Although the new major version permits source breaks, retaining compatible tags reduces persisted-record and mixed-client migration risk; intentionally reshaped JSON-RPC and MCP submessages remain explicit conversion boundaries proto/policy.proto common endpoint and middleware fields retain their historical wire numbers; internal authority tags are reserved +2026-09-17T00:20:00Z verification Treat raw generated SDK availability as complete for the vertical slice and record curated/test-fixture migration as follow-up Rust production crates compile and Go/TypeScript generate the public package, but legacy Rust test fixtures and Go curated converters still assume runtime messages cargo test --workspace --no-run; mise run go:ci known incomplete migration remains visible and blocks declaring the whole issue complete +2026-09-17T00:27:00Z api-review Preserve historical endpoint and middleware tag numbers for semantically identical public fields The API review found that aesthetic renumbering would turn an avoidable source break into a wire and persistence break; MCP and JSON-RPC remain intentionally reshaped where authored presence cannot use the runtime representation proto/sandbox.proto compared with proto/policy.proto matching fields now retain tags and runtime-only authority tags 18 and 26 are reserved +2026-09-17T00:46:00Z audit-fix Lower edited public draft rules before encoding the internal DraftChunkRecord The independent audit found EditDraftChunk persisted openshell.policy.v1 bytes that later code decodes as openshell.sandbox.v1; MCP and recursive matcher layouts are not wire-compatible crates/openshell-server/src/grpc/policy.rs handle_edit_draft_chunk public edit input now crosses the same checked lowering boundary before internal persistence +2026-09-17T01:02:00Z audit-fix Make provider-profile protobuf conversion fallible at untrusted ingress A malformed public endpoint must produce an import diagnostic instead of panicking inside the gateway crates/openshell-providers/src/profiles.rs; crates/openshell-server/src/grpc/provider.rs server import uses try_from_proto; the infallible wrapper is restricted to trusted validated values +2026-09-17T01:03:00Z audit-fix Generate the public policy package for Python and rewrite its relative import The API binding imports policy_pb2 after openshell.proto begins referencing the public package tasks/scripts/generate_python_proto.py manual Python generation completes with package-local imports +2026-09-17T01:04:00Z audit-fix Reject middleware integers that cannot round-trip exactly through google.protobuf.Value Struct stores numbers as f64, so accepting a larger YAML integer would silently change policy semantics crates/openshell-policy-schema/src/generated.rs 9007199254740993 is rejected by a regression test +2026-09-17T01:05:00Z audit-fix Preserve present-empty MCP versions so validation can reject them Only omission selects the pinned default; an explicitly empty authored list is invalid and must not normalize to a default crates/openshell-core/src/mcp.rs; crates/openshell-providers/src/profiles.rs omitted versions materialize the default while present-empty public versions fail conversion +2026-09-17T01:06:00Z persistence Require a clean gateway database for this new-major prototype unless a one-time migration is added Persisted SandboxSpec and StoredProviderProfile now embed public messages whose MCP, matcher, and JSON-RPC layouts are not wire-compatible with old records architecture/security-policy.md policy revision rows remain compatible; mixed-version rollout and old embedded records are explicitly unsupported +2026-09-17T01:14:00Z audit-fix Lower standalone authored rules through a complete public SandboxPolicy The rule-only adapter previously bypassed document-level intrinsic validation, allowing present-empty MCP versions to be normalized before rejection crates/openshell-policy/src/lib.rs; provider boundary regression test provider imports and draft edits now apply the same authored validation as complete policy updates +2026-09-17T01:32:00Z verification Open a draft PR with production Rust checks green and fixture/curated-SDK failures explicit The implementation is a usable architecture prototype, but converting 174 Rust test fixtures and the curated Go model is separate mechanical work that should not be hidden behind a green claim cargo check --workspace; cargo clippy --workspace --lib --bins; cargo test --workspace --no-run; mise run go:ci production Rust and focused boundary tests pass; all-target Rust and curated Go remain draft blockers +2026-09-18T04:05:12Z compatibility Supersede the temporary YAML compatibility codec with strict descriptor-driven protobuf-shaped YAML The team chose one language-neutral authored shape and accepted breaking scalar and shorthand spellings crates/openshell-policy-schema/src/generated.rs bounded YAML decodes directly through the openshell.policy.v1 descriptor without a rewrite shim +2026-09-18T04:05:12Z presence Supersede present-empty MCP versions rejection; omitted null and empty select the same pinned default Proto3 repeated fields do not preserve presence and policy YAML now follows protobuf semantics proto/policy.proto; crates/openshell-core/src/mcp.rs MCP versions is a repeated string and all absence-equivalent shapes materialize the default +2026-09-18T04:05:12Z persistence Supersede the clean-database requirement by preserving internal durable sandbox and provider-profile wire shapes The public schema belongs at API and file boundaries; durable gateway state remains an internal implementation detail crates/openshell-server/proto/storage.proto; crates/openshell-server/src/storage_proto.rs existing database rows decode without a one-time migration while API reads project to public messages +2026-09-18T04:05:12Z sdk Remove runtime-only authority fields from the curated Go policy model and generate openshell.policy.v1 bindings SDK callers must not be able to author advisor or provider-derived provenance sdk/go/openshell/v1/types/network_policy.go; sdk/go/proto/policyv1/policy.pb.go Go API models the public matcher oneofs recursive parameters tool matcher and MCP body limit +2026-09-18T05:05:00Z canonicalization Give scalar port and repeated ports spellings one internal policy identity The public proto exposes both historical fields, while hashes, history deduplication, and size enforcement require equivalent authored policies to lower identically crates/openshell-policy/src/lib.rs validated policies materialize ports and retain the first effective port in the scalar field +2026-09-18T05:05:00Z limits Measure policy size after lowering the public message to its canonical internal representation Compact public protobuf spellings must not bypass the policy limit when defaults and normalized fields expand internally crates/openshell-server/src/grpc/validation.rs boundary-size tests cover omitted empty and explicit MCP defaults at and above the limit +2026-09-18T05:05:00Z providers Treat provider-profile protobuf messages from every source as fallible authored input External interceptors and stored user profiles can supply malformed policy fields; catalog construction must report a failed precondition instead of panicking crates/openshell-server/src/provider_profile_sources.rs malformed MCP versions remain unchanged through normalization and fail catalog validation +2026-09-18T05:20:00Z main-merge Keep main's explicit L7 append target and express its binaries and rule payloads with the public policy proto The target closes an authorization ambiguity and belongs to the external UpdateConfig contract alongside its authored payload proto/openshell.proto; crates/openshell-cli/src/policy_update.rs; crates/openshell-server/src/grpc/policy.rs CLI and Go SDK send public target binaries and public allow or deny rules; the gateway lowers them before merge +2026-09-18T05:20:00Z storage Persist provider_attachment_epoch in the gateway-private sandbox wire message Main added the field to SandboxSpec and dropping it during public-to-internal storage conversion would break provider readiness generation tracking crates/openshell-server/proto/storage.proto; crates/openshell-server/src/storage_proto.rs old rows decode with an empty epoch and new rows round-trip it without exposing internal policy fields +2026-09-18T05:54:27Z strict-yaml Reject YAML mapping keys that are not strings before ProtoJSON decoding Noyalib intentionally stringifies scalar mapping keys, which would otherwise turn numeric, boolean, or null keys into different authored data crates/openshell-policy-schema/src/generated.rs bounded parsing is followed by a CST key check; quoted scalar-like strings remain valid +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 +2026-09-19T07:26:31Z build Add protobuf to the Nix development shell The pinned Rust Protovalidate runtime compiles its bundled constraint descriptor at build time; mise already provides protoc but the multi-architecture Rust CI shell did not x86_64-linux Branch Checks lint failure; prost-protovalidate-types 0.6.0 build script; mise and flake tool inventories Local and Nix-backed Rust builds now supply the same required descriptor compiler without changing runtime behavior +2026-09-19T07:56:21Z e2e-fixture Adapt published community-image fixtures at the backend test boundary without accepting legacy YAML in production The external base image still embeds singular port fields; container-backed suites can derive and import a corrected image while VM and tmachine lifecycle suites can select an explicit minimal PolicyDocument e2e Dockerfile and Docker Podman Kubernetes launchers; VM policy fixture; tmachine conformance environment Image-policy discovery remains covered on container backends, registry-only suites remain deterministic, and the strict authored contract is unchanged +2026-09-19T08:21:32Z e2e-filesystem-baseline Retain the community base image filesystem Landlock and process baseline in explicit VM and tmachine fixtures A structurally valid empty policy correctly denied the shell and sandbox paths needed by backend lifecycle conformance; relaxing production parsing would weaken the new contract remote VM and tmachine Permission denied diagnostics; local Docker external-driver lifecycle with the explicit policy Registry-independent suites can execute their existing lifecycle checks while network access remains denied unless a test supplies a purpose-built policy diff --git a/.gitignore b/.gitignore index b4e8cd1631..c3e89e9860 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 109e6df8d5..816f2c887d 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" @@ -4460,8 +4477,14 @@ version = "0.0.0" dependencies = [ "miette", "noyalib", + "prost", + "prost-protovalidate", + "prost-reflect", + "prost-types", + "protoc-bin-vendored", "serde", "serde_json", + "tonic-prost-build", ] [[package]] @@ -5541,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" @@ -5549,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" @@ -5895,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 f57b22c397..622765b59b 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 45e934d610..4b7e9c871f 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 5164ec4233..3c1778a3c8 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -41,6 +41,55 @@ before any consumer-specific projection runs. There is no permissive parsing profile: unsupported policy fields always invalidate the document. Middleware `config`, query and persisted-query names, and recursive MCP 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 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 +private storage envelopes whose field numbers mirror the historical records, +then projects their policy fields to the public schema on read. No clean +database or policy-data migration is required for this boundary. Mixed-version +gateway rollouts remain unsupported because the public RPC contract changes. + +Policy YAML uses the public protobuf field shape directly. The schema crate +performs bounded YAML decoding and descriptor-driven protobuf conversion; it +does not rewrite legacy spellings. Scalar matchers, scalar MCP `tool` values, +and other YAML-only shorthands are breaking changes and must be rewritten as +their message or oneof forms. Protobuf cannot distinguish omitted repeated +fields from empty lists, so both select the same default where the policy +language defines one. Contextual validation remains explicit code because +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 77fde36564..63ddcaddb7 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 139271ed0c..196b4a6148 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/policy_update.rs b/crates/openshell-cli/src/policy_update.rs index 2907afaaed..e2df7eccd0 100644 --- a/crates/openshell-cli/src/policy_update.rs +++ b/crates/openshell-cli/src/policy_update.rs @@ -4,6 +4,7 @@ use std::collections::{BTreeMap, HashMap}; use miette::{Result, miette}; +use openshell_core::proto::policy::NetworkBinary as AuthoredNetworkBinary; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::{ AddAllowRules, AddDenyRules, AddNetworkRule, L7Allow, L7DenyRule, L7Rule, @@ -120,7 +121,10 @@ pub fn build_policy_update_plan( merge_operations.push(PolicyMergeOperation { operation: Some(policy_merge_operation::Operation::AddRule(AddNetworkRule { rule_name: target_rule_name.clone(), - rule: Some(rule.clone()), + rule: Some(openshell_policy::project_authored_rule( + &target_rule_name, + &rule, + )?), })), }); preview_operations.push(PolicyMergeOp::AddRule { @@ -178,7 +182,10 @@ pub fn build_policy_update_plan( operation: Some(policy_merge_operation::Operation::AddAllowRules( AddAllowRules { target: Some(l7_target_to_proto(&target)), - rules: rules.clone(), + rules: rules + .iter() + .map(openshell_policy::project_authored_l7_rule) + .collect::>>()?, }, )), }); @@ -199,7 +206,10 @@ pub fn build_policy_update_plan( operation: Some(policy_merge_operation::Operation::AddDenyRules( AddDenyRules { target: Some(l7_target_to_proto(&target)), - deny_rules: deny_rules.clone(), + deny_rules: deny_rules + .iter() + .map(openshell_policy::project_authored_l7_deny_rule) + .collect::>>()?, }, )), }); @@ -263,7 +273,12 @@ fn l7_target_to_proto(target: &L7RuleTarget) -> ProtoL7RuleTarget { host: target.host.clone(), ports: target.ports.clone(), path: target.path.clone(), - binaries, + binaries: binaries + .iter() + .map(|binary| AuthoredNetworkBinary { + path: binary.path.clone(), + }) + .collect(), any_binary, } } @@ -1251,7 +1266,14 @@ mod tests { Some(Operation::AddAllowRules(wire)), PolicyMergeOp::AddAllowRules { target, rules }, ) => { - assert_eq!(wire.rules, *rules); + assert_eq!( + wire.rules, + rules + .iter() + .map(openshell_policy::project_authored_l7_rule) + .collect::>>() + .unwrap() + ); assert_eq!(rules.len(), 2); assert_eq!( rules[0].allow.as_ref().expect("allow matcher").path, @@ -1263,7 +1285,14 @@ mod tests { Some(Operation::AddDenyRules(wire)), PolicyMergeOp::AddDenyRules { target, deny_rules }, ) => { - assert_eq!(wire.deny_rules, *deny_rules); + assert_eq!( + wire.deny_rules, + deny_rules + .iter() + .map(openshell_policy::project_authored_l7_deny_rule) + .collect::>>() + .unwrap() + ); assert_eq!(deny_rules.len(), 2); assert_eq!(deny_rules[0].path, "/v1/a:b"); (wire.target.as_ref().expect("wire target"), target) @@ -1284,7 +1313,17 @@ mod tests { assert_eq!(wire_target.host, target.host); assert_eq!(wire_target.ports, target.ports); assert_eq!(wire_target.path, target.path); - assert_eq!(wire_target.binaries, *binaries); + assert_eq!( + wire_target + .binaries + .iter() + .map(|binary| binary.path.as_str()) + .collect::>(), + binaries + .iter() + .map(|binary| binary.path.as_str()) + .collect::>() + ); assert!(!wire_target.any_binary); } } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 5ac8d4b1a0..184aae1c73 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -43,6 +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::PolicyDocument; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, BeginRootfsTarStagingRequest, ClearDraftChunksRequest, CreateSandboxRequest, CreateSandboxTemplateRequest, @@ -1475,8 +1476,8 @@ 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> { - openshell_policy::load_sandbox_policy(cli_path) +fn load_sandbox_policy(cli_path: Option<&str>) -> Result> { + openshell_policy::load_authored_policy(cli_path) } /// Sync files to or from a sandbox. @@ -5338,8 +5339,8 @@ where if view.includes_policy() { if let Some(ref policy) = rev.policy { writeln!(stdout, "---").into_diagnostic()?; - let policy = policy_for_view(policy, view); - let yaml_str = openshell_policy::serialize_sandbox_policy(policy.as_ref()) + let policy = authored_policy_for_view(policy, view); + let yaml_str = openshell_policy::serialize_authored_policy(policy.as_ref()) .wrap_err("failed to serialize policy to YAML")?; write!(stdout, "{yaml_str}").into_diagnostic()?; } else { @@ -5508,8 +5509,8 @@ pub async fn sandbox_policy_get_global( if view.includes_policy() { if let Some(ref policy) = rev.policy { println!("---"); - let policy = policy_for_view(policy, view); - let yaml_str = openshell_policy::serialize_sandbox_policy(policy.as_ref()) + let policy = authored_policy_for_view(policy, view); + let yaml_str = openshell_policy::serialize_authored_policy(policy.as_ref()) .wrap_err("failed to serialize policy to YAML")?; print!("{yaml_str}"); } else { @@ -5579,8 +5580,8 @@ fn policy_revision_to_json( if view.includes_policy() { let policy = match rev.policy.as_ref() { Some(policy) => { - let policy = policy_for_view(policy, view); - openshell_policy::sandbox_policy_to_json_value(policy.as_ref())? + let policy = authored_policy_for_view(policy, view); + openshell_policy::authored_policy_to_json_value(policy.as_ref())? } None => serde_json::Value::Null, }; @@ -5601,6 +5602,21 @@ fn policy_for_view(policy: &SandboxPolicy, view: PolicyGetView) -> Cow<'_, Sandb Cow::Owned(base_policy) } +fn authored_policy_for_view( + policy: &PolicyDocument, + view: PolicyGetView, +) -> Cow<'_, PolicyDocument> { + if view != PolicyGetView::Base { + return Cow::Borrowed(policy); + } + + let mut base_policy = policy.clone(); + base_policy + .network_policies + .retain(|name, _| !openshell_policy::is_provider_rule_name(name)); + Cow::Owned(base_policy) +} + pub async fn sandbox_policy_list( server: &str, name: &str, @@ -6212,7 +6228,10 @@ pub async fn sandbox_draft_history( } /// Format a `NetworkPolicyRule`'s endpoints as a compact string. -fn format_endpoints(rule: &openshell_core::proto::NetworkPolicyRule) -> String { +fn format_endpoints(rule: &openshell_core::proto::policy::NetworkPolicyRule) -> String { + let Ok(rule) = openshell_policy::lower_authored_rule("display", rule.clone()) else { + return "".to_string(); + }; rule.endpoints .iter() .map(format_endpoint) @@ -6307,10 +6326,10 @@ mod tests { use openshell_core::proto::{ EndpointResult, EndpointStatus, GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, - SandboxPolicy, SandboxPolicyRevision, SandboxResources, SandboxStatus, - SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, - SandboxWorkloadTemplateSpec, ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, - WorkspaceRole, datamodel::v1::ObjectMeta, + SandboxPolicyRevision, SandboxResources, SandboxStatus, SandboxWorkloadConfig, + SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, + ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, WorkspaceRole, + datamodel::v1::ObjectMeta, }; #[test] @@ -6351,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(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_only_process_identity_integration.rs b/crates/openshell-cli/tests/policy_only_process_identity_integration.rs index 1b5f4594b0..f1a1e01063 100644 --- a/crates/openshell-cli/tests/policy_only_process_identity_integration.rs +++ b/crates/openshell-cli/tests/policy_only_process_identity_integration.rs @@ -85,7 +85,7 @@ async fn policy_only_preserves_all_process_identity_combinations_through_gateway for (name, process_yaml, expected) in cases { let authored = format!("version: 1\n{process_yaml}\n"); - let policy = openshell_policy::parse_sandbox_policy(&authored) + let policy = openshell_policy::parse_authored_policy(&authored) .unwrap_or_else(|error| panic!("{name}: authored policy should parse: {error}")); let mut client = grpc_client(&server.endpoint, &server.tls).await.unwrap(); diff --git a/crates/openshell-cli/tests/policy_parse_errors_integration.rs b/crates/openshell-cli/tests/policy_parse_errors_integration.rs new file mode 100644 index 0000000000..c82ad7bedc --- /dev/null +++ b/crates/openshell-cli/tests/policy_parse_errors_integration.rs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! User-facing policy parse failures must happen before the CLI connects to a +//! gateway, with enough context to identify the protobuf shape violation. + +use std::process::Command; + +#[test] +fn policy_set_reports_legacy_matcher_shape_before_connecting() { + let directory = tempfile::tempdir().expect("create temp directory"); + let policy_path = directory.path().join("legacy-policy.yaml"); + std::fs::write( + &policy_path, + r" +version: 1 +network_policies: + mcp: + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + rules: + - allow: + method: tools/call + tool: search_* +", + ) + .expect("write legacy policy"); + + let output = Command::new(env!("CARGO_BIN_EXE_openshell")) + .args([ + "policy", + "set", + "test-sandbox", + "--policy", + policy_path.to_str().expect("UTF-8 temp path"), + "--gateway-endpoint", + "http://127.0.0.1:1", + "--color", + "never", + ]) + .output() + .expect("run openshell policy set"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("failed to decode proto-shaped sandbox policy YAML"), + "unexpected stderr: {stderr}" + ); + assert!( + !stderr.contains("127.0.0.1") && !stderr.contains("transport error"), + "the CLI connected before rejecting the local file: {stderr}" + ); +} diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 4a26334c02..949512de69 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 bd295a9362..cfe7f821d8 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -8,6 +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, PolicyDocument}; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, @@ -20,9 +21,9 @@ use openshell_core::proto::{ GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, - ListSandboxesResponse, NetworkEndpoint, NetworkPolicyRule, PolicyStatus, ProviderResponse, - Sandbox, SandboxPolicy, SandboxPolicyRevision, SandboxResponse, SandboxStreamEvent, - ServiceStatus, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, + ListSandboxesResponse, PolicyStatus, ProviderResponse, Sandbox, SandboxPolicyRevision, + SandboxResponse, SandboxStreamEvent, ServiceStatus, SupervisorMessage, UpdateProviderRequest, + WatchSandboxRequest, }; use std::sync::Arc; use tempfile::TempDir; @@ -193,14 +194,14 @@ impl OpenShell for TestOpenShell { let req = request.into_inner(); assert!(!req.name.is_empty()); Ok(Response::new(GetSandboxConfigResponse { - policy: Some(SandboxPolicy { + policy: Some(openshell_core::proto::SandboxPolicy { version: 1, network_policies: [ ( "user_api".to_string(), - NetworkPolicyRule { + openshell_core::proto::NetworkPolicyRule { name: "user_api".to_string(), - endpoints: vec![NetworkEndpoint { + endpoints: vec![openshell_core::proto::NetworkEndpoint { host: "api.user.example.com".to_string(), port: 443, protocol: "rest".to_string(), @@ -214,9 +215,9 @@ impl OpenShell for TestOpenShell { ), ( "_provider_api".to_string(), - NetworkPolicyRule { + openshell_core::proto::NetworkPolicyRule { name: "_provider_api".to_string(), - endpoints: vec![NetworkEndpoint { + endpoints: vec![openshell_core::proto::NetworkEndpoint { host: "api.provider.example.com".to_string(), port: 443, protocol: "rest".to_string(), @@ -486,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(), @@ -494,10 +495,10 @@ 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: openshell_core::proto::NetworkEnforcementMode::Enforce as i32, - access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32, + enforcement: "enforce".to_string(), + access: "read-only".to_string(), ..Default::default() }], ..Default::default() diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index 38c961b1d4..9438d5eba8 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -51,6 +51,9 @@ fn main() -> Result<(), Box> { .build_server(true) .build_client(true) .include_file("openshell.rs") + // The public authored-policy package is generated exactly once by + // openshell-policy-schema. API messages refer to that Rust type. + .extern_path(".openshell.policy.v1", "::openshell_policy_schema::proto") // Emit a binary FileDescriptorSet so the server can enumerate every // RPC at runtime (used by the per-handler auth exhaustiveness test). .file_descriptor_set_path(&descriptor_path) diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 5304286fd2..cd1070d29a 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -37,6 +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::proto::PolicyDocument; use tonic::Status; use tonic::metadata::AsciiMetadataValue; use tonic::service::interceptor::InterceptedService; @@ -964,7 +965,7 @@ async fn fetch_policy_with_client( async fn sync_policy_with_client( client: &mut OpenShellClient, sandbox: &str, - policy: &ProtoSandboxPolicy, + policy: &PolicyDocument, workspace: &str, ) -> Result<()> { client @@ -988,7 +989,7 @@ async fn sync_policy_with_client( pub async fn discover_and_sync_policy( endpoint: &str, sandbox: &str, - discovered_policy: &ProtoSandboxPolicy, + discovered_policy: &PolicyDocument, workspace: &str, ) -> Result { debug!( @@ -1018,7 +1019,7 @@ pub async fn discover_and_sync_policy( pub async fn sync_policy( endpoint: &str, sandbox: &str, - policy: &ProtoSandboxPolicy, + policy: &PolicyDocument, workspace: &str, ) -> Result<()> { debug!(endpoint = %endpoint, sandbox = %sandbox, "Syncing enriched policy to gateway"); @@ -1030,7 +1031,7 @@ pub async fn sync_policy( pub async fn sync_policy_and_fetch_snapshot( endpoint: &str, sandbox: &str, - policy: &ProtoSandboxPolicy, + 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 4300b713c6..6deb38e200 100644 --- a/crates/openshell-core/src/mcp.rs +++ b/crates/openshell-core/src/mcp.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; -use crate::proto::{McpOptions, ProviderProfile}; +use crate::proto::{ProviderProfile, policy::McpConfig}; pub use openshell_policy_schema::{ DEFAULT_MCP_PROTOCOL_VERSION, MAX_MCP_LEGACY_BATCH_MESSAGES, McpProtocolVersion, @@ -36,9 +36,9 @@ pub fn normalize_provider_profile_mcp_fields(profile: &mut ProviderProfile) { } let Some(options) = endpoint.mcp.as_mut() else { - endpoint.mcp = Some(McpOptions { + endpoint.mcp = Some(McpConfig { versions: vec![DEFAULT_MCP_PROTOCOL_VERSION.as_str().to_string()], - ..McpOptions::default() + ..McpConfig::default() }); continue; }; @@ -51,7 +51,7 @@ pub fn normalize_provider_profile_mcp_fields(profile: &mut ProviderProfile) { // Parse into the shared version type before mutation. Comparing the // set size with the input length detects duplicates without erasing // the duplicate values that a fail-closed validator must report. - let Ok(versions) = options + let Ok(canonical) = options .versions .iter() .map(|version| version.parse::()) @@ -59,11 +59,11 @@ pub fn normalize_provider_profile_mcp_fields(profile: &mut ProviderProfile) { else { continue; }; - if versions.len() != options.versions.len() { + if canonical.len() != options.versions.len() { continue; } - options.versions = versions + options.versions = canonical .into_iter() .map(|version| version.as_str().to_string()) .collect(); @@ -108,24 +108,24 @@ mod tests { assert_eq!(profile.max_batch_messages(), None); } - fn provider_profile_with_mcp(protocol: &str, options: Option) -> ProviderProfile { + fn provider_profile_with_mcp(protocol: &str, options: Option) -> ProviderProfile { ProviderProfile { id: "mcp-profile".to_string(), display_name: "MCP profile".to_string(), description: "source-owned description".to_string(), - endpoints: vec![crate::proto::NetworkEndpoint { + 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::NetworkEndpoint::default() + ..crate::proto::policy::NetworkEndpoint::default() }], ..ProviderProfile::default() } } #[test] - fn provider_profile_mcp_normalization_materializes_omitted_and_empty_versions() { + fn provider_profile_mcp_normalization_materializes_empty_versions() { let mut omitted = provider_profile_with_mcp("McP", None); normalize_provider_profile_mcp_fields(&mut omitted); assert_eq!( @@ -139,10 +139,11 @@ mod tests { let mut empty = provider_profile_with_mcp( "mcp", - Some(McpOptions { + Some(McpConfig { strict_tool_names: Some(false), allow_all_known_mcp_methods: Some(true), versions: Vec::new(), + ..McpConfig::default() }), ); normalize_provider_profile_mcp_fields(&mut empty); @@ -150,11 +151,12 @@ mod tests { empty.endpoints[0] .mcp .as_ref() - .expect("empty MCP versions must materialize"), - &McpOptions { + .expect("explicit MCP options remain present"), + &McpConfig { strict_tool_names: Some(false), allow_all_known_mcp_methods: Some(true), versions: vec!["2025-11-25".to_string()], + ..McpConfig::default() } ); } @@ -163,14 +165,14 @@ mod tests { fn provider_profile_mcp_normalization_canonicalizes_valid_explicit_versions_only() { let mut profile = provider_profile_with_mcp( "mcp", - Some(McpOptions { + Some(McpConfig { strict_tool_names: Some(true), versions: vec![ "2025-11-25".to_string(), "2025-03-26".to_string(), "2025-06-18".to_string(), ], - ..McpOptions::default() + ..McpConfig::default() }), ); let original = profile.clone(); @@ -181,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 @@ -191,14 +193,14 @@ mod tests { .mcp .as_ref() .expect("valid MCP options"), - &McpOptions { + &McpConfig { strict_tool_names: Some(true), versions: vec![ "2025-03-26".to_string(), "2025-06-18".to_string(), "2025-11-25".to_string(), ], - ..McpOptions::default() + ..McpConfig::default() } ); } @@ -215,9 +217,9 @@ mod tests { ] { let mut profile = provider_profile_with_mcp( "mcp", - Some(McpOptions { + Some(McpConfig { versions: versions.into_iter().map(ToString::to_string).collect(), - ..McpOptions::default() + ..McpConfig::default() }), ); let original = profile.clone(); @@ -232,9 +234,9 @@ mod tests { fn provider_profile_mcp_normalization_ignores_non_mcp_endpoint_evidence() { let mut profile = provider_profile_with_mcp( "rest", - Some(McpOptions { + Some(McpConfig { versions: vec!["latest".to_string()], - ..McpOptions::default() + ..McpConfig::default() }), ); let original = profile.clone(); diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index e05222e670..e524b2ddea 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -36,6 +36,11 @@ pub mod sandbox { pub use super::generated::openshell::sandbox::v1; } +/// Public author-controlled policy messages. +pub mod policy { + pub use openshell_policy_schema::proto::*; +} + pub mod compute { pub use super::generated::openshell::compute::v1; } 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 e5529eaeb6..a474573d9a 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 11ee465557..e1d7fc697f 100644 --- a/crates/openshell-gateway-interceptors/src/profile_source.rs +++ b/crates/openshell-gateway-interceptors/src/profile_source.rs @@ -121,7 +121,7 @@ fn resolve_provider_profile_snapshot_revision( #[cfg(test)] mod tests { - use openshell_core::proto::{McpOptions, NetworkEndpoint}; + use openshell_core::proto::policy::{McpConfig, NetworkEndpoint}; use super::*; @@ -130,14 +130,14 @@ 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| McpOptions { + mcp: versions.map(|versions| McpConfig { versions: versions .iter() .map(|version| (*version).to_string()) .collect(), - ..McpOptions::default() + ..McpConfig::default() }), ..NetworkEndpoint::default() }], diff --git a/crates/openshell-policy-schema/Cargo.toml b/crates/openshell-policy-schema/Cargo.toml index 24808d396b..ea9d422c57 100644 --- a/crates/openshell-policy-schema/Cargo.toml +++ b/crates/openshell-policy-schema/Cargo.toml @@ -12,9 +12,17 @@ repository.workspace = true [dependencies] 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 } serde_yml = { workspace = true } +[build-dependencies] +protoc-bin-vendored = { workspace = true } +tonic-prost-build = { workspace = true } + [lints] workspace = true diff --git a/crates/openshell-policy-schema/build.rs b/crates/openshell-policy-schema/build.rs new file mode 100644 index 0000000000..149366e160 --- /dev/null +++ b/crates/openshell-policy-schema/build.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::env; +use std::path::PathBuf; + +const PROTO_ROOT: &str = "../../proto"; + +fn main() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let proto_root = manifest_dir.join(PROTO_ROOT); + let policy_proto = proto_root.join("policy.proto"); + + println!("cargo:rerun-if-changed={}", policy_proto.display()); + + // SAFETY: Build scripts run in their own single-threaded process. + #[allow(unsafe_code)] + unsafe { + env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); + env::set_var("PROTOC_INCLUDE", protoc_bin_vendored::include_path()?); + } + + tonic_prost_build::configure() + .build_server(false) + .build_client(false) + .file_descriptor_set_path(PathBuf::from(env::var("OUT_DIR")?).join("policy_descriptor.bin")) + .compile_protos(&[policy_proto], &[proto_root])?; + + Ok(()) +} diff --git a/crates/openshell-policy-schema/src/generated.rs b/crates/openshell-policy-schema/src/generated.rs new file mode 100644 index 0000000000..a046df980d --- /dev/null +++ b/crates/openshell-policy-schema/src/generated.rs @@ -0,0 +1,1691 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, HashMap}; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::sync::OnceLock; + +use miette::{IntoDiagnostic, Result, WrapErr}; +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; + +use crate::proto; +use crate::{ + AnyMatcher, FilesystemPolicy, GraphqlOperation, JsonRpcConfig, L7Allow, L7DenyRule, L7Rule, + LandlockCompatibility, LandlockPolicy, McpConfig, MiddlewareEndpointSelector, NetworkBinary, + NetworkCredentialBinding, NetworkEndpoint, NetworkMiddleware, NetworkPolicyRule, + ParameterMatcher, ParseLimits, PolicyDocument, ProcessPolicy, QueryMatcher, +}; + +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_pool() -> &'static DescriptorPool { + static POOL: OnceLock = OnceLock::new(); + POOL.get_or_init(|| { + DescriptorPool::decode( + include_bytes!(concat!(env!("OUT_DIR"), "/policy_descriptor.bin")).as_slice(), + ) + .expect("compiled policy descriptor set must decode") + }) +} + +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<()> { + match value { + serde_yml::Value::Sequence(values) => { + for value in values { + validate_proto_json_yaml(value)?; + } + } + serde_yml::Value::Mapping(entries) => { + for value in entries.values() { + validate_proto_json_yaml(value)?; + } + } + serde_yml::Value::Number(number) => { + let exact = number.as_i64().map_or_else( + || { + number + .as_u64() + .map_or_else(|| number.as_f64().is_finite(), integer_is_exact_in_f64) + }, + |value| integer_is_exact_in_f64(value.unsigned_abs()), + ); + if !exact { + miette::bail!( + "policy YAML number {number} cannot be represented exactly by protobuf JSON" + ); + } + } + _ => {} + } + 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() + .wrap_err("failed to inspect policy YAML mapping key")?; + if !matches!(value, serde_yml::Value::String(_)) { + miette::bail!( + "policy YAML mapping key {source:?} is not a string in the protobuf JSON data model" + ); + } + Ok(()) +} + +fn is_yaml_trivia(kind: serde_yml::cst::SyntaxKind) -> bool { + use serde_yml::cst::SyntaxKind; + matches!( + kind, + SyntaxKind::Whitespace + | SyntaxKind::Newline + | SyntaxKind::Comment + | SyntaxKind::Bom + | SyntaxKind::Directive + ) +} + +fn validate_mapping_entry_keys( + node: &serde_yml::cst::GreenNode, + source: &str, + base: usize, +) -> Result<()> { + use serde_yml::cst::{GreenChild, SyntaxKind}; + + let mut offset = base; + let mut found_key = false; + for child in node.children() { + match child { + GreenChild::Token { kind, len } if *kind == SyntaxKind::ColonIndicator => break, + GreenChild::Token { kind, .. } + if is_yaml_trivia(*kind) || *kind == SyntaxKind::QuestionIndicator => {} + GreenChild::Token { kind, len } if !found_key => { + let raw = &source[offset..offset + *len as usize]; + match kind { + SyntaxKind::PlainScalar => validate_plain_mapping_key(raw)?, + SyntaxKind::SingleQuotedScalar + | SyntaxKind::DoubleQuotedScalar + | SyntaxKind::LiteralScalar + | SyntaxKind::FoldedScalar => {} + _ => miette::bail!( + "policy YAML mapping keys must be string scalars for the protobuf JSON data model" + ), + } + found_key = true; + } + GreenChild::Node(_) if !found_key => miette::bail!( + "policy YAML mapping keys must be string scalars for the protobuf JSON data model" + ), + _ => {} + } + offset += child.text_len(); + } + if !found_key { + miette::bail!( + "policy YAML mapping keys must be string scalars for the protobuf JSON data model" + ); + } + Ok(()) +} + +fn validate_flow_mapping_keys( + node: &serde_yml::cst::GreenNode, + source: &str, + base: usize, +) -> Result<()> { + use serde_yml::cst::{GreenChild, SyntaxKind}; + + let mut offset = base; + let mut expecting_key = true; + let mut found_key = false; + for child in node.children() { + match child { + GreenChild::Token { kind, .. } + if is_yaml_trivia(*kind) + || *kind == SyntaxKind::OpenBrace + || *kind == SyntaxKind::QuestionIndicator => {} + GreenChild::Token { kind, .. } if *kind == SyntaxKind::Comma => { + expecting_key = true; + found_key = false; + } + GreenChild::Token { kind, .. } if *kind == SyntaxKind::CloseBrace => {} + GreenChild::Token { kind, .. } if *kind == SyntaxKind::ColonIndicator => { + if expecting_key && !found_key { + miette::bail!( + "policy YAML mapping keys must be string scalars for the protobuf JSON data model" + ); + } + expecting_key = false; + } + GreenChild::Token { kind, len } if expecting_key && !found_key => { + let raw = &source[offset..offset + *len as usize]; + match kind { + SyntaxKind::PlainScalar => validate_plain_mapping_key(raw)?, + SyntaxKind::SingleQuotedScalar | SyntaxKind::DoubleQuotedScalar => {} + _ => miette::bail!( + "policy YAML mapping keys must be string scalars for the protobuf JSON data model" + ), + } + found_key = true; + } + GreenChild::Node(_) if expecting_key => miette::bail!( + "policy YAML mapping keys must be string scalars for the protobuf JSON data model" + ), + _ => {} + } + offset += child.text_len(); + } + Ok(()) +} + +fn validate_proto_json_mapping_keys(source: &str) -> Result<()> { + fn walk(node: &serde_yml::cst::GreenNode, source: &str, base: usize) -> Result<()> { + use serde_yml::cst::{GreenChild, SyntaxKind}; + + match node.kind() { + SyntaxKind::MappingEntry => validate_mapping_entry_keys(node, source, base)?, + SyntaxKind::FlowMapping => validate_flow_mapping_keys(node, source, base)?, + _ => {} + } + let mut offset = base; + for child in node.children() { + if let GreenChild::Node(child_node) = child { + walk(child_node, source, offset)?; + } + offset += child.text_len(); + } + Ok(()) + } + + let document = serde_yml::cst::parse_document(source) + .into_diagnostic() + .wrap_err("failed to inspect policy YAML mapping keys")?; + walk(document.syntax(), source, 0) +} + +/// Parse strict proto-shaped policy YAML into the generated public message. +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() + .wrap_err("policy YAML must use the JSON-compatible protobuf data model")?; + let dynamic = DynamicMessage::deserialize(policy_descriptor(), json_value) + .into_diagnostic() + .wrap_err("failed to decode proto-shaped sandbox policy YAML")?; + let policy = dynamic + .transcode_to::() + .into_diagnostic() + .wrap_err("failed to decode generated sandbox policy")?; + validate_authored_policy(&policy)?; + Ok(policy) +} + +/// Parse a strict proto-shaped policy YAML file into the generated message. +pub fn parse_policy_proto_file(path: &Path, limits: ParseLimits) -> Result { + let metadata = path + .metadata() + .into_diagnostic() + .wrap_err_with(|| format!("failed to inspect sandbox policy {}", path.display()))?; + if !metadata.is_file() { + miette::bail!( + "sandbox policy source is not a regular file: {}", + path.display() + ); + } + if metadata.len() > u64::try_from(limits.max_bytes).unwrap_or(u64::MAX) { + miette::bail!("policy exceeds the {}-byte input limit", limits.max_bytes); + } + let mut bytes = Vec::new(); + File::open(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))? + .take( + u64::try_from(limits.max_bytes) + .unwrap_or(u64::MAX) + .saturating_add(1), + ) + .read_to_end(&mut bytes) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))?; + if bytes.len() > limits.max_bytes { + miette::bail!("policy exceeds the {}-byte input limit", limits.max_bytes); + } + let source = std::str::from_utf8(&bytes) + .into_diagnostic() + .wrap_err("sandbox policy is not valid UTF-8")?; + let config = crate::parser_config(limits); + 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() + .wrap_err("policy YAML must use the JSON-compatible protobuf data model")?; + let dynamic = DynamicMessage::deserialize(policy_descriptor(), json_value) + .into_diagnostic() + .wrap_err("failed to decode proto-shaped sandbox policy YAML")?; + let policy = dynamic + .transcode_to::() + .into_diagnostic() + .wrap_err("failed to decode generated sandbox policy")?; + validate_authored_policy(&policy)?; + Ok(policy) +} + +/// Validate a generated public policy with the schema-owned intrinsic checks. +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, +} + +impl Serialize for ProtoYaml<'_> { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + self.message.serialize_with_options( + serializer, + &SerializeOptions::new().use_proto_field_name(true), + ) + } +} + +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::PolicyDocument) -> Result { + validate_authored_policy(policy)?; + let dynamic = dynamic_policy(policy)?; + serde_yml::to_string(&ProtoYaml { message: &dynamic }) + .into_diagnostic() + .wrap_err("failed to serialize proto-shaped sandbox policy YAML") +} + +/// Convert a generated public policy to canonical proto-shaped JSON. +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 }) + .into_diagnostic() + .wrap_err("failed to serialize proto-shaped sandbox policy JSON") +} + +impl TryFrom for proto::PolicyDocument { + type Error = miette::Report; + + fn try_from(document: PolicyDocument) -> Result { + crate::validate_policy(&document)?; + Ok(Self { + version: document.version, + filesystem_policy: document.filesystem_policy.map(Into::into), + landlock: document.landlock.map(Into::into), + process: document.process.map(Into::into), + network_policies: document + .network_policies + .into_iter() + .map(|(name, rule)| Ok((name, rule.try_into()?))) + .collect::>>()?, + network_middlewares: document + .network_middlewares + .into_iter() + .map(|(name, middleware)| Ok((name, middleware.try_into()?))) + .collect::>>()?, + }) + } +} + +impl TryFrom for PolicyDocument { + type Error = miette::Report; + + fn try_from(policy: proto::PolicyDocument) -> Result { + let document = Self { + version: policy.version, + filesystem_policy: policy.filesystem_policy.map(Into::into), + landlock: policy.landlock.map(TryInto::try_into).transpose()?, + process: policy.process.map(Into::into), + network_policies: policy + .network_policies + .into_iter() + .map(|(name, rule)| Ok((name, rule.try_into()?))) + .collect::>>()?, + network_middlewares: policy + .network_middlewares + .into_iter() + .map(|(name, middleware)| Ok((name, middleware.try_into()?))) + .collect::>>()?, + }; + crate::validate_policy(&document)?; + Ok(document) + } +} + +impl From for proto::FilesystemPolicy { + fn from(policy: FilesystemPolicy) -> Self { + Self { + include_workdir: policy.include_workdir, + read_only: policy.read_only, + read_write: policy.read_write, + } + } +} + +impl From for FilesystemPolicy { + fn from(policy: proto::FilesystemPolicy) -> Self { + Self { + include_workdir: policy.include_workdir, + read_only: policy.read_only, + read_write: policy.read_write, + } + } +} + +impl From for proto::LandlockPolicy { + fn from(policy: LandlockPolicy) -> Self { + let compatibility = match policy.compatibility { + LandlockCompatibility::BestEffort => "best_effort", + LandlockCompatibility::HardRequirement => "hard_requirement", + }; + Self { + compatibility: compatibility.to_string(), + } + } +} + +impl TryFrom for LandlockPolicy { + type Error = miette::Report; + + fn try_from(policy: proto::LandlockPolicy) -> Result { + let compatibility = match policy.compatibility.as_str() { + "" | "best_effort" => LandlockCompatibility::BestEffort, + "hard_requirement" => LandlockCompatibility::HardRequirement, + value => miette::bail!( + "invalid landlock.compatibility '{value}'; expected best_effort or hard_requirement" + ), + }; + Ok(Self { compatibility }) + } +} + +impl From for proto::ProcessPolicy { + fn from(policy: ProcessPolicy) -> Self { + Self { + run_as_user: policy.run_as_user, + run_as_group: policy.run_as_group, + } + } +} + +impl From for ProcessPolicy { + fn from(policy: proto::ProcessPolicy) -> Self { + Self { + run_as_user: policy.run_as_user, + run_as_group: policy.run_as_group, + } + } +} + +impl TryFrom for proto::NetworkPolicyRule { + type Error = miette::Report; + + fn try_from(rule: NetworkPolicyRule) -> Result { + Ok(Self { + name: rule.name, + endpoints: rule + .endpoints + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + binaries: rule.binaries.into_iter().map(Into::into).collect(), + }) + } +} + +impl TryFrom for NetworkPolicyRule { + type Error = miette::Report; + + fn try_from(rule: proto::NetworkPolicyRule) -> Result { + Ok(Self { + name: rule.name, + endpoints: rule + .endpoints + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + binaries: rule.binaries.into_iter().map(Into::into).collect(), + }) + } +} + +impl TryFrom for proto::NetworkEndpoint { + type Error = miette::Report; + + fn try_from(endpoint: NetworkEndpoint) -> Result { + Ok(Self { + host: endpoint.host, + path: endpoint.path, + ports: endpoint.ports.into_iter().map(u32::from).collect(), + protocol: endpoint.protocol, + tls: endpoint.tls, + enforcement: endpoint.enforcement, + access: endpoint.access, + rules: endpoint + .rules + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + allowed_ips: endpoint.allowed_ips, + deny_rules: endpoint + .deny_rules + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + allow_encoded_slash: endpoint.allow_encoded_slash, + websocket_credential_rewrite: endpoint.websocket_credential_rewrite, + request_body_credential_rewrite: endpoint.request_body_credential_rewrite, + allow_uninspected_credentials: endpoint.allow_uninspected_credentials, + persisted_queries: endpoint.persisted_queries, + graphql_persisted_queries: endpoint + .graphql_persisted_queries + .into_iter() + .map(|(name, operation)| (name, operation.into())) + .collect(), + graphql_max_body_bytes: endpoint.graphql_max_body_bytes, + credential_signing: endpoint.credential_signing, + signing_service: endpoint.signing_service, + signing_region: endpoint.signing_region, + credential_binding: endpoint.credential_binding.map(Into::into), + json_rpc: endpoint.json_rpc.map(Into::into), + mcp: endpoint.mcp.map(Into::into), + }) + } +} + +impl TryFrom for NetworkEndpoint { + type Error = miette::Report; + + fn try_from(endpoint: proto::NetworkEndpoint) -> Result { + Ok(Self { + host: endpoint.host, + path: endpoint.path, + ports: endpoint + .ports + .into_iter() + .map(|port| { + u16::try_from(port) + .into_diagnostic() + .wrap_err("endpoint.ports values must be in 0..=65535") + }) + .collect::>()?, + protocol: endpoint.protocol, + tls: endpoint.tls, + enforcement: endpoint.enforcement, + access: endpoint.access, + rules: endpoint + .rules + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + allowed_ips: endpoint.allowed_ips, + deny_rules: endpoint + .deny_rules + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + allow_encoded_slash: endpoint.allow_encoded_slash, + websocket_credential_rewrite: endpoint.websocket_credential_rewrite, + request_body_credential_rewrite: endpoint.request_body_credential_rewrite, + allow_uninspected_credentials: endpoint.allow_uninspected_credentials, + persisted_queries: endpoint.persisted_queries, + graphql_persisted_queries: endpoint + .graphql_persisted_queries + .into_iter() + .map(|(name, operation)| (name, operation.into())) + .collect(), + graphql_max_body_bytes: endpoint.graphql_max_body_bytes, + credential_signing: endpoint.credential_signing, + signing_service: endpoint.signing_service, + signing_region: endpoint.signing_region, + credential_binding: endpoint.credential_binding.map(Into::into), + json_rpc: endpoint.json_rpc.map(Into::into), + mcp: endpoint.mcp.map(Into::into), + }) + } +} + +impl From for proto::NetworkCredentialBinding { + fn from(binding: NetworkCredentialBinding) -> Self { + Self { + provider: binding.provider, + } + } +} + +impl From for NetworkCredentialBinding { + fn from(binding: proto::NetworkCredentialBinding) -> Self { + Self { + provider: binding.provider, + } + } +} + +impl From for proto::JsonRpcConfig { + fn from(config: JsonRpcConfig) -> Self { + Self { + max_body_bytes: config.max_body_bytes, + } + } +} + +impl From for JsonRpcConfig { + fn from(config: proto::JsonRpcConfig) -> Self { + Self { + max_body_bytes: config.max_body_bytes, + } + } +} + +impl From for proto::McpConfig { + fn from(config: McpConfig) -> Self { + Self { + versions: config.versions.unwrap_or_default(), + max_body_bytes: config.max_body_bytes, + strict_tool_names: config.strict_tool_names, + allow_all_known_mcp_methods: config.allow_all_known_mcp_methods, + } + } +} + +impl From for McpConfig { + fn from(config: proto::McpConfig) -> Self { + Self { + versions: (!config.versions.is_empty()).then_some(config.versions), + max_body_bytes: config.max_body_bytes, + strict_tool_names: config.strict_tool_names, + allow_all_known_mcp_methods: config.allow_all_known_mcp_methods, + } + } +} + +impl From for proto::GraphqlOperation { + fn from(operation: GraphqlOperation) -> Self { + Self { + operation_type: operation.operation_type, + operation_name: operation.operation_name, + fields: operation.fields, + } + } +} + +impl From for GraphqlOperation { + fn from(operation: proto::GraphqlOperation) -> Self { + Self { + operation_type: operation.operation_type, + operation_name: operation.operation_name, + fields: operation.fields, + } + } +} + +impl TryFrom for proto::L7Rule { + type Error = miette::Report; + + fn try_from(rule: L7Rule) -> Result { + Ok(Self { + allow: Some(rule.allow.try_into()?), + }) + } +} + +impl TryFrom for L7Rule { + type Error = miette::Report; + + fn try_from(rule: proto::L7Rule) -> Result { + Ok(Self { + allow: rule + .allow + .ok_or_else(|| miette::miette!("L7Rule.allow is required"))? + .try_into()?, + }) + } +} + +impl TryFrom for proto::L7Allow { + type Error = miette::Report; + + fn try_from(allow: L7Allow) -> Result { + Ok(Self { + method: allow.method, + path: allow.path, + command: allow.command, + query: allow + .query + .into_iter() + .map(|(name, matcher)| (name, matcher.into())) + .collect(), + operation_type: allow.operation_type, + operation_name: allow.operation_name, + fields: allow.fields, + tool: allow.tool.map(Into::into), + params: allow + .params + .into_iter() + .map(|(name, matcher)| (name, matcher.into())) + .collect(), + }) + } +} + +impl TryFrom for L7Allow { + type Error = miette::Report; + + fn try_from(allow: proto::L7Allow) -> Result { + Ok(Self { + method: allow.method, + path: allow.path, + command: allow.command, + query: allow + .query + .into_iter() + .map(|(name, matcher)| Ok((name, matcher.try_into()?))) + .collect::>()?, + operation_type: allow.operation_type, + operation_name: allow.operation_name, + fields: allow.fields, + tool: allow.tool.map(TryInto::try_into).transpose()?, + params: allow + .params + .into_iter() + .map(|(name, matcher)| Ok((name, matcher.try_into()?))) + .collect::>()?, + }) + } +} + +impl TryFrom for proto::L7DenyRule { + type Error = miette::Report; + + fn try_from(rule: L7DenyRule) -> Result { + Ok(Self { + method: rule.method, + path: rule.path, + command: rule.command, + query: rule + .query + .into_iter() + .map(|(name, matcher)| (name, matcher.into())) + .collect(), + operation_type: rule.operation_type, + operation_name: rule.operation_name, + fields: rule.fields, + tool: rule.tool.map(Into::into), + params: rule + .params + .into_iter() + .map(|(name, matcher)| (name, matcher.into())) + .collect(), + }) + } +} + +impl TryFrom for L7DenyRule { + type Error = miette::Report; + + fn try_from(rule: proto::L7DenyRule) -> Result { + Ok(Self { + method: rule.method, + path: rule.path, + command: rule.command, + query: rule + .query + .into_iter() + .map(|(name, matcher)| Ok((name, matcher.try_into()?))) + .collect::>()?, + operation_type: rule.operation_type, + operation_name: rule.operation_name, + fields: rule.fields, + tool: rule.tool.map(TryInto::try_into).transpose()?, + params: rule + .params + .into_iter() + .map(|(name, matcher)| Ok((name, matcher.try_into()?))) + .collect::>()?, + }) + } +} + +impl From for proto::Matcher { + fn from(matcher: QueryMatcher) -> Self { + let kind = match matcher { + QueryMatcher::Glob(glob) => proto::matcher::Kind::Glob(glob), + QueryMatcher::Any(any) => { + proto::matcher::Kind::Any(proto::AnyMatcher { values: any.any }) + } + }; + Self { kind: Some(kind) } + } +} + +impl TryFrom for QueryMatcher { + type Error = miette::Report; + + fn try_from(matcher: proto::Matcher) -> Result { + match matcher.kind { + Some(proto::matcher::Kind::Glob(glob)) => Ok(Self::Glob(glob)), + Some(proto::matcher::Kind::Any(any)) => Ok(Self::Any(AnyMatcher { any: any.values })), + None => miette::bail!("matcher kind is required"), + } + } +} + +impl From for proto::ParameterMatcher { + fn from(matcher: ParameterMatcher) -> Self { + let kind = match matcher { + ParameterMatcher::Matcher(matcher) => { + proto::parameter_matcher::Kind::Matcher(matcher.into()) + } + ParameterMatcher::Object(fields) => { + proto::parameter_matcher::Kind::Object(proto::ParameterObject { + fields: fields + .into_iter() + .map(|(name, matcher)| (name, matcher.into())) + .collect(), + }) + } + }; + Self { kind: Some(kind) } + } +} + +impl TryFrom for ParameterMatcher { + type Error = miette::Report; + + fn try_from(matcher: proto::ParameterMatcher) -> Result { + match matcher.kind { + Some(proto::parameter_matcher::Kind::Matcher(matcher)) => { + Ok(Self::Matcher(matcher.try_into()?)) + } + Some(proto::parameter_matcher::Kind::Object(object)) => Ok(Self::Object( + object + .fields + .into_iter() + .map(|(name, matcher)| Ok((name, matcher.try_into()?))) + .collect::>()?, + )), + None => miette::bail!("parameter matcher kind is required"), + } + } +} + +impl From for proto::NetworkBinary { + fn from(binary: NetworkBinary) -> Self { + Self { path: binary.path } + } +} + +impl From for NetworkBinary { + fn from(binary: proto::NetworkBinary) -> Self { + Self { path: binary.path } + } +} + +impl TryFrom for proto::NetworkMiddleware { + type Error = miette::Report; + + fn try_from(middleware: NetworkMiddleware) -> Result { + Ok(Self { + name: middleware.name, + middleware: middleware.middleware, + order: middleware.order, + config: (!middleware.config.is_empty()) + .then(|| json_object_to_struct(middleware.config)) + .transpose()?, + on_error: middleware.on_error, + endpoints: middleware.endpoints.map(Into::into), + }) + } +} + +impl TryFrom for NetworkMiddleware { + type Error = miette::Report; + + fn try_from(middleware: proto::NetworkMiddleware) -> Result { + Ok(Self { + name: middleware.name, + middleware: middleware.middleware, + order: middleware.order, + config: middleware + .config + .map(struct_to_json_object) + .transpose()? + .unwrap_or_default(), + on_error: middleware.on_error, + endpoints: middleware.endpoints.map(Into::into), + }) + } +} + +impl From for proto::MiddlewareEndpointSelector { + fn from(selector: MiddlewareEndpointSelector) -> Self { + Self { + include: selector.include, + exclude: selector.exclude, + } + } +} + +impl From for MiddlewareEndpointSelector { + fn from(selector: proto::MiddlewareEndpointSelector) -> Self { + Self { + include: selector.include, + exclude: selector.exclude, + } + } +} + +fn json_object_to_struct(config: BTreeMap) -> Result { + Ok(Struct { + fields: config + .into_iter() + .map(|(name, value)| Ok((name, json_to_proto_value(value)?))) + .collect::>()?, + }) +} + +fn json_to_proto_value(value: serde_json::Value) -> Result { + let kind = match value { + serde_json::Value::Null => value::Kind::NullValue(0), + serde_json::Value::Bool(value) => value::Kind::BoolValue(value), + serde_json::Value::Number(value) => value::Kind::NumberValue(number_to_f64_exact(&value)?), + serde_json::Value::String(value) => value::Kind::StringValue(value), + serde_json::Value::Array(values) => value::Kind::ListValue(ListValue { + values: values + .into_iter() + .map(json_to_proto_value) + .collect::>()?, + }), + serde_json::Value::Object(fields) => value::Kind::StructValue(Struct { + fields: fields + .into_iter() + .map(|(name, value)| Ok((name, json_to_proto_value(value)?))) + .collect::>()?, + }), + }; + Ok(Value { kind: Some(kind) }) +} + +fn number_to_f64_exact(value: &serde_json::Number) -> Result { + let number = value.as_f64().ok_or_else(|| { + miette::miette!( + "middleware config number {value} is not representable as a protobuf double" + ) + })?; + let exact = value.as_i64().map_or_else( + || value.as_u64().is_none_or(integer_is_exact_in_f64), + |integer| integer_is_exact_in_f64(integer.unsigned_abs()), + ); + exact.then_some(number).ok_or_else(|| { + miette::miette!( + "middleware config number {value} is not representable exactly as a protobuf double" + ) + }) +} + +fn integer_is_exact_in_f64(integer: u64) -> bool { + integer == 0 + || (u64::BITS - integer.leading_zeros()).saturating_sub(integer.trailing_zeros()) + <= f64::MANTISSA_DIGITS +} + +fn struct_to_json_object(config: Struct) -> Result> { + config + .fields + .into_iter() + .map(|(name, value)| Ok((name, proto_to_json_value(value)?))) + .collect() +} + +fn proto_to_json_value(value: Value) -> Result { + match value.kind { + Some(value::Kind::NullValue(_)) => Ok(serde_json::Value::Null), + Some(value::Kind::BoolValue(value)) => Ok(serde_json::Value::Bool(value)), + Some(value::Kind::NumberValue(value)) => serde_json::Number::from_f64(value) + .map(serde_json::Value::Number) + .ok_or_else(|| miette::miette!("middleware config contains a non-finite number")), + Some(value::Kind::StringValue(value)) => Ok(serde_json::Value::String(value)), + Some(value::Kind::ListValue(list)) => Ok(serde_json::Value::Array( + list.values + .into_iter() + .map(proto_to_json_value) + .collect::>()?, + )), + Some(value::Kind::StructValue(object)) => Ok(serde_json::Value::Object( + object + .fields + .into_iter() + .map(|(name, value)| Ok((name, proto_to_json_value(value)?))) + .collect::>()?, + )), + None => miette::bail!("middleware config value is missing its kind"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_policy_round_trips_canonical_yaml() { + let source = r#" +version: 1 +filesystem_policy: {} +network_policies: + mcp: + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + mcp: + versions: ["2025-11-25"] + strict_tool_names: false + rules: + - allow: + method: tools/call + tool: + glob: search_* + params: + arguments: + object: + fields: + query: + matcher: + glob: "public-*" + binaries: + - path: /usr/bin/agent +network_middlewares: + audit: + middleware: example/audit + config: + enabled: true + nullable: null +"#; + + let policy = parse_policy_proto(source).expect("generated policy must parse"); + assert!(policy.filesystem_policy.is_some()); + assert_eq!( + policy.network_policies["mcp"].endpoints[0] + .mcp + .as_ref() + .and_then(|mcp| mcp.strict_tool_names), + Some(false) + ); + let yaml = serialize_policy_proto(&policy).expect("generated policy must serialize"); + let reparsed = parse_policy_proto(&yaml).expect("canonical YAML must parse"); + assert_eq!(policy, reparsed); + } + + #[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, ports: [443], protocol: mcp, mcp: {} }\n", + ) + .unwrap(); + assert!( + absent.network_policies["mcp"].endpoints[0] + .mcp + .as_ref() + .unwrap() + .versions + .is_empty() + ); + assert!(validate_authored_policy(&absent).is_ok()); + } + + #[test] + fn generated_policy_rejects_runtime_authority_by_construction() { + let fields = proto::NetworkEndpoint::default(); + let debug = format!("{fields:?}"); + assert!(!debug.contains("advisor_proposed")); + assert!(!debug.contains("provider_credentialed")); + } + + #[test] + fn generated_policy_rejects_middleware_integers_that_protobuf_would_round() { + let source = r" +version: 1 +network_middlewares: + audit: + middleware: example/audit + config: + request_id: 9007199254740993 +"; + let error = parse_policy_proto(source).expect_err("integer must not be rounded"); + assert!(error.to_string().contains("cannot be represented exactly")); + } + + #[test] + fn generated_policy_rejects_legacy_scalar_matchers() { + let source = r" +version: 1 +network_policies: + mcp: + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + rules: + - allow: + tool: search_* +"; + let error = parse_policy_proto(source).expect_err("scalar matcher must be rejected"); + assert!(error.to_string().contains("proto-shaped")); + } + + #[test] + fn generated_policy_rejects_legacy_non_proto_shapes() { + let cases = [ + ( + "scalar query matcher", + r" +version: 1 +network_policies: + api: + endpoints: + - host: api.example.com + ports: [443] + rules: + - allow: + query: + owner: NVIDIA/* +", + ), + ( + "legacy any matcher", + r" +version: 1 +network_policies: + api: + endpoints: + - host: api.example.com + ports: [443] + rules: + - allow: + query: + owner: + any: [NVIDIA/*, openai/*] +", + ), + ( + "scalar parameter matcher", + r" +version: 1 +network_policies: + mcp: + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + rules: + - allow: + method: tools/call + params: + name: search_* +", + ), + ( + "unwrapped parameter object", + r" +version: 1 +network_policies: + mcp: + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + rules: + - allow: + method: tools/call + params: + arguments: + query: public-* +", + ), + ( + "scalar binary", + r" +version: 1 +network_policies: + api: + binaries: [/usr/bin/curl] +", + ), + ]; + + for (name, source) in cases { + let Err(error) = parse_policy_proto(source) else { + panic!("{name} unexpectedly parsed"); + }; + assert!( + error.to_string().contains("proto-shaped"), + "{name}: {error:?}" + ); + } + } + + #[test] + fn generated_policy_rejects_incomplete_and_ambiguous_oneofs() { + let cases = [ + ( + "missing matcher kind", + r" +version: 1 +network_policies: + api: + endpoints: + - host: api.example.com + ports: [443] + rules: + - allow: + query: + owner: {} +", + ), + ( + "multiple matcher kinds", + r" +version: 1 +network_policies: + api: + endpoints: + - host: api.example.com + ports: [443] + rules: + - allow: + tool: + glob: search_* + any: + values: [fetch_*] +", + ), + ( + "missing parameter matcher kind", + r" +version: 1 +network_policies: + mcp: + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + rules: + - allow: + method: tools/call + params: + arguments: {} +", + ), + ]; + + for (name, source) in cases { + assert!( + parse_policy_proto(source).is_err(), + "{name} unexpectedly parsed" + ); + } + } + + #[test] + 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 +network_policies: + mcp: + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + mcp: + versions: null + rules: + - allow: + method: tools/call + tool: null +", + ) + .expect_err("typed nulls must not silently become absent fields"); + assert!(typed_null.to_string().contains("null is not allowed")); + + 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 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] + fn generated_policy_follows_protobuf_json_scalar_rules() { + let policy = parse_policy_proto( + r#" +version: "1" +network_policies: + api: + endpoints: + - host: api.example.com + ports: ["443"] +"#, + ) + .expect("protobuf JSON permits quoted integer scalars"); + assert_eq!(policy.version, 1); + 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") + .version, + 1 + ); + + for (name, source) in [ + ( + "number for string", + "version: 1\nprocess: { run_as_user: 1000 }\n", + ), + ( + "string for bool", + "version: 1\nfilesystem_policy: { include_workdir: \"true\" }\n", + ), + ("non-integral number", "version: 1.5\n"), + ("negative unsigned integer", "version: -1\n"), + ("out-of-range uint32", "version: 4294967296\n"), + ] { + assert!( + parse_policy_proto(source).is_err(), + "{name} unexpectedly parsed" + ); + } + } + + #[test] + fn generated_policy_accepts_proto_json_names_but_serializes_proto_names() { + let policy = parse_policy_proto( + r" +version: 1 +filesystemPolicy: + includeWorkdir: true + readOnly: [/usr] +", + ) + .expect("protobuf JSON lowerCamelCase names must parse"); + assert!(policy.filesystem_policy.as_ref().unwrap().include_workdir); + + let yaml = serialize_policy_proto(&policy).expect("policy must serialize"); + assert!(yaml.contains("filesystem_policy:")); + assert!(yaml.contains("include_workdir: true")); + assert!(!yaml.contains("filesystemPolicy")); + } + + #[test] + fn generated_policy_rejects_public_attempts_to_set_internal_authority() { + let error = parse_policy_proto( + r" +version: 1 +network_policies: + api: + endpoints: + - host: api.example.com + ports: [443] + advisor_proposed: true +", + ) + .expect_err("runtime-only authority must not be accepted from YAML"); + assert!(error.to_string().contains("proto-shaped")); + } + + #[test] + fn generated_policy_rejects_yaml_features_outside_the_safe_profile() { + let cases = [ + ("duplicate key", "version: 1\nversion: 1\n"), + ( + "merge key", + "version: 1\nbase: &base { include_workdir: true }\nfilesystem_policy:\n <<: *base\n", + ), + ("multiple documents", "version: 1\n---\nversion: 1\n"), + ("custom YAML tag", "version: !custom 1\n"), + ( + "non-finite number", + "version: 1\nnetwork_middlewares:\n audit:\n middleware: example/audit\n config: { threshold: .nan }\n", + ), + ( + "non-string protobuf map key", + "version: 1\nnetwork_policies:\n 1: {}\n", + ), + ( + "non-string protobuf flow-map key", + "version: 1\nnetwork_policies: {1: {}}\n", + ), + ]; + + for (name, source) in cases { + assert!( + parse_policy_proto(source).is_err(), + "{name} unexpectedly parsed" + ); + } + } + + #[test] + fn generated_policy_accepts_quoted_and_plain_string_mapping_keys() { + let policy = parse_policy_proto( + "version: 1\nnetwork_policies:\n \"1\":\n endpoints: []\n ordinary-name:\n endpoints: []\n", + ) + .expect("quoted and ordinary string mapping keys should parse"); + + assert!(policy.network_policies.contains_key("1")); + assert!(policy.network_policies.contains_key("ordinary-name")); + } + + #[test] + fn generated_policy_enforces_default_input_and_collection_limits() { + let limits = ParseLimits::default(); + let prefix = "version: 1\n#"; + let at_limit = format!("{prefix}{}", "x".repeat(limits.max_bytes - prefix.len())); + assert_eq!(at_limit.len(), limits.max_bytes); + parse_policy_proto(&at_limit).expect("document at byte limit must parse"); + let over_limit = format!("{at_limit}x"); + assert!(parse_policy_proto(&over_limit).is_err()); + + let mut oversized_sequence = String::from("version: 1\nfilesystem_policy:\n read_only:\n"); + for _ in 0..=limits.max_sequence_elements { + oversized_sequence.push_str(" - /usr\n"); + } + assert!(parse_policy_proto(&oversized_sequence).is_err()); + + let mut oversized_map = String::from("version: 1\nnetwork_policies:\n"); + for index in 0..=limits.max_mapping_keys { + use std::fmt::Write as _; + writeln!(oversized_map, " rule_{index}: {{}}").unwrap(); + } + assert!(parse_policy_proto(&oversized_map).is_err()); + + let mut deeply_nested = String::from( + "version: 1\nnetwork_middlewares:\n audit:\n middleware: example/audit\n config: ", + ); + for _ in 0..limits.max_depth { + deeply_nested.push_str("{child: "); + } + deeply_nested.push_str("null"); + for _ in 0..limits.max_depth { + deeply_nested.push('}'); + } + deeply_nested.push('\n'); + assert!(parse_policy_proto(&deeply_nested).is_err()); + + let alias_amplification = format!( + "version: 1\nnetwork_middlewares:\n audit:\n middleware: example/audit\n config:\n base: &base {{ value: x }}\n copies: [{}]\n", + std::iter::repeat_n("*base", 6) + .collect::>() + .join(", ") + ); + assert!(parse_policy_proto(&alias_amplification).is_err()); + } +} diff --git a/crates/openshell-policy-schema/src/lib.rs b/crates/openshell-policy-schema/src/lib.rs index 62c2b06dfe..f19402fce9 100644 --- a/crates/openshell-policy-schema/src/lib.rs +++ b/crates/openshell-policy-schema/src/lib.rs @@ -7,6 +7,29 @@ //! pure schema validation, and lexical policy-path normalization. Runtime and //! protobuf adaptation intentionally live in `openshell-policy`. +/// Generated public authored-policy messages. +pub mod proto { + #![allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + dead_code, + unused_imports, + unused_qualifications, + rust_2018_idioms + )] + + include!(concat!(env!("OUT_DIR"), "/openshell.policy.v1.rs")); +} + +mod generated; + +pub use generated::{ + parse_policy_proto, parse_policy_proto_file, policy_proto_to_json_value, + serialize_policy_proto, validate_authored_l7_deny_rule, validate_authored_l7_rule, + validate_authored_network_binary, validate_authored_policy, +}; + use std::collections::BTreeMap; use std::fmt; use std::fs::File; @@ -218,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")] @@ -457,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 { @@ -728,7 +741,6 @@ fn inspect_endpoint(value: &serde_yml::Value, path: &str) -> InspectionResult { &[ "host", "path", - "port", "ports", "protocol", "tls", @@ -1020,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. @@ -1130,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()); } @@ -1141,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(), @@ -1181,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", ), ( @@ -1197,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", ), ]; @@ -1256,7 +1264,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {} rules: @@ -1280,7 +1288,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {} rules: @@ -1340,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 5443883ac9..9346c2fdb6 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -55,6 +55,7 @@ pub use middleware::validate_json_with_config as validate_network_middleware_jso // The authored serde tree lives in `openshell-policy-schema`. These local // aliases keep the protobuf adapter readable while exposing consumer-facing // names from the schema crate. +use openshell_policy_schema::proto as authored; use openshell_policy_schema::{ AnyMatcher as QueryAnyDef, FilesystemPolicy as FilesystemDef, GraphqlOperation as GraphqlOperationDef, JsonRpcConfig as JsonRpcConfigDef, @@ -158,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 }) } } @@ -521,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, @@ -675,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!( @@ -688,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) @@ -735,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) @@ -863,22 +857,157 @@ pub fn is_valid_sandbox_identity(value: &str) -> bool { // Public API // --------------------------------------------------------------------------- +/// Lower an author-controlled public policy into the normalized internal model. +/// +/// 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::PolicyDocument) -> Result { + openshell_policy_schema::validate_authored_policy(&policy)?; + let document = PolicyFile::try_from(policy)?; + to_proto(document) +} + +/// Project an internal base policy into the public author-controlled model. +/// +/// The projection is canonical and intentionally removes runtime provenance. +pub fn project_base_policy(policy: &SandboxPolicy) -> Result { + validate_proto_version_for_authored_serialization(policy)?; + validate_policy_enum_values(policy)?; + let mut canonical = validate_and_canonicalize_mcp_policy_schema(policy.clone()) + .map_err(|error| miette::miette!("cannot project invalid sandbox policy: {error}"))?; + 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 { + project_base_policy(policy) +} + +/// Lower one public rule through the same checked policy boundary. +pub fn lower_authored_rule( + rule_name: &str, + rule: authored::NetworkPolicyRule, +) -> Result { + let policy = authored::PolicyDocument { + version: 1, + filesystem_policy: None, + landlock: None, + process: None, + network_policies: HashMap::from([(rule_name.to_string(), rule)]), + network_middlewares: HashMap::new(), + }; + lower_authored_policy(policy)? + .network_policies + .remove(rule_name) + .ok_or_else(|| miette::miette!("lowered policy did not retain rule '{rule_name}'")) +} + +/// Project one internal rule into the public author-controlled model. +pub fn project_authored_rule( + rule_name: &str, + rule: &NetworkPolicyRule, +) -> Result { + let internal = SandboxPolicy { + version: 1, + network_policies: HashMap::from([(rule_name.to_string(), rule.clone())]), + ..Default::default() + }; + project_base_policy(&internal)? + .network_policies + .remove(rule_name) + .ok_or_else(|| miette::miette!("projected policy did not retain rule '{rule_name}'")) +} + +/// 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)), + }) +} + +/// 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 + .allow + .clone() + .ok_or_else(|| miette::miette!("L7Rule.allow is required"))?; + L7RuleDef { + allow: allow_proto_to_def("", allow, false), + } + .try_into() +} + +/// Project one normalized L7 deny rule for an incremental merge operation. +pub fn project_authored_l7_deny_rule(rule: &L7DenyRule) -> Result { + deny_proto_to_def("", rule, false).try_into() +} + +/// Parse compatible policy YAML directly into the public generated message. +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 { + openshell_policy_schema::parse_policy_proto_file( + path, + openshell_policy_schema::ParseLimits::default(), + ) +} + +/// Serialize a public generated policy to canonical YAML. +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::PolicyDocument, +) -> Result { + openshell_policy_schema::policy_proto_to_json_value(policy) +} + // Validate raw authored values and their relationship to the endpoint protocol // before conversion. Keeping validation outside the Serde error wrapper makes // actionable MCP diagnostics the top-level user-facing error. /// Parse a sandbox policy from a YAML string. pub fn parse_sandbox_policy(yaml: &str) -> Result { - let raw = openshell_policy_schema::parse_policy(yaml)?; - to_proto(raw) + lower_authored_policy(parse_authored_policy(yaml)?) } /// Parse a sandbox policy from a regular file using the shared bounded reader. pub fn parse_sandbox_policy_file(path: &Path) -> Result { - let raw = openshell_policy_schema::parse_policy_file( - path, - openshell_policy_schema::ParseLimits::default(), - )?; - to_proto(raw) + lower_authored_policy(parse_authored_policy_file(path)?) } /// Serialize a proto sandbox policy to a YAML string. @@ -887,12 +1016,7 @@ pub fn parse_sandbox_policy_file(path: &Path) -> Result { /// canonical YAML field names (e.g. `filesystem_policy`, not `filesystem`) /// and is round-trippable through `parse_sandbox_policy`. pub fn serialize_sandbox_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()) - .map_err(|error| miette::miette!("cannot serialize invalid sandbox policy: {error}"))?; - let yaml_repr = from_proto(&canonical)?; - openshell_policy_schema::serialize_policy(&yaml_repr) + serialize_authored_policy(&project_base_policy(policy)?) } /// Convert a proto sandbox policy into the canonical policy JSON representation. @@ -900,12 +1024,7 @@ pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result { /// The shape mirrors the YAML schema used by [`serialize_sandbox_policy`], so /// automation can use the same documented field names in either format. pub fn sandbox_policy_to_json_value(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()) - .map_err(|error| miette::miette!("cannot serialize invalid sandbox policy: {error}"))?; - let json_repr = from_proto(&canonical)?; - openshell_policy_schema::policy_to_json_value(&json_repr) + authored_policy_to_json_value(&project_base_policy(policy)?) } fn validate_proto_version_for_authored_serialization(policy: &SandboxPolicy) -> Result<()> { @@ -962,6 +1081,19 @@ pub fn load_sandbox_policy(cli_path: Option<&str>) -> Result) -> 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") { + parse_authored_policy_file(Path::new(&policy_path))? + } else { + return Ok(None); + }; + Ok(Some(policy)) +} + /// Well-known path where a sandbox container image can ship a policy YAML file. /// /// When the gateway provides no policy at sandbox creation time, the sandbox @@ -1876,6 +2008,7 @@ pub fn validate_and_canonicalize_sandbox_policy( .map_err(|violations| PolicyValidationError { violations })?; materialize_default_mcp_versions(&mut policy); canonicalize_mcp_version_allowlists(&mut policy); + canonicalize_endpoint_ports(&mut policy); debug_assert!( validate_sandbox_policy(&policy).is_ok(), "validated MCP canonicalization must preserve every policy invariant" @@ -1883,6 +2016,21 @@ pub fn validate_and_canonicalize_sandbox_policy( Ok(policy) } +/// Materialize the effective port list while retaining the legacy scalar field. +/// This gives policies authored with `port` and `ports` one internal identity. +fn canonicalize_endpoint_ports(policy: &mut SandboxPolicy) { + for rule in policy.network_policies.values_mut() { + for endpoint in &mut rule.endpoints { + if endpoint.ports.is_empty() && endpoint.port != 0 { + endpoint.ports.push(endpoint.port); + } + if let Some(first) = endpoint.ports.first().copied() { + endpoint.port = first; + } + } + } +} + /// Replace absent protobuf MCP options and empty revision lists with the /// single pinned policy default while preserving every explicit MCP option. pub(crate) fn materialize_default_mcp_versions(policy: &mut SandboxPolicy) { @@ -1988,7 +2136,7 @@ network_policies: github: endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: https binaries: - path: /usr/bin/curl @@ -2012,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" @@ -2039,7 +2187,7 @@ network_policies: name: my-custom-api-name endpoints: - host: api.example.com - port: 443 + ports: [443] binaries: - path: /usr/bin/curl "; @@ -2075,7 +2223,7 @@ network_policies: name: api endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest binaries: - path: /usr/bin/curl @@ -2196,7 +2344,7 @@ network_policies: github_api: endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest tls: skp enforcement: enforc @@ -2331,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 } "; @@ -2355,16 +2503,18 @@ network_policies: name: query_test endpoints: - host: api.example.com - port: 8080 + ports: [8080] protocol: rest rules: - allow: method: GET path: /download query: - slug: "my-*" + slug: + glob: "my-*" tag: - any: ["foo-*", "bar-*"] + any: + values: ["foo-*", "bar-*"] binaries: - path: /usr/bin/curl "#; @@ -2404,7 +2554,7 @@ network_policies: middleware: [redact] endpoints: - host: api.example.com - port: 443 + ports: [443] "; assert!(parse_sandbox_policy(policy_attachment).is_err()); @@ -2414,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()); @@ -2541,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}" ) } @@ -2606,10 +2756,8 @@ network_policies: } #[test] - fn mcp_version_yaml_rejects_explicit_empty_duplicate_unknown_and_misplaced_values() { + fn mcp_version_yaml_uses_protobuf_empty_semantics_and_rejects_invalid_values() { let cases = [ - ("null allowlist", "mcp", Some(" versions: null\n")), - ("empty allowlist", "mcp", Some(" versions: []\n")), ( "empty identifier", "mcp", @@ -2657,11 +2805,34 @@ network_policies: assert!(parse_sandbox_policy(&yaml).is_err(), "{case} must fail"); } + let policy = parse_sandbox_policy(&mcp_version_endpoint_yaml( + "mcp", + Some(" versions: []\n"), + )) + .expect("an empty repeated field uses protobuf omission semantics"); + assert_eq!( + 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(), - "an explicit null MCP stanza must fail" + "typed null messages must not collapse to protobuf omission" ); } @@ -2715,12 +2886,13 @@ network_policies: fn protobuf_missing_and_empty_mcp_options_materialize_the_same_default() { let missing = mcp_version_policy("mcp", None); let empty = mcp_version_policy("mcp", Some(McpOptions::default())); - let explicit = mcp_version_policy( + let explicit = validate_and_canonicalize_sandbox_policy(mcp_version_policy( "mcp", Some(mcp_version_options( &[DEFAULT_MCP_PROTOCOL_VERSION.as_str()], )), - ); + )) + .expect("explicit default must canonicalize"); for raw in [&missing, &empty] { assert!(matches!( @@ -3463,7 +3635,93 @@ network_policies: } #[test] - fn validate_rejects_yaml_tcp_endpoint_without_host_or_port() { + 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: + invalid: + endpoints: + - host: "" + protocol: tcp +"#, + ) + .expect_err("the portable ports constraint must reject the endpoint first"); + assert!(format!("{error:?}").contains("ports")); + let policy = parse_sandbox_policy( r#" version: 1 @@ -3471,12 +3729,13 @@ network_policies: invalid: endpoints: - host: "" + ports: [443] protocol: tcp "#, ) - .expect("policy syntax should parse before semantic validation"); + .expect("portable validation should accept the structurally complete endpoint"); - let violations = validate_sandbox_policy(&policy).expect_err("endpoint is incomplete"); + 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" @@ -3486,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] @@ -3500,7 +3755,7 @@ version: 1 network_policies: legacy-proxy: endpoints: - - port: 9443 + - ports: [9443] allowed_ips: - 10.0.5.0/24 ", @@ -3519,7 +3774,7 @@ version: 1 network_policies: native-tcp: endpoints: - - port: 6379 + - ports: [6379] protocol: tcp allowed_ips: - 10.0.5.0/24 @@ -4266,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 } "; @@ -4285,7 +4540,7 @@ network_policies: name: test endpoints: - host: api.example.com - port: 443 + ports: [443] path: "/graphql" protocol: graphql rules: @@ -4312,7 +4567,7 @@ network_policies: gcp_storage: endpoints: - host: storage.googleapis.com - port: 443 + ports: [443] protocol: rest credential_binding: provider: work-gcp @@ -4361,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] @@ -4393,7 +4644,7 @@ network_policies: test: name: test endpoints: - - { host: "*.example.com", port: 443 } + - { host: "*.example.com", ports: [443] } binaries: - { path: /usr/bin/curl } "#; @@ -4411,7 +4662,7 @@ network_policies: name: test endpoints: - host: "*.example.com" - port: 443 + ports: [443] binaries: - { path: /usr/bin/curl } "#; @@ -4433,7 +4684,7 @@ network_policies: name: github endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest access: read-write deny_rules: @@ -4462,7 +4713,7 @@ network_policies: name: github endpoints: - host: api.github.com - port: 443 + ports: [443] protocol: rest access: full deny_rules: @@ -4471,7 +4722,8 @@ network_policies: - method: DELETE path: "/repos/*/branches/*/protection" query: - force: "true" + force: + glob: "true" binaries: - path: /usr/bin/curl "#; @@ -4497,7 +4749,7 @@ network_policies: name: test endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: full deny_rules: @@ -4505,7 +4757,8 @@ network_policies: path: /action query: type: - any: ["admin-*", "root-*"] + any: + values: ["admin-*", "root-*"] binaries: - path: /usr/bin/curl "#; @@ -4523,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 @@ -4574,7 +4827,7 @@ network_policies: name: jsonrpc_api endpoints: - host: jsonrpc.example.com - port: 443 + ports: [443] protocol: json-rpc enforcement: enforce json_rpc: @@ -4603,7 +4856,7 @@ network_policies: name: mcp endpoints: - host: mcp.example.com - port: 443 + ports: [443] path: /mcp protocol: mcp enforcement: enforce @@ -4619,10 +4872,12 @@ network_policies: - allow: method: tools/call tool: - any: [search_web, list_tools] + any: + values: [search_web, list_tools] deny_rules: - method: tools/call - tool: send_email + tool: + glob: send_email binaries: - path: /usr/bin/curl "; @@ -4657,7 +4912,7 @@ network_policies: name: mcp endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: [2025-03-26] @@ -4666,11 +4921,13 @@ network_policies: rules: - allow: method: tools/call - tool: search_web + tool: + glob: search_web deny_rules: - method: tools/call tool: - any: [send_email, delete_resource] + any: + values: [send_email, delete_resource] binaries: - path: /usr/bin/curl "; @@ -4680,7 +4937,7 @@ network_policies: assert!(yaml_out.contains("protocol: mcp")); assert!(yaml_out.contains("method: tools/call")); - assert!(yaml_out.contains("tool: search_web")); + assert!(yaml_out.contains("glob: search_web")); assert!(yaml_out.contains("any:")); assert!(yaml_out.contains("- send_email")); assert!(yaml_out.contains("- delete_resource")); @@ -4699,7 +4956,7 @@ network_policies: mcp: endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {} rules: @@ -4707,8 +4964,14 @@ network_policies: method: tools/call params: arguments: - any: "first" - other: "second" + object: + fields: + any: + matcher: + glob: "first" + other: + matcher: + glob: "second" "#; let proto = parse_sandbox_policy(yaml).expect("authored policy must parse"); @@ -4722,8 +4985,8 @@ network_policies: let serialized = serialize_sandbox_policy(&proto).expect("protobuf policy must serialize"); assert!(serialized.contains("arguments:")); - assert!(serialized.contains("any: first")); - assert!(serialized.contains("other: second")); + assert!(serialized.contains("glob: first")); + assert!(serialized.contains("glob: second")); let reparsed = parse_sandbox_policy(&serialized).expect("serialized protobuf policy must parse again"); @@ -4738,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 @@ -4764,7 +5027,7 @@ network_policies: name: discord_gateway endpoints: - host: gateway.example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: full @@ -4791,7 +5054,7 @@ network_policies: name: slack_api endpoints: - host: slack.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: read-write @@ -4817,7 +5080,7 @@ network_policies: vendor_api: endpoints: - host: api.vendor.example - port: 443 + ports: [443] tls: skip allow_uninspected_credentials: true "; @@ -4843,7 +5106,7 @@ network_policies: gateway: endpoints: - host: gateway.example.com - port: 443 + ports: [443] protocol: rest access: full binaries: @@ -4865,7 +5128,7 @@ network_policies: test: endpoints: - host: example.com - port: 443 + ports: [443] deny_rules: - method: POST path: /foo @@ -4882,7 +5145,7 @@ network_policies: legacy: endpoints: - host: example.com - port: 443 + ports: [443] binaries: - path: /usr/bin/curl harness: true @@ -4891,7 +5154,7 @@ network_policies: let error = parse_sandbox_policy(yaml).expect_err("removed harness field must be rejected"); let error_debug = format!("{error:?}"); assert!( - error_debug.contains("unknown field") && error_debug.contains("harness"), + error_debug.contains("unrecognized field") && error_debug.contains("harness"), "unexpected error: {error_debug}" ); } @@ -4904,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 1d6106029d..46fdbac700 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 new file mode 100644 index 0000000000..0169419501 --- /dev/null +++ b/crates/openshell-policy/tests/authored_boundary.rs @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_core::proto::{NetworkEndpoint as InternalEndpoint, SandboxPolicy as InternalPolicy}; +use openshell_policy::{ + lower_authored_policy, parse_authored_policy, project_base_policy, serialize_authored_policy, +}; + +#[test] +fn public_policy_lowers_and_projects_semantically() { + let authored = parse_authored_policy( + r" +version: 1 +filesystem_policy: {} +network_policies: + mcp: + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + mcp: {} + rules: + - allow: + method: tools/call + tool: + glob: search_* + binaries: + - path: /usr/bin/agent +", + ) + .unwrap(); + + let internal = lower_authored_policy(authored).unwrap(); + let endpoint = &internal.network_policies["mcp"].endpoints[0]; + assert!(!endpoint.advisor_proposed); + assert!(!endpoint.provider_credentialed); + assert_eq!(endpoint.mcp.as_ref().unwrap().versions, ["2025-11-25"]); + assert_eq!( + endpoint.rules[0].allow.as_ref().unwrap().params["name"].glob, + "search_*" + ); + + let projected = project_base_policy(&internal).unwrap(); + let canonical = serialize_authored_policy(&projected).unwrap(); + assert!(canonical.contains("glob: search_*")); + assert!(!canonical.contains("advisor_proposed")); + assert!(!canonical.contains("provider_credentialed")); + assert_eq!(lower_authored_policy(projected).unwrap(), internal); +} + +#[test] +fn projection_removes_internal_authority_without_mutating_internal_policy() { + let mut internal = InternalPolicy { + version: 1, + ..Default::default() + }; + internal.network_policies.insert( + "advisor".to_string(), + openshell_core::proto::NetworkPolicyRule { + name: "advisor".to_string(), + endpoints: vec![InternalEndpoint { + host: "example.com".to_string(), + port: 443, + ports: vec![443], + advisor_proposed: true, + provider_credentialed: true, + ..Default::default() + }], + ..Default::default() + }, + ); + + let projected = project_base_policy(&internal).unwrap(); + let lowered = lower_authored_policy(projected).unwrap(); + let lowered_endpoint = &lowered.network_policies["advisor"].endpoints[0]; + assert!(!lowered_endpoint.advisor_proposed); + assert!(!lowered_endpoint.provider_credentialed); + 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 414164c85d..17d1574867 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 ce116525f3..83017116eb 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 d2f2d37a84..06fd54abac 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 bba84da6e9..a77ab6906f 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 86397f754c..c6c7f31eea 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 2274846548..07514bf7b4 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 b8bd2bfc13..5b18aaaf22 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 0d4729f7e4..bd6fc7e3f4 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 e98d95319f..31a056f2b0 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 e5cd698019..8b6fdc687c 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -4,6 +4,7 @@ //! Declarative provider type profiles. use openshell_core::mcp::{DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion}; +use openshell_core::proto::policy as authored; use openshell_core::proto::{ GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, @@ -36,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 { @@ -53,6 +58,8 @@ pub enum ProfileError { field: String, message: String, }, + #[error("provider profile contains invalid authored network policy: {0}")] + InvalidAuthoredPolicy(String), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -317,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, @@ -329,7 +337,6 @@ pub struct DiscoveryProfile { )] pub struct EndpointProfile { pub host: String, - pub port: u32, pub protocol: String, pub tls: String, pub access: String, @@ -368,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")] @@ -726,7 +731,25 @@ pub struct ProviderTypeProfile { impl ProviderTypeProfile { #[must_use] pub fn from_proto(profile: &ProviderProfile) -> Self { - Self { + Self::try_from_proto(profile) + .expect("validated provider profile protobuf must satisfy the authored policy schema") + } + + /// 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, annotations: profile.annotations.clone(), @@ -753,8 +776,18 @@ impl ProviderTypeProfile { token_grant: credential.token_grant.as_ref().map(token_grant_from_proto), }) .collect(), - endpoints: profile.endpoints.iter().map(endpoint_from_proto).collect(), - binaries: profile.binaries.iter().map(binary_from_proto).collect(), + endpoints: network_rule + .endpoints + .iter() + .map(endpoint_from_proto) + .collect(), + binaries: network_rule + .binaries + .iter() + .map(|binary| BinaryProfile { + path: binary.path.clone(), + }) + .collect(), inference_capable: profile.inference_capable, discovery: profile .discovery @@ -763,7 +796,7 @@ impl ProviderTypeProfile { .unwrap_or_default(), source: profile.source.clone(), scope: profile.scope.clone(), - } + }) } #[must_use] @@ -919,8 +952,12 @@ impl ProviderTypeProfile { token_grant: credential.token_grant.as_ref().map(token_grant_to_proto), }) .collect(), - endpoints: self.endpoints.iter().map(endpoint_to_proto).collect(), - binaries: self.binaries.iter().map(binary_to_proto).collect(), + endpoints: self + .endpoints + .iter() + .map(authored_endpoint_to_proto) + .collect(), + binaries: self.binaries.iter().map(authored_binary_to_proto).collect(), inference_capable: self.inference_capable, discovery: (!discovery_is_empty(&self.discovery)) .then(|| discovery_to_proto(&self.discovery)), @@ -1544,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) @@ -1591,10 +1636,31 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { } } +fn authored_endpoint_to_proto(endpoint: &EndpointProfile) -> authored::NetworkEndpoint { + let rule = NetworkPolicyRule { + name: "provider-profile".to_string(), + endpoints: vec![endpoint_to_proto(endpoint)], + binaries: Vec::new(), + }; + openshell_policy::project_authored_rule("provider-profile", &rule) + .expect("validated provider profile endpoint must project to the public policy schema") + .endpoints + .into_iter() + .next() + .expect("projected provider profile rule must retain its endpoint") +} + 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), @@ -1610,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 { @@ -1704,8 +1770,8 @@ fn binary_to_proto(binary: &BinaryProfile) -> NetworkBinary { } } -fn binary_from_proto(binary: &NetworkBinary) -> BinaryProfile { - BinaryProfile { +fn authored_binary_to_proto(binary: &BinaryProfile) -> authored::NetworkBinary { + authored::NetworkBinary { path: binary.path.clone(), } } @@ -1997,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 { @@ -2508,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); @@ -2877,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 ), )); } @@ -2962,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> { @@ -3164,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; } @@ -3219,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, @@ -3646,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(), }; @@ -3678,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(()) - ); } } } @@ -3751,8 +3817,7 @@ credentials: endpoint.host == "api.github.com" && endpoint.protocol == "graphql" && endpoint.path == "/graphql" - && endpoint.access - == openshell_core::proto::NetworkAccessPreset::ReadOnly as i32 + && endpoint.access == "read-only" }), "github profile should include read-only GraphQL endpoint" ); @@ -3762,9 +3827,9 @@ credentials: // transport endpoint (github.com) carries explicit rules // instead so it can allow clone/fetch while blocking push. if endpoint.host == "github.com" { - endpoint.access == 0 + endpoint.access.is_empty() } else { - endpoint.access == openshell_core::proto::NetworkAccessPreset::ReadOnly as i32 + endpoint.access == "read-only" } }), "github API endpoints should be read-only; git transport uses explicit rules" @@ -3780,13 +3845,13 @@ 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 // (an empty preset would otherwise expand to GET/HEAD/OPTIONS). assert!( - git_transport.access == 0, + git_transport.access.is_empty(), "git transport must use explicit rules, not an access preset" ); @@ -3972,7 +4037,7 @@ id: policy-only display_name: Policy Only endpoints: - host: example.com - port: 443 + ports: [443] ", ) .expect("profile"); @@ -4096,7 +4161,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] path: /mcp protocol: mcp mcp: @@ -4114,7 +4179,8 @@ binaries: .mcp .as_ref() .expect("MCP options") - .versions, + .versions + .as_slice(), expected_versions ); let mut proto = profile.to_proto(); @@ -4137,7 +4203,8 @@ binaries: .mcp .as_ref() .expect("MCP options") - .versions, + .versions + .as_slice(), expected_versions ); @@ -4147,7 +4214,8 @@ binaries: .mcp .as_ref() .expect("MCP options") - .versions, + .versions + .as_slice(), expected_versions ); assert_eq!( @@ -4184,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"] @@ -4242,7 +4310,7 @@ endpoints: } #[test] - fn provider_boundaries_materialize_programmatic_and_protobuf_empty_versions() { + fn provider_boundaries_materialize_programmatic_and_public_empty_versions() { let mut profile = mcp_profile_for_serialization(); profile.endpoints[0] .mcp @@ -4301,12 +4369,13 @@ endpoints: .expect("MCP options") .versions .clear(); - let from_proto = ProviderTypeProfile::from_proto(&proto_with_empty_versions); + let from_proto = ProviderTypeProfile::try_from_proto(&proto_with_empty_versions) + .expect("empty protobuf list uses omission semantics"); assert_eq!( from_proto.endpoints[0] .mcp .as_ref() - .expect("materialized MCP options") + .expect("MCP options") .versions, expected ); @@ -4320,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'] @@ -4364,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) @@ -4375,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"); @@ -4438,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(), @@ -4466,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" @@ -4474,7 +4543,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {} "; @@ -4484,7 +4553,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['{}'] @@ -4497,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 = @@ -4568,7 +4637,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: {invalid_mcp} @@ -4585,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'] @@ -4604,7 +4673,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: null "; @@ -4616,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()); @@ -4696,7 +4760,7 @@ id: mcp-example display_name: MCP Example endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: [draft] @@ -4729,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'] @@ -4753,7 +4817,7 @@ id: uppercase-mcp display_name: Uppercase MCP endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: MCP rules: - allow: @@ -4779,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'] @@ -4789,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'] @@ -4819,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!( @@ -5388,7 +5452,7 @@ credentials: audience: api://beta endpoints: - host: alpha.default.svc.cluster.local - port: 80 + ports: [80] path: /v1/** protocol: rest access: full @@ -5430,7 +5494,7 @@ credentials: audience: api://admin endpoints: - host: alpha.default.svc.cluster.local - port: 80 + ports: [80] path: /v1/** protocol: rest access: full @@ -5502,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 @@ -5545,10 +5609,7 @@ binaries: let proto = profile.to_proto(); let graphql_ep = &proto.endpoints[0]; - assert_eq!( - graphql_ep.access, - openshell_core::proto::NetworkAccessPreset::ReadOnly as i32 - ); + assert_eq!(graphql_ep.access, "read-only"); assert_eq!(graphql_ep.persisted_queries, "allow_registered"); assert_eq!(graphql_ep.graphql_max_body_bytes, 131_072); assert_eq!(graphql_ep.path, "/graphql"); @@ -5561,16 +5622,11 @@ 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, - openshell_core::proto::NetworkTlsMode::Terminate as i32 - ); + assert_eq!(rest_ep.tls, "terminate"); assert_eq!(rest_ep.allowed_ips, vec!["10.0.0.0/24"]); assert!(rest_ep.allow_encoded_slash); assert!(rest_ep.allow_uninspected_credentials); - assert!(!rest_ep.provider_credentialed); assert_eq!( rest_ep .rules @@ -5586,15 +5642,11 @@ binaries: assert!(serialized.contains("- /usr/bin/custom")); let reparsed = parse_profile_yaml(&serialized).expect("serialized profile should parse"); let reprotoo = reparsed.to_proto(); - assert_eq!( - reprotoo.endpoints[0].access, - openshell_core::proto::NetworkAccessPreset::ReadOnly as i32 - ); + assert_eq!(reprotoo.endpoints[0].access, "read-only"); assert_eq!(reprotoo.endpoints[1].rules.len(), 1); assert_eq!(reprotoo.endpoints[1].deny_rules.len(), 1); assert_eq!(reprotoo.endpoints[1].ports, vec![443, 8443]); assert!(reprotoo.endpoints[1].allow_uninspected_credentials); - assert!(!reprotoo.endpoints[1].provider_credentialed); assert_eq!(reprotoo.binaries[0].path, "/usr/bin/custom"); } @@ -5630,7 +5682,7 @@ credentials: env_vars: [TOKEN] endpoints: - host: api.example.com - port: 443 + ports: [443] ", ) .expect("profile should parse"); @@ -5643,7 +5695,7 @@ display_name: Signed credentials: [] endpoints: - host: s3.example.com - port: 443 + ports: [443] credential_signing: sigv4 ", ) @@ -5657,7 +5709,7 @@ display_name: Plain credentials: [] endpoints: - host: pypi.org - port: 443 + ports: [443] ", ) .expect("profile should parse"); @@ -5675,7 +5727,7 @@ credentials: env_vars: [TOKEN] endpoints: - host: raw.example.com - port: 443 + ports: [443] ", ) .expect("profile should parse"); @@ -5693,7 +5745,7 @@ credentials: env_vars: [TOKEN] endpoints: - host: raw.example.com - port: 443 + ports: [443] allow_uninspected_credentials: true ", ) @@ -5725,7 +5777,7 @@ discovery: credentials: [api_key, missing_key] endpoints: - host: "" - port: 0 + ports: [0] binaries: ["", /usr/bin/broken] "#, ) @@ -5869,7 +5921,7 @@ id: bad-endpoint display_name: Bad Endpoint endpoints: - host: api.example.com - port: 0 + ports: [0] "]) .unwrap_err(); @@ -5883,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: @@ -6335,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 @@ -6371,7 +6423,7 @@ discovery: credentials: [api_key] endpoints: - host: openrouter.ai - port: 443 + ports: [443] protocol: rest enforcement: enforce binaries: @@ -6404,7 +6456,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: read-write binaries: @@ -6436,7 +6488,7 @@ discovery: credentials: [api_key] endpoints: - host: database.example.com - port: 5432 + ports: [5432] protocol: tcp tls: skip allow_uninspected_credentials: true @@ -6470,7 +6522,7 @@ discovery: credentials: [api_key] endpoints: - host: database.example.com - port: 5432 + ports: [5432] protocol: tcp enforcement: enforce path: /query @@ -6542,7 +6594,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: ftp binaries: - /usr/bin/app @@ -6574,7 +6626,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: full rules: @@ -6611,7 +6663,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: full rules: [] @@ -6645,7 +6697,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest rules: - allow: {} @@ -6679,7 +6731,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest access: full deny_rules: [] @@ -6713,7 +6765,7 @@ discovery: credentials: [api_key] endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -6753,7 +6805,7 @@ discovery: credentials: [api_key] endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -6795,7 +6847,7 @@ discovery: credentials: [api_key] endpoints: - host: mcp.example.com - port: 443 + ports: [443] protocol: mcp mcp: versions: ['2025-11-25'] @@ -6834,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'] @@ -6878,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'] @@ -6925,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'] @@ -6967,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'] @@ -7024,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'] @@ -7059,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'] @@ -7094,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'] @@ -7129,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'] @@ -7166,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'] @@ -7220,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'] @@ -7255,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'] @@ -7287,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'] @@ -7319,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'] @@ -7350,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'] @@ -7385,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'] @@ -7415,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'] @@ -7457,7 +7509,7 @@ discovery: credentials: [api_key] endpoints: - host: api.example.com - port: 443 + ports: [443] deny_rules: - method: POST binaries: @@ -7483,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'] @@ -7511,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'] @@ -7543,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'] @@ -7577,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'] @@ -7611,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'] @@ -7641,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'] @@ -7675,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'] @@ -7709,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'] @@ -7740,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'] @@ -7772,7 +7824,7 @@ id: rest-tool display_name: REST Tool endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest rules: - allow: @@ -7802,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 3758338dfc..ef954f9cff 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 3937462d00..7ce6d30093 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 4eab8e2313..bb150cc18f 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::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::SandboxPolicy { + policy: Some(proto::policy::PolicyDocument { version: 2, ..Default::default() }), diff --git a/crates/openshell-server/proto/storage.proto b/crates/openshell-server/proto/storage.proto index 28e9e9e4a1..e5da5f3c55 100644 --- a/crates/openshell-server/proto/storage.proto +++ b/crates/openshell-server/proto/storage.proto @@ -109,6 +109,50 @@ message StoredProviderProfile { openshell.v1.ProviderProfile profile = 2; } +// Durable provider-profile envelope. It mirrors StoredProviderProfile on the +// wire while retaining internal endpoint and binary policy messages. +message StoredProviderProfileWire { + openshell.datamodel.v1.ObjectMeta metadata = 1; + StoredProviderProfileData profile = 2; +} + +message StoredProviderProfileData { + string id = 1; + string display_name = 2; + string description = 3; + openshell.v1.ProviderProfileCategory category = 4; + repeated openshell.v1.ProviderProfileCredential credentials = 5; + repeated openshell.sandbox.v1.NetworkEndpoint endpoints = 6; + repeated openshell.sandbox.v1.NetworkBinary binaries = 7; + bool inference_capable = 8; + openshell.v1.ProviderProfileDiscovery discovery = 9; + uint64 resource_version = 10; + map annotations = 11; + string source = 12; + string scope = 13; +} + +// Durable sandbox object. The field numbers mirror openshell.v1.Sandbox, but +// its policy remains the gateway's internal runtime representation. +message StoredSandbox { + openshell.datamodel.v1.ObjectMeta metadata = 1; + StoredSandboxSpec spec = 2; + openshell.v1.SandboxStatus status = 3; + openshell.v1.SandboxWorkloadTemplateProvenance created_from_workload_template = 20; +} + +message StoredSandboxSpec { + string log_level = 1; + map environment = 5; + openshell.v1.SandboxTemplate template = 6; + openshell.sandbox.v1.SandboxPolicy policy = 7; + repeated string providers = 8; + openshell.v1.ResourceRequirements resource_requirements = 9; + repeated string command = 12; + bool tty = 13; + string provider_attachment_epoch = 14; +} + // Durable, non-secret progress for a sandbox-scoped desired-state mutation. // The target_* fields let a recovery worker bind the exact effective snapshot // revision after the desired-state transaction commits. diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 8e2b5cb462..e028de54dd 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -909,7 +909,9 @@ impl ComputeRuntime { &sandbox_id, sandbox.object_name(), sandbox.object_workspace(), - &sandbox.encode_to_vec(), + &crate::storage_proto::encode_sandbox(&sandbox).map_err(|error| { + Status::internal(format!("encode sandbox for persistence failed: {error}")) + })?, labels_json.as_deref(), WriteCondition::MustCreate, ) @@ -2173,7 +2175,11 @@ impl ComputeRuntime { &id, &name, sandbox.object_workspace(), - &sandbox.encode_to_vec(), + &crate::storage_proto::encode_sandbox(&sandbox).map_err(|error| { + crate::persistence::PersistenceError::Encode(format!( + "encode sandbox for persistence failed: {error}" + )) + })?, labels_json.as_deref(), WriteCondition::MatchResourceVersion(expected_resource_version), ) @@ -2989,7 +2995,7 @@ impl ComputeRuntime { let grace_ms = grace_period.as_millis().try_into().unwrap_or(i64::MAX); for record in records { - let sandbox = match Sandbox::decode(record.payload.as_slice()) { + let sandbox = match crate::storage_proto::decode_sandbox(record.payload.as_slice()) { Ok(sandbox) => sandbox, Err(err) => { warn!(error = %err, "Failed to decode sandbox record during reconciliation"); @@ -4194,6 +4200,16 @@ fn driver_sandbox_spec_from_public( spec: &SandboxSpec, driver_name: &str, ) -> Result> { + let policy = spec + .policy + .clone() + .map(openshell_policy::lower_authored_policy) + .transpose() + .map_err(|error| { + Box::new(Status::invalid_argument(format!( + "invalid authored policy: {error}" + ))) + })?; Ok(DriverSandboxSpec { log_level: spec.log_level.clone(), environment: spec.environment.clone(), @@ -4202,7 +4218,7 @@ fn driver_sandbox_spec_from_public( .as_ref() .map(|template| driver_sandbox_template_from_public(template, driver_name)) .transpose()?, - policy: spec.policy.clone(), + policy: policy.clone(), resource_requirements: spec.resource_requirements.as_ref().map(|requirements| { DriverSandboxResourceRequirements { gpu: requirements @@ -4216,13 +4232,11 @@ fn driver_sandbox_spec_from_public( tty: spec.tty, await_main_process_attachment: false, workload_identity: Some(WorkloadIdentityRequest { - user: spec - .policy + user: policy .as_ref() .and_then(|policy| policy.process.as_ref()) .map_or_else(String::new, |process| process.run_as_user.clone()), - group: spec - .policy + group: policy .as_ref() .and_then(|policy| policy.process.as_ref()) .map_or_else(String::new, |process| process.run_as_group.clone()), @@ -4536,7 +4550,7 @@ fn compute_error_from_status(status: Status) -> ComputeError { } fn decode_sandbox_record(record: &ObjectRecord) -> Result { - Sandbox::decode(record.payload.as_slice()).map_err(|e| e.to_string()) + crate::storage_proto::decode_sandbox(record.payload.as_slice()) } fn sandbox_resource_version(sandbox: &Sandbox) -> u64 { @@ -5482,13 +5496,16 @@ mod tests { #[test] fn driver_sandbox_spec_carries_admitted_identity_selectors() { let public = SandboxSpec { - policy: Some(openshell_core::proto::sandbox::v1::SandboxPolicy { - process: Some(openshell_core::proto::sandbox::v1::ProcessPolicy { - run_as_user: "10001".to_string(), - run_as_group: "10002".to_string(), - }), - ..Default::default() - }), + policy: Some( + openshell_policy::project_base_policy(&openshell_core::proto::SandboxPolicy { + process: Some(openshell_core::proto::sandbox::v1::ProcessPolicy { + run_as_user: "10001".to_string(), + run_as_group: "10002".to_string(), + }), + ..Default::default() + }) + .unwrap(), + ), ..Default::default() }; @@ -11893,10 +11910,13 @@ mod tests { let mut sandbox = sandbox_record("sb-uds", "uds-sandbox", SandboxPhase::Provisioning); sandbox.spec = Some(SandboxSpec { log_level: "debug".to_string(), - policy: Some(openshell_core::proto::SandboxPolicy { - version: 42, - ..Default::default() - }), + policy: Some( + openshell_policy::project_base_policy(&openshell_core::proto::SandboxPolicy { + version: 1, + ..Default::default() + }) + .unwrap(), + ), template: Some(SandboxTemplate { image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), driver_config: Some(prost_types::Struct { @@ -11942,13 +11962,13 @@ mod tests { .as_ref() .and_then(|spec| spec.policy.as_ref()) .map(|policy| policy.version), - Some(42) + Some(1) ); assert!(matches!( &calls[2], FakeComputeDriverCall::CreateSandbox { sandbox: Some(sandbox) } if sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()) - .is_some_and(|policy| policy.version == 42) + .is_some_and(|policy| policy.version == 1) )); driver.clear_calls(); diff --git a/crates/openshell-server/src/compute/provisioning_deadline.rs b/crates/openshell-server/src/compute/provisioning_deadline.rs index 54d1f83682..cb7ce4c1b3 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 8773249ead..59bf50b93b 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs @@ -8,12 +8,15 @@ use crate::grpc::mutation_replay::tests::reason; use crate::grpc::mutation_replay::{Admission, OBJECT_TYPE, OriginalMutation, fingerprint, run}; 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, PolicyDocument, +}; use openshell_core::proto::{ - DeletionOutcome, DraftChunkApproval, GetDraftPolicyRequest, NetworkBinary, NetworkEndpoint, - NetworkPolicyRule, PolicyChunk, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, + DeletionOutcome, DraftChunkApproval, GetDraftPolicyRequest, PolicyChunk, + ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, ProviderCredentialRefreshStrategy, ProviderProfileCategory, ProviderProfileCredential, - ProviderProfileImportItem, SandboxPhase, SandboxPolicy, SandboxSpec, ServiceEndpoint, - SettingValue, SubmitPolicyAnalysisRequest, WorkspaceMember, WorkspaceRole, setting_value, + ProviderProfileImportItem, SandboxPhase, SandboxSpec, ServiceEndpoint, SettingValue, + SubmitPolicyAnalysisRequest, WorkspaceMember, WorkspaceRole, setting_value, }; use tonic::Code; @@ -1003,7 +1006,10 @@ 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::default()), + policy: Some(PolicyDocument { + version: 1, + ..Default::default() + }), ..Default::default() }), ..Default::default() @@ -1013,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 c9863813e3..48b640d413 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -37,6 +37,7 @@ use crate::storage_proto::StoredProviderCredentialRefreshStateV2 as StoredProvid use crate::storage_proto::StoredProviderProfile; use openshell_core::net::{is_always_blocked_ip, is_internal_ip}; use openshell_core::policy_identity::{canonical_rule_bytes, deterministic_policy_hash}; +use openshell_core::proto::policy as authored; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; use openshell_core::proto::{ @@ -93,12 +94,24 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use tonic::{Request, Response, Status}; + +pub(super) fn lower_public_policy( + 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 { + openshell_policy::project_base_policy(policy) + .map_err(|error| Status::internal(format!("failed to project public policy: {error}"))) +} use tracing::{debug, info, warn}; use super::validation::{ level_matches, source_matches, validate_and_canonicalize_policy, validate_annotations, - validate_no_reserved_provider_policy_keys, validate_policy_safety, - validate_static_fields_unchanged, + validate_canonical_policy_size, validate_no_reserved_provider_policy_keys, + validate_policy_safety, validate_static_fields_unchanged, }; use super::{StoredSettingValue, StoredSettings}; use crate::persistence::current_time_ms; @@ -1801,9 +1814,10 @@ async fn current_effective_policy_from_records( canonical_policy_record_identity(&record)?.0 } else { match sandbox.spec.as_ref().and_then(|spec| spec.policy.clone()) { - Some(policy) => { - validate_and_canonicalize_stored_policy(policy, STORED_POLICY_SOURCE_SPEC)? - } + Some(policy) => validate_and_canonicalize_stored_policy( + lower_public_policy(policy)?, + STORED_POLICY_SOURCE_SPEC, + )?, None => ProtoSandboxPolicy::default(), } }; @@ -2094,19 +2108,23 @@ fn profile_declares_sigv4_credentials(profile: &openshell_providers::ProviderTyp fn signed_endpoint_is_covered( signed: &NetworkEndpoint, - profile_endpoints: &[NetworkEndpoint], + profile_endpoints: &[authored::NetworkEndpoint], ) -> bool { endpoint_ports_for_validation(signed) .into_iter() .all(|port| { profile_endpoints.iter().any(|profile| { - endpoint_ports_for_validation(profile).contains(&port) + authored_endpoint_ports(profile).contains(&port) && host_pattern_covers(&profile.host, &signed.host) && path_pattern_covers(&profile.path, &signed.path) }) }) } +fn authored_endpoint_ports(endpoint: &authored::NetworkEndpoint) -> Vec { + endpoint.ports.clone() +} + fn endpoint_ports_for_validation(endpoint: &NetworkEndpoint) -> Vec { if endpoint.ports.is_empty() { vec![endpoint.port] @@ -2224,7 +2242,12 @@ pub(super) async fn current_base_policy_for_sandbox( .and_then(|spec| spec.policy.clone()) .map_or_else( || Ok(ProtoSandboxPolicy::default()), - |policy| validate_and_canonicalize_stored_policy(policy, STORED_POLICY_SOURCE_SPEC), + |policy| { + validate_and_canonicalize_stored_policy( + lower_public_policy(policy)?, + STORED_POLICY_SOURCE_SPEC, + ) + }, ) } @@ -2476,7 +2499,7 @@ async fn persist_existing_policy_projection( } let annotations = annotations.clone(); - let backfill_policy = backfill_policy.cloned(); + let backfill_policy = backfill_policy.map(project_public_policy).transpose()?; let updated = state .store .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { @@ -2681,7 +2704,7 @@ pub(super) async fn load_sandbox_config( // creating policy history so malformed state is never copied or // marked loaded, and hash the canonical representation. let spec_policy = validate_and_canonicalize_stored_policy( - spec_policy, + lower_public_policy(spec_policy)?, STORED_POLICY_SOURCE_SPEC, )?; let hash = deterministic_policy_hash(&spec_policy); @@ -3612,12 +3635,13 @@ async fn handle_update_config_inner( "delete_setting cannot be combined with policy payload", )); } - let mut new_policy = req.policy.ok_or_else(|| { + let mut new_policy = lower_public_policy(req.policy.ok_or_else(|| { Status::invalid_argument("policy is required for global policy update") - })?; + })?)?; clear_provider_credentialed_markers(&mut new_policy); validate_no_reserved_provider_policy_keys(&new_policy)?; new_policy = validate_and_canonicalize_policy(new_policy)?; + validate_canonical_policy_size(&new_policy, "policy")?; validate_policy_safety(&new_policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy) .await?; @@ -3906,7 +3930,7 @@ async fn handle_update_config_inner( provenance: &req.annotations, annotations: &req.annotations, }; - let baseline_policy = spec.policy.clone(); + let baseline_policy = spec.policy.clone().map(lower_public_policy).transpose()?; let (version, hash, updated_sandbox) = apply_merge_operations_with_retry( state.store.as_ref(), &sandbox_id, @@ -3979,9 +4003,10 @@ async fn handle_update_config_inner( } // Sandbox-scoped policy update. - let mut new_policy = req - .policy - .ok_or_else(|| Status::invalid_argument("policy is required"))?; + let mut new_policy = lower_public_policy( + req.policy + .ok_or_else(|| Status::invalid_argument("policy is required"))?, + )?; clear_provider_credentialed_markers(&mut new_policy); let global_settings = load_global_settings(state.store.as_ref()).await?; if global_settings.settings.contains_key(POLICY_SETTING_KEY) { @@ -4012,7 +4037,7 @@ async fn handle_update_config_inner( // must be able to repair every field before the first activation. true } else if let Some(baseline_policy) = spec.policy.as_ref() { - let comparable_baseline = baseline_policy.clone(); + let comparable_baseline = lower_public_policy(baseline_policy.clone())?; validate_static_fields_unchanged(&comparable_baseline, &new_policy)?; false } else { @@ -4020,6 +4045,7 @@ async fn handle_update_config_inner( }; new_policy = validate_and_canonicalize_policy(new_policy)?; + validate_canonical_policy_size(&new_policy, "policy")?; let backfill_policy = should_backfill_policy.then(|| new_policy.clone()); validate_policy_safety(&new_policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy).await?; @@ -4998,7 +5024,17 @@ pub(super) async fn handle_submit_policy_analysis( } }; - let rule_ref = chunk.proposed_rule.as_ref().expect("checked above"); + let rule_ref = openshell_policy::lower_authored_rule( + &chunk.rule_name, + chunk.proposed_rule.clone().expect("checked above"), + ) + .map_err(|error| { + Status::invalid_argument(format!( + "chunk '{}' contains an invalid authored rule: {error}", + chunk.rule_name + )) + })?; + let rule_ref = &rule_ref; if req.analysis_mode == "agent_authored" && let Some(reason) = rule_ref.endpoints.iter().find_map(|endpoint| { openshell_policy::agent_authored_transport_rejection( @@ -5914,6 +5950,9 @@ pub(super) async fn handle_edit_draft_chunk( ))); } + let proposed_rule = openshell_policy::lower_authored_rule(&chunk.rule_name, proposed_rule) + .map_err(|error| Status::invalid_argument(format!("proposed_rule is invalid: {error}")))?; + let mut edited_chunk = chunk.clone(); edited_chunk.proposed_rule = proposed_rule.encode_to_vec(); edited_chunk.review_token.clear(); @@ -6270,10 +6309,15 @@ fn current_draft_chunk_security_notes(record: &DraftChunkRecord) -> Result Result { - let proposed_rule = decode_draft_chunk_rule(record)?; - let security_notes = proposed_rule + let internal_proposed_rule = decode_draft_chunk_rule(record)?; + let security_notes = internal_proposed_rule .as_ref() .map_or_else(String::new, generate_security_notes); + let proposed_rule = internal_proposed_rule + .as_ref() + .map(|rule| openshell_policy::project_authored_rule(&record.rule_name, rule)) + .transpose() + .map_err(|error| Status::internal(format!("failed to project draft rule: {error}")))?; Ok(PolicyChunk { id: record.id.clone(), @@ -6309,8 +6353,22 @@ fn draft_chunk_record_to_proto(record: &DraftChunkRecord) -> Result { @@ -6580,7 +6640,15 @@ fn parse_merge_operations( } Ok(PolicyMergeOp::AddRule { rule_name: rule_name.to_string(), - rule: add_rule.rule.clone().unwrap_or_default(), + rule: openshell_policy::lower_authored_rule( + rule_name, + add_rule.rule.clone().unwrap_or_default(), + ) + .map_err(|error| { + Status::invalid_argument(format!( + "merge_operations[{index}].add_rule is invalid: {error}" + )) + })?, }) } policy_merge_operation::Operation::RemoveEndpoint(remove_endpoint) => { @@ -6647,7 +6715,17 @@ fn parse_proto_add_deny_rules( Ok(PolicyMergeOp::AddDenyRules { target: parse_proto_l7_target(index, add_deny_rules.target.as_ref())?, - deny_rules: add_deny_rules.deny_rules.clone(), + deny_rules: add_deny_rules + .deny_rules + .iter() + .cloned() + .map(openshell_policy::lower_authored_l7_deny_rule) + .collect::>>() + .map_err(|error| { + Status::invalid_argument(format!( + "merge_operations[{index}].add_deny_rules is invalid: {error}" + )) + })?, }) } @@ -6672,7 +6750,17 @@ fn parse_proto_add_allow_rules( Ok(PolicyMergeOp::AddAllowRules { target: parse_proto_l7_target(index, add_allow_rules.target.as_ref())?, - rules: add_allow_rules.rules.clone(), + rules: add_allow_rules + .rules + .iter() + .cloned() + .map(openshell_policy::lower_authored_l7_rule) + .collect::>>() + .map_err(|error| { + Status::invalid_argument(format!( + "merge_operations[{index}].add_allow_rules is invalid: {error}" + )) + })?, }) } @@ -6683,11 +6771,26 @@ 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()) { (true, true) => L7BinaryScope::Any, - (false, false) => L7BinaryScope::Restricted(target.binaries.clone()), + (false, false) => L7BinaryScope::Restricted( + target + .binaries + .iter() + .map(|binary| NetworkBinary { + path: binary.path.clone(), + }) + .collect(), + ), _ => { return Err(Status::invalid_argument(format!( "merge_operations[{index}].target requires exactly one of any_binary=true or nonempty binaries" @@ -6887,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, @@ -6896,6 +7007,8 @@ fn stage_validated_merge_operation( let merged = merge_policy(current_policy.clone(), std::slice::from_ref(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() { @@ -6958,6 +7071,8 @@ 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 { @@ -7508,6 +7623,92 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tonic::Code; + 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::PolicyDocument { + let mut policy = authored_policy(mcp_policy_with_versions(&["2025-11-25"])); + policy.network_policies.get_mut("mcp").unwrap().endpoints[0] + .mcp + .as_mut() + .unwrap() + .versions = versions + .iter() + .map(|version| (*version).to_string()) + .collect(); + policy + } + + fn authored_rule(rule: NetworkPolicyRule) -> authored::NetworkPolicyRule { + openshell_policy::project_authored_rule("test-rule", &rule) + .expect("test rule must be authorable") + } + + fn authored_endpoint(endpoint: NetworkEndpoint) -> authored::NetworkEndpoint { + authored_rule(NetworkPolicyRule { + name: "test-rule".to_string(), + endpoints: vec![endpoint], + ..Default::default() + }) + .endpoints + .pop() + .expect("projected test endpoint") + } + + fn authored_binary(binary: NetworkBinary) -> authored::NetworkBinary { + authored_rule(NetworkPolicyRule { + name: "test-rule".to_string(), + binaries: vec![binary], + ..Default::default() + }) + .binaries + .pop() + .expect("projected test binary") + } + + trait IntoTestPolicy { + fn into_test_policy(self) -> T; + } + + impl IntoTestPolicy for T { + fn into_test_policy(self) -> T { + self + } + } + + impl IntoTestPolicy for ProtoSandboxPolicy { + fn into_test_policy(self) -> authored::PolicyDocument { + authored_policy(self) + } + } + + impl IntoTestPolicy for authored::PolicyDocument { + fn into_test_policy(self) -> ProtoSandboxPolicy { + openshell_policy::lower_authored_policy(self).expect("test policy must lower") + } + } + + impl IntoTestPolicy for NetworkPolicyRule { + fn into_test_policy(self) -> authored::NetworkPolicyRule { + authored_rule(self) + } + } + + impl IntoTestPolicy for authored::NetworkPolicyRule { + fn into_test_policy(self) -> NetworkPolicyRule { + openshell_policy::lower_authored_rule("test-rule", self).expect("test rule must lower") + } + } + + #[allow(clippy::unnecessary_wraps)] + fn some>(value: U) -> Option { + Some(value.into_test_policy()) + } + /// Wrap a request with a user `Principal` so handler scope guards treat /// the test caller as a CLI user. Most handler tests exercise /// user-facing behavior and should not trip sandbox equality checks. @@ -7608,7 +7809,7 @@ mod tests { }; let wrapped = LegacyStoredPolicyRevisionPayload { - policy: Some(legacy), + policy: some(legacy), } .encode_to_vec(); let record = crate::policy_store::policy_record_from_parts( @@ -7907,7 +8108,12 @@ mod tests { &persisted )); let mut replacement = persisted.spec.as_ref().unwrap().policy.clone().unwrap(); - replacement.filesystem.as_mut().unwrap().read_only.clear(); + replacement + .filesystem_policy + .as_mut() + .unwrap() + .read_only + .clear(); let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { @@ -7979,10 +8185,8 @@ mod tests { with_sandbox( Request::new(UpdateConfigRequest { sandbox: "image-admission".to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - policy: Some(image), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(openshell_policy::project_base_policy(&image).unwrap()), ..Default::default() }), sandbox_id, @@ -8013,10 +8217,13 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { sandbox: "image-admission".to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - policy: Some(openshell_policy::restrictive_default_policy()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some( + openshell_policy::project_base_policy( + &openshell_policy::restrictive_default_policy(), + ) + .unwrap(), + ), ..Default::default() })), ) @@ -8141,7 +8348,7 @@ mod tests { state, with_user(Request::new(UpdateConfigRequest { global: true, - policy: Some(mcp_policy_with_versions(&[])), + policy: some(authored_policy(mcp_policy_with_versions(&[]))), ..Default::default() })), ) @@ -8251,6 +8458,128 @@ mod tests { assert_eq!(error.code(), Code::InvalidArgument); } + #[tokio::test] + async fn public_policy_boundary_rejects_invalid_mcp_versions() { + let cases = [ + ("duplicate", &["2025-11-25", "2025-11-25"][..]), + ("unsupported", &["latest"][..]), + ]; + + for (case, versions) in cases { + let error = lower_public_policy(authored_mcp_policy_with_versions(versions)) + .expect_err("invalid authored policy must fail at the public boundary"); + assert_eq!(error.code(), Code::InvalidArgument, "{case}"); + } + } + + #[test] + fn public_policy_boundary_rejects_structurally_incomplete_messages() { + let mut missing_allow = authored::PolicyDocument { + version: 1, + ..Default::default() + }; + missing_allow.network_policies.insert( + "api".to_string(), + authored::NetworkPolicyRule { + endpoints: vec![authored::NetworkEndpoint { + host: "api.example.com".to_string(), + ports: vec![443], + rules: vec![authored::L7Rule { allow: None }], + ..Default::default() + }], + ..Default::default() + }, + ); + + 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, ports: [443] }\n", + ) + .unwrap(), + ); + missing_matcher_kind + .network_policies + .get_mut("api") + .unwrap() + .endpoints[0] + .rules = vec![authored::L7Rule { + allow: Some(authored::L7Allow { + query: std::iter::once(("owner".to_string(), authored::Matcher { kind: None })) + .collect(), + ..Default::default() + }), + }]; + + let mut oversized_port = authored::PolicyDocument { + version: 1, + ..Default::default() + }; + oversized_port.network_policies.insert( + "api".to_string(), + authored::NetworkPolicyRule { + endpoints: vec![authored::NetworkEndpoint { + host: "api.example.com".to_string(), + ports: vec![65_536], + ..Default::default() + }], + ..Default::default() + }, + ); + + for (name, policy) in [ + ("missing allow", missing_allow), + ("missing matcher kind", missing_matcher_kind), + ("oversized port", oversized_port), + ("missing version", authored::PolicyDocument::default()), + ] { + let Err(error) = lower_public_policy(policy) else { + panic!("{name} unexpectedly lowered"); + }; + assert_eq!(error.code(), Code::InvalidArgument, "{name}"); + } + } + + #[tokio::test] + async fn global_policy_replacement_rejects_policy_over_canonical_size_limit() { + let state = test_server_state().await; + let mut policy = openshell_policy::restrictive_default_policy(); + policy.network_policies.insert( + "padding".to_string(), + NetworkPolicyRule { + name: "x".repeat(super::super::MAX_POLICY_SIZE), + ..Default::default() + }, + ); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(authored_policy(policy)), + ..Default::default() + })), + ) + .await + .expect_err("oversized replacement must be rejected"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!( + error + .message() + .contains("policy serialized size exceeds maximum"), + "{error}" + ); + assert!( + state + .store + .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) + .await + .unwrap() + .is_none(), + "oversized replacement must not persist" + ); + } + #[tokio::test] async fn get_sandbox_config_rejects_invalid_spec_policy_before_history_backfill() { let state = test_server_state().await; @@ -8264,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"); @@ -8288,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!( @@ -8305,6 +8651,85 @@ mod tests { } } + #[test] + fn incremental_merge_rejects_policy_over_canonical_size_limit() { + let current = openshell_policy::restrictive_default_policy(); + let operation = PolicyMergeOp::AddRule { + rule_name: "x".repeat(super::super::MAX_POLICY_SIZE), + rule: NetworkPolicyRule { + endpoints: vec![NetworkEndpoint { + host: "api.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("oversized merge must be rejected"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!( + error + .message() + .contains("policy serialized size exceeds maximum"), + "{error}" + ); + } + + #[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; @@ -8467,7 +8892,10 @@ mod tests { let revision = policy_record_to_revision(&record, true) .expect("legacy history export must canonicalize"); assert_eq!(revision.policy_hash, canonical_hash, "{case}"); - let exported = revision.policy.expect("exported history policy"); + let exported = openshell_policy::lower_authored_policy( + revision.policy.expect("exported history policy"), + ) + .expect("exported history policy must lower"); assert_eq!(exported, canonical, "{case}"); assert_eq!( mcp_versions(&exported), @@ -8677,21 +9105,21 @@ mod tests { } #[tokio::test] - async fn valid_global_policy_overrides_invalid_legacy_spec_without_backfill() { + async fn valid_global_policy_overrides_local_spec_without_backfill() { let state = test_server_state().await; let global_policy = install_test_global_policy(&state).await; let sandbox_id = "global-overrides-invalid-spec"; let sandbox = test_sandbox( sandbox_id, sandbox_id, - legacy_non_mcp_policy_with_mcp_options(), + mcp_policy_with_versions(&["2025-11-25"]), Vec::new(), ); state .store .put_message(&sandbox) .await - .expect("store sandbox with invalid legacy spec"); + .expect("store sandbox with local spec"); let response = handle_get_sandbox_config( &state, @@ -8704,7 +9132,7 @@ mod tests { ), ) .await - .expect("valid global policy must override invalid local spec") + .expect("valid global policy must override local spec") .into_inner(); assert_eq!(response.policy.as_ref(), Some(&global_policy)); @@ -8722,7 +9150,7 @@ mod tests { .await .expect("policy history lookup") .is_none(), - "global override must not backfill invalid dormant spec state" + "global override must not backfill dormant spec state" ); let catalog = state @@ -8771,7 +9199,7 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { global: true, - policy: Some(mcp_policy_with_versions(&[ + policy: some(mcp_policy_with_versions(&[ "2025-11-25", "2025-06-18", "2025-03-26", @@ -8814,7 +9242,7 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { global: true, - policy: Some(policy), + policy: some(policy), ..Default::default() })), ) @@ -8885,7 +9313,7 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { global: true, - policy: Some(mcp_policy_with_versions(&["2025-11-25"])), + policy: some(mcp_policy_with_versions(&["2025-11-25"])), ..Default::default() })), ) @@ -8951,11 +9379,9 @@ mod tests { let response = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - sandbox: sandbox_name.clone(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - policy: Some(mcp_policy_with_versions(&["2025-11-25"])), + sandbox: sandbox_name, + policy: some(mcp_policy_with_versions(&["2025-11-25"])), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() })), ) @@ -9061,10 +9487,8 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { sandbox: sandbox_name.to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - policy: Some(candidate.clone()), + policy: some(candidate.clone()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() })), ) @@ -9122,10 +9546,8 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { sandbox: sandbox_name.to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - policy: Some(candidate.clone()), + policy: some(candidate.clone()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() })), ) @@ -9474,10 +9896,8 @@ mod tests { fn sandbox_caller_update_validation_allows_sandbox_policy_sync() { let req = UpdateConfigRequest { sandbox: "sandbox-1".to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - policy: Some(ProtoSandboxPolicy::default()), + policy: some(ProtoSandboxPolicy::default()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() }; assert!(validate_sandbox_caller_update(&req).is_ok()); @@ -9487,7 +9907,7 @@ mod tests { fn sandbox_caller_update_validation_rejects_global_mutation() { let req = UpdateConfigRequest { global: true, - policy: Some(ProtoSandboxPolicy::default()), + policy: some(ProtoSandboxPolicy::default()), ..Default::default() }; let err = validate_sandbox_caller_update(&req).unwrap_err(); @@ -9750,7 +10170,7 @@ mod tests { .unwrap(); let chunk = |name: &str| PolicyChunk { rule_name: name.to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: name.to_string(), endpoints: vec![NetworkEndpoint { host: format!("{name}.example.com"), @@ -9966,10 +10386,10 @@ mod tests { ports: vec![443, 8443], path: Some(String::new()), binaries: vec![ - NetworkBinary { + authored::NetworkBinary { path: "/usr/bin/curl".to_string(), }, - NetworkBinary { + authored::NetworkBinary { path: "/usr/bin/python3".to_string(), }, ], @@ -9981,7 +10401,7 @@ mod tests { let operation = if deny { policy_merge_operation::Operation::AddDenyRules(ProtoAddDenyRules { target, - deny_rules: vec![L7DenyRule { + deny_rules: vec![authored::L7DenyRule { method: "POST".to_string(), path: "/admin".to_string(), ..Default::default() @@ -9990,8 +10410,8 @@ mod tests { } else { policy_merge_operation::Operation::AddAllowRules(ProtoAddAllowRules { target, - rules: vec![L7Rule { - allow: Some(openshell_core::proto::L7Allow { + rules: vec![authored::L7Rule { + allow: Some(authored::L7Allow { method: "POST".to_string(), path: "/admin".to_string(), ..Default::default() @@ -10024,7 +10444,11 @@ mod tests { ..endpoint.clone() }, ], - binaries: l7_scope_target().binaries, + binaries: l7_scope_target() + .binaries + .into_iter() + .map(|binary| NetworkBinary { path: binary.path }) + .collect(), }; let sibling = NetworkPolicyRule { name: "sibling".to_string(), @@ -10095,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] { @@ -10119,7 +10546,7 @@ mod tests { }), policy_merge_operation::Operation::AddAllowRules(ProtoAddAllowRules { target: Some(l7_scope_target()), - rules: vec![L7Rule { allow: None }], + rules: vec![authored::L7Rule { allow: None }], }), ] { let error = parse_merge_operations(&[PolicyMergeOperation { @@ -10719,10 +11146,8 @@ mod tests { let req = with_sandbox( Request::new(UpdateConfigRequest { sandbox: "missing-sandbox".to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - policy: Some(ProtoSandboxPolicy::default()), + policy: some(ProtoSandboxPolicy::default()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() }), "sb-a", @@ -11007,7 +11432,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(policy), + policy: some(authored_policy(policy)), providers, ..Default::default() }), @@ -11069,13 +11494,13 @@ mod tests { }), ..Default::default() }], - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: host.to_string(), port: 443, protocol: "rest".to_string(), access: openshell_core::proto::NetworkAccessPreset::Full as i32, ..Default::default() - }], + })], ..Default::default() } } @@ -11223,7 +11648,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "snapshot_consistency_test".to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: "snapshot_consistency_test".to_string(), endpoints: vec![NetworkEndpoint { host: "proposal.example.com".to_string(), @@ -11287,11 +11712,11 @@ mod tests { description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, credentials: Vec::new(), - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "backdoor.example".to_string(), port: 443, ..Default::default() - }], + })], binaries: Vec::new(), inference_capable: false, discovery: None, @@ -11329,11 +11754,11 @@ mod tests { profile: Some(openshell_core::proto::ProviderProfile { id: "gh".to_string(), display_name: "Enterprise GitHub".to_string(), - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "github.enterprise.example".to_string(), port: 443, ..Default::default() - }], + })], ..Default::default() }), }) @@ -11380,7 +11805,7 @@ mod tests { description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, credentials: Vec::new(), - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.custom.example".to_string(), protocol: "rest".to_string(), ports: vec![443, 8443], @@ -11395,10 +11820,10 @@ mod tests { allow_encoded_slash: true, path: "/v1".to_string(), ..Default::default() - }], - binaries: vec![NetworkBinary { + })], + binaries: vec![authored_binary(NetworkBinary { path: "/usr/bin/custom".to_string(), - }], + })], inference_capable: false, discovery: None, source: String::new(), @@ -11451,11 +11876,11 @@ mod tests { description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, credentials: Vec::new(), - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.custom.example".to_string(), port: 443, ..Default::default() - }], + })], binaries: Vec::new(), inference_capable: false, discovery: None, @@ -11616,11 +12041,11 @@ mod tests { profile: Some(openshell_core::proto::ProviderProfile { id: id.to_string(), display_name: format!("{host} profile"), - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: host.to_string(), port: 443, ..Default::default() - }], + })], ..Default::default() }), }; @@ -11734,7 +12159,7 @@ mod tests { id: "mcp-default".to_string(), display_name: "MCP default".to_string(), category: ProviderProfileCategory::Other as i32, - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "mcp.example.com".to_string(), port: 443, protocol: "mcp".to_string(), @@ -11746,7 +12171,7 @@ mod tests { }), }], ..Default::default() - }], + })], ..Default::default() }), }) @@ -11841,7 +12266,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(test_ambiguous_policy()), + policy: some(test_ambiguous_policy()), ..Default::default() })), ) @@ -11880,7 +12305,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(test_policy_with_credential_binding( + policy: some(test_policy_with_credential_binding( "cloud", "api.cloud.example", "missing-provider", @@ -11927,7 +12352,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(test_policy_with_credential_binding( + policy: some(test_policy_with_credential_binding( "cloud", "api.cloud.example", "work-github", @@ -11986,7 +12411,7 @@ mod tests { operation: Some(policy_merge_operation::Operation::AddRule( openshell_core::proto::AddNetworkRule { rule_name: "bound".to_string(), - rule: Some(policy.network_policies["bound"].clone()), + rule: some(policy.network_policies["bound"].clone()), }, )), }; @@ -11997,7 +12422,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(l4.clone()), + policy: some(l4.clone()), ..Default::default() })), ) @@ -12022,7 +12447,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(tls_skip.clone()), + policy: some(tls_skip.clone()), ..Default::default() })), ) @@ -12125,7 +12550,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(test_sigv4_policy("s3.amazonaws.com", None)), + policy: some(test_sigv4_policy("s3.amazonaws.com", None)), ..Default::default() })), ) @@ -12173,7 +12598,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(test_sigv4_policy("s3.amazonaws.com", None)), + policy: some(test_sigv4_policy("s3.amazonaws.com", None)), ..Default::default() })), ) @@ -12212,7 +12637,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(test_sigv4_policy("s3.amazonaws.com", Some("aws-prod"))), + policy: some(test_sigv4_policy("s3.amazonaws.com", Some("aws-prod"))), ..Default::default() })), ) @@ -12258,7 +12683,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(policy), + policy: some(policy), ..Default::default() })), ) @@ -12290,7 +12715,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(test_sigv4_policy("api.example.com", None)), + policy: some(test_sigv4_policy("api.example.com", None)), ..Default::default() })), ) @@ -12367,12 +12792,12 @@ mod tests { id: "ambiguous".to_string(), display_name: "Ambiguous".to_string(), category: ProviderProfileCategory::Other as i32, - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.example.com".to_string(), port: 443, tls: openshell_core::proto::NetworkTlsMode::Skip as i32, ..Default::default() - }], + })], ..Default::default() }), }) @@ -12441,12 +12866,12 @@ mod tests { id: "tls-skip".to_string(), display_name: "TLS skip".to_string(), category: ProviderProfileCategory::Other as i32, - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.example.com".to_string(), port: 443, tls: openshell_core::proto::NetworkTlsMode::Skip as i32, ..Default::default() - }], + })], ..Default::default() }), }; @@ -12608,11 +13033,11 @@ mod tests { description: String::new(), category: ProviderProfileCategory::Other as i32, credentials: Vec::new(), - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: host.to_string(), port: 443, ..Default::default() - }], + })], binaries: Vec::new(), inference_capable: false, discovery: None, @@ -13104,7 +13529,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(next_policy.clone()), + policy: some(next_policy.clone()), ..Default::default() })), ) @@ -13164,7 +13589,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(unbound_policy), + policy: some(unbound_policy), ..Default::default() })), ) @@ -13252,14 +13677,14 @@ mod tests { }), ..Default::default() }], - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.dynamic.example.test".to_string(), port: 443, path: "/**".to_string(), protocol: "rest".to_string(), access: openshell_core::proto::NetworkAccessPreset::Full as i32, ..Default::default() - }], + })], ..Default::default() }), }; @@ -13353,13 +13778,13 @@ mod tests { ..Default::default() }, ], - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.exchange.example.test".to_string(), port: 443, protocol: "rest".to_string(), access: openshell_core::proto::NetworkAccessPreset::Full as i32, ..Default::default() - }], + })], ..Default::default() }), }; @@ -13552,14 +13977,14 @@ mod tests { }), ..Default::default() }], - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: endpoint_host.to_string(), port: 443, path: "/**".to_string(), protocol: "rest".to_string(), access: openshell_core::proto::NetworkAccessPreset::Full as i32, ..Default::default() - }], + })], ..Default::default() }), } @@ -13739,13 +14164,13 @@ mod tests { }), ..Default::default() }], - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.custom.example".to_string(), port: 443, protocol: "rest".to_string(), access: openshell_core::proto::NetworkAccessPreset::Full as i32, ..Default::default() - }], + })], binaries: Vec::new(), inference_capable: false, discovery: None, @@ -13849,12 +14274,12 @@ mod tests { id: "scoped-revision".to_string(), display_name: format!("{workspace} scoped revision"), category: ProviderProfileCategory::Other as i32, - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.example.test".to_string(), port: 443, path: path.to_string(), ..Default::default() - }], + })], ..Default::default() }), } @@ -14066,7 +14491,7 @@ mod tests { required: true, ..Default::default() }], - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.custom.example".to_string(), port: 443, protocol: "rest".to_string(), @@ -14078,10 +14503,10 @@ mod tests { }), }], ..Default::default() - }], - binaries: vec![NetworkBinary { + })], + binaries: vec![authored_binary(NetworkBinary { path: "/usr/bin/custom".to_string(), - }], + })], inference_capable: false, discovery: None, source: String::new(), @@ -14252,7 +14677,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(sandbox_policy), + policy: some(sandbox_policy), providers: vec!["work-github".to_string()], ..Default::default() }), @@ -14371,7 +14796,7 @@ mod tests { .unwrap() .unwrap(); if let Some(ref mut spec) = sandbox.spec { - spec.policy = Some(new_policy.clone()); + spec.policy = Some(authored_policy(new_policy.clone())); } store.put_message(&sandbox).await.unwrap(); @@ -14380,7 +14805,8 @@ mod tests { .await .unwrap() .unwrap(); - let policy = loaded.spec.unwrap().policy.unwrap(); + let policy = openshell_policy::lower_authored_policy(loaded.spec.unwrap().policy.unwrap()) + .expect("stored public policy must lower"); assert_eq!(policy.version, 1); assert!(policy.filesystem.is_some()); assert_eq!(policy.process.unwrap().run_as_user, "sandbox"); @@ -14446,7 +14872,7 @@ mod tests { .into_iter() .map(|(name, host, binary)| PolicyChunk { rule_name: name.to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: name.to_string(), endpoints: vec![NetworkEndpoint { host: host.to_string(), @@ -14571,7 +14997,7 @@ mod tests { proposed_chunks: vec![ PolicyChunk { rule_name: "inspected".to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: "inspected".to_string(), endpoints: vec![NetworkEndpoint { host: "shared.example.com".to_string(), @@ -14590,7 +15016,7 @@ mod tests { }, PolicyChunk { rule_name: "conflicting".to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: "conflicting".to_string(), endpoints: vec![NetworkEndpoint { host: "shared.example.com".to_string(), @@ -14796,7 +15222,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "private_service".to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: "private_service".to_string(), endpoints: vec![NetworkEndpoint { host: "service.example.com".to_string(), @@ -14930,7 +15356,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "edited_service".to_string(), - proposed_rule: Some(safe_rule.clone()), + proposed_rule: some(safe_rule.clone()), ..Default::default() }], ..Default::default() @@ -14948,11 +15374,11 @@ mod tests { with_user(Request::new(EditDraftChunkRequest { request_id: String::new(), sandbox: sandbox_name.to_string(), + chunk_id: chunk_id.clone(), + proposed_rule: some(private_rule), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - chunk_id: chunk_id.clone(), - proposed_rule: Some(private_rule), })), ) .await @@ -15104,7 +15530,7 @@ mod tests { analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "private_service".to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: "private_service".to_string(), endpoints: vec![NetworkEndpoint { host: "service.example.com".to_string(), @@ -15188,7 +15614,7 @@ mod tests { analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: safe_rule.name.clone(), - proposed_rule: Some(safe_rule.clone()), + proposed_rule: some(safe_rule.clone()), ..Default::default() }], ..Default::default() @@ -15244,11 +15670,11 @@ mod tests { with_user(Request::new(EditDraftChunkRequest { request_id: String::new(), sandbox: sandbox_name.to_string(), + chunk_id: chunk_id.clone(), + proposed_rule: some(finding_rule), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - chunk_id: chunk_id.clone(), - proposed_rule: Some(finding_rule), })), ) .await @@ -15266,7 +15692,7 @@ mod tests { analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: safe_rule.name.clone(), - proposed_rule: Some(safe_rule), + proposed_rule: some(safe_rule), ..Default::default() }], ..Default::default() @@ -15357,7 +15783,7 @@ mod tests { name: sandbox_name.clone(), proposed_chunks: vec![PolicyChunk { rule_name: "allow_github".to_string(), - proposed_rule: Some(proposed_rule.clone()), + proposed_rule: some(proposed_rule.clone()), rationale: "observed denied request".to_string(), confidence: 0.85, hit_count: 3, @@ -15613,7 +16039,7 @@ mod tests { name: sandbox_name.clone(), proposed_chunks: vec![PolicyChunk { rule_name: "allow_example".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "agent intent".to_string(), ..Default::default() }], @@ -15692,7 +16118,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -15740,7 +16166,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "github_contents_write".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "write one demo file".to_string(), ..Default::default() }], @@ -15813,7 +16239,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -15851,7 +16277,7 @@ mod tests { analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "allow_api_github_com_443".to_string(), - proposed_rule: Some(mechanistic_rule), + proposed_rule: some(mechanistic_rule), rationale: "Allow /usr/bin/curl to connect to api.github.com:443.".to_string(), ..Default::default() }], @@ -15931,7 +16357,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "github_contents_put".to_string(), - proposed_rule: Some(agent_rule), + proposed_rule: some(agent_rule), rationale: "refined L7 scope for the demo write".to_string(), ..Default::default() }], @@ -16026,7 +16452,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -16065,7 +16491,7 @@ mod tests { analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "Allow /usr/bin/curl to connect to example.com:443.".to_string(), ..Default::default() }], @@ -16138,7 +16564,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(base_policy), + policy: some(base_policy), ..Default::default() }), ..Default::default() @@ -16158,7 +16584,7 @@ mod tests { analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "allow_index_crates_io_443".to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: "allow_index_crates_io_443".to_string(), endpoints: vec![NetworkEndpoint { host: "index.crates.io".to_string(), @@ -16202,15 +16628,7 @@ mod tests { assert!(!chunk.review_token.is_empty()); let canonical = chunk.proposed_rule.as_ref().unwrap(); assert_eq!(canonical.endpoints[0].protocol, "rest"); - assert_eq!( - canonical.endpoints[0].access, - openshell_core::proto::NetworkAccessPreset::ReadOnly as i32 - ); - assert!( - canonical.endpoints[0].advisor_proposed, - "a new advisor overlay must retain proposal provenance" - ); - + assert_eq!(canonical.endpoints[0].access, "read-only"); let revision = state .store .get_latest_policy("sb-mechanistic-existing-rest") @@ -16262,7 +16680,7 @@ mod tests { ..Default::default() }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy::default()), + policy: some(SandboxPolicy::default()), ..Default::default() }), ..Default::default() @@ -16278,7 +16696,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "bad_graphql".to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: "bad-graphql".to_string(), endpoints: vec![NetworkEndpoint { host: "api.example.com".to_string(), @@ -16347,7 +16765,7 @@ mod tests { ..Default::default() }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy::default()), + policy: some(SandboxPolicy::default()), ..Default::default() }), ..Default::default() @@ -16363,7 +16781,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "example".to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: "example".to_string(), endpoints: vec![NetworkEndpoint { host: "example.com".to_string(), @@ -16515,7 +16933,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "example".to_string(), - proposed_rule: Some(rule.clone()), + proposed_rule: some(rule.clone()), ..Default::default() }], ..Default::default() @@ -16578,7 +16996,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -16620,7 +17038,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "github_l7_full".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "broad L7 dressing".to_string(), ..Default::default() }], @@ -16689,7 +17107,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -16726,7 +17144,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "un-credentialed L4 — prover sees no finding".to_string(), ..Default::default() }], @@ -16788,7 +17206,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -16825,7 +17243,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "un-credentialed L4".to_string(), ..Default::default() }], @@ -16879,7 +17297,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -16915,7 +17333,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "un-credentialed L4 — prover sees no finding".to_string(), ..Default::default() }], @@ -16972,7 +17390,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -17009,7 +17427,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "un-credentialed L4 — empty delta".to_string(), ..Default::default() }], @@ -17065,7 +17483,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -17106,7 +17524,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "un-credentialed L4 — empty delta".to_string(), ..Default::default() }], @@ -17163,7 +17581,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -17199,7 +17617,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "_provider_work_github".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "should be rejected — addresses provider rule by name".to_string(), ..Default::default() }], @@ -17249,7 +17667,7 @@ mod tests { }; let chunk = |name: &str, endpoint: NetworkEndpoint| PolicyChunk { rule_name: name.to_string(), - proposed_rule: Some(NetworkPolicyRule { + proposed_rule: some(NetworkPolicyRule { name: name.to_string(), endpoints: vec![endpoint], binaries: vec![NetworkBinary { @@ -17433,7 +17851,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -17470,7 +17888,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "github_l4".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "broad fallback".to_string(), ..Default::default() }], @@ -17536,7 +17954,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -17572,7 +17990,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "no privileged access available".to_string(), ..Default::default() }], @@ -17627,7 +18045,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -17663,7 +18081,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "metadata_endpoint".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "agent is curious about IMDS".to_string(), ..Default::default() }], @@ -17732,7 +18150,7 @@ mod tests { description: String::new(), category: ProviderProfileCategory::Other as i32, credentials: Vec::new(), - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.github.com".to_string(), port: 443, protocol: "rest".to_string(), @@ -17743,10 +18161,10 @@ mod tests { ..Default::default() }], ..Default::default() - }], - binaries: vec![NetworkBinary { + })], + binaries: vec![authored_binary(NetworkBinary { path: "/usr/bin/curl".to_string(), - }], + })], inference_capable: false, discovery: None, source: String::new(), @@ -17770,7 +18188,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -17819,7 +18237,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "github_contents_write".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), rationale: "write one demo file".to_string(), ..Default::default() }], @@ -17959,7 +18377,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -18006,7 +18424,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "github_raw_openapi_get".to_string(), - proposed_rule: Some(uncredentialed_rule), + proposed_rule: some(uncredentialed_rule), rationale: "fetch the public github openapi description".to_string(), ..Default::default() }], @@ -18047,7 +18465,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "github_contents_put".to_string(), - proposed_rule: Some(credentialed_rule), + proposed_rule: some(credentialed_rule), rationale: "write the demo file via the GitHub Contents API".to_string(), ..Default::default() }], @@ -18190,7 +18608,7 @@ mod tests { analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { rule_name, - proposed_rule: Some(rule), + proposed_rule: some(rule), ..Default::default() }], ..Default::default() @@ -18307,7 +18725,7 @@ mod tests { analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "allow_example".to_string(), - proposed_rule: Some(rule), + proposed_rule: some(rule), ..Default::default() }], ..Default::default() @@ -18385,7 +18803,7 @@ mod tests { deletion_time: None, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { + policy: some(SandboxPolicy { version: 1, filesystem: Some(FilesystemPolicy { read_write: vec!["/sandbox".to_string()], @@ -18427,7 +18845,7 @@ mod tests { analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "allow_example_8080".to_string(), - proposed_rule: Some(rule), + proposed_rule: some(rule), ..Default::default() }], ..Default::default() @@ -18690,7 +19108,7 @@ mod tests { name: sandbox_name.clone(), proposed_chunks: vec![PolicyChunk { rule_name: "allow_example".to_string(), - proposed_rule: Some(proposed_rule), + proposed_rule: some(proposed_rule), ..Default::default() }], ..Default::default() @@ -18854,7 +19272,7 @@ mod tests { name: sandbox_a.object_name().to_string(), proposed_chunks: vec![PolicyChunk { rule_name: "allow_example".to_string(), - proposed_rule: Some(proposed_rule.clone()), + proposed_rule: some(proposed_rule.clone()), rationale: "observed denied request".to_string(), confidence: 0.85, hit_count: 3, @@ -18923,11 +19341,11 @@ mod tests { authed_request(EditDraftChunkRequest { request_id: String::new(), sandbox: other_name.clone(), + chunk_id: chunk_id.clone(), + proposed_rule: some(proposed_rule.clone()), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - chunk_id: chunk_id.clone(), - proposed_rule: Some(proposed_rule.clone()), }), ) .await @@ -19912,7 +20330,7 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { global: true, - policy: Some(test_policy_with_rule( + policy: some(test_policy_with_rule( "_provider_work_github", "api.github.com", )), @@ -19935,7 +20353,7 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { global: true, - policy: Some(test_ambiguous_policy()), + policy: some(test_ambiguous_policy()), ..Default::default() })), ) @@ -19977,12 +20395,12 @@ mod tests { id: profile_name.clone(), display_name: "Ambiguous".to_string(), category: ProviderProfileCategory::Other as i32, - endpoints: vec![NetworkEndpoint { + endpoints: vec![authored_endpoint(NetworkEndpoint { host: "api.example.com".to_string(), port: 443, tls: openshell_core::proto::NetworkTlsMode::Skip as i32, ..Default::default() - }], + })], ..Default::default() }), }) @@ -20014,7 +20432,7 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { global: true, - policy: Some(test_policy_with_rule("global", "global.example.com")), + policy: some(test_policy_with_rule("global", "global.example.com")), ..Default::default() })), ) @@ -21077,7 +21495,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(new_policy), + policy: some(new_policy), setting_key: String::new(), setting_value: None, delete_setting: false, @@ -21176,7 +21594,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(ProtoSandboxPolicy::default()), + policy: some(ProtoSandboxPolicy::default()), setting_key: String::new(), setting_value: None, delete_setting: false, @@ -21254,7 +21672,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(policy), + policy: some(policy), annotations: HashMap::from([( "openshell.nvidia.com/policy-signature".to_string(), "same-hash-signature".to_string(), @@ -21343,7 +21761,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(policy.clone()), + policy: some(policy.clone()), annotations: annotations.clone(), ..Default::default() })), @@ -21358,7 +21776,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(policy), + policy: some(policy), annotations: annotations.clone(), ..Default::default() })), @@ -21412,10 +21830,8 @@ mod tests { &state, with_user(Request::new(UpdateConfigRequest { sandbox: "preserve-full".to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - policy: Some(updated), + policy: some(updated), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() })), ) @@ -21470,7 +21886,7 @@ mod tests { operation: Some(policy_merge_operation::Operation::AddRule( openshell_core::proto::AddNetworkRule { rule_name: "allow_api_example".to_string(), - rule: Some(NetworkPolicyRule { + rule: some(NetworkPolicyRule { name: "allow_api_example".to_string(), endpoints: vec![NetworkEndpoint { host: "api.example.com".to_string(), @@ -21544,7 +21960,7 @@ mod tests { operation: Some(policy_merge_operation::Operation::AddRule( openshell_core::proto::AddNetworkRule { rule_name: "allow_api_example".to_string(), - rule: Some(NetworkPolicyRule { + rule: some(NetworkPolicyRule { name: "allow_api_example".to_string(), endpoints: vec![NetworkEndpoint { host: "api.example.com".to_string(), @@ -21619,7 +22035,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(ProtoSandboxPolicy::default()), + policy: some(ProtoSandboxPolicy::default()), expected_resource_version: current_version, ..Default::default() }), @@ -21696,7 +22112,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(unsafe_replacement), + policy: some(unsafe_replacement), expected_resource_version: current_version, ..Default::default() })), @@ -21717,7 +22133,9 @@ mod tests { .spec .as_ref() .and_then(|spec| spec.policy.as_ref()), - Some(&openshell_policy::restrictive_default_policy()) + Some(&authored_policy( + openshell_policy::restrictive_default_policy() + )) ); assert!( state @@ -21774,7 +22192,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(mcp_policy_with_versions(versions)), + policy: Some(authored_mcp_policy_with_versions(versions)), expected_resource_version: current_version, ..Default::default() })), @@ -21858,7 +22276,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(policy), + policy: some(policy), expected_resource_version: current_version, ..Default::default() })), @@ -21880,9 +22298,15 @@ mod tests { .as_ref() .and_then(|spec| spec.policy.as_ref()) .expect("backfilled sandbox policy"); - assert_eq!(stored_policy, &canonical_policy, "{case}"); assert_eq!( - mcp_versions(stored_policy), + stored_policy, + &authored_policy(canonical_policy.clone()), + "{case}" + ); + let stored_policy = openshell_policy::lower_authored_policy(stored_policy.clone()) + .expect("backfilled policy must lower"); + assert_eq!( + mcp_versions(&stored_policy), &["2025-11-25".to_string()], "{case}" ); @@ -21945,7 +22369,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(mcp_policy_with_versions(&[ + policy: some(mcp_policy_with_versions(&[ "2025-11-25", "2025-06-18", "2025-03-26", @@ -21965,7 +22389,7 @@ mod tests { .unwrap(); assert_eq!( stored.spec.as_ref().and_then(|spec| spec.policy.as_ref()), - Some(&canonical_policy) + Some(&authored_policy(canonical_policy.clone())) ); let revision = state .store @@ -22026,7 +22450,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(ProtoSandboxPolicy::default()), + policy: some(ProtoSandboxPolicy::default()), annotations: HashMap::from([("bad key".to_string(), "value".to_string())]), ..Default::default() })), @@ -22059,7 +22483,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(test_policy_with_rule( + policy: some(test_policy_with_rule( "_provider_work_github", "api.github.com", )), @@ -22130,7 +22554,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(synced_policy), + policy: some(synced_policy), expected_resource_version: current_version, ..Default::default() }), @@ -22229,7 +22653,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(new_policy), + policy: some(new_policy), setting_key: String::new(), setting_value: None, delete_setting: false, @@ -22331,7 +22755,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - policy: Some(new_policy), + policy: some(new_policy), setting_key: String::new(), setting_value: None, delete_setting: false, diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs index cd3f863cf3..bf3206d797 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs @@ -5,6 +5,7 @@ use super::super::tests::{mcp_policy_with_versions, test_sandbox, with_sandbox}; use super::super::{handle_get_sandbox_config, handle_report_policy_status, handle_update_config}; use super::*; use crate::grpc::test_support::{authed_request, test_server_state}; +use crate::grpc::validation::validate_and_canonicalize_policy; use openshell_core::endpoint_status::endpoint_id; use openshell_core::proto::{ EndpointObservation, GetSandboxConfigRequest, GetSandboxRequest, NetworkEndpoint, @@ -277,6 +278,8 @@ async fn loaded_policy_comparison_uses_one_provider_profile_snapshot() { .expect("sandbox spec") .policy .expect("sandbox policy"); + let internal_policy = + openshell_policy::lower_authored_policy(policy).expect("stored public policy must lower"); state .store .put_policy_revision( @@ -284,7 +287,7 @@ async fn loaded_policy_comparison_uses_one_provider_profile_snapshot() { sandbox_id, "default", 2, - &policy.encode_to_vec(), + &internal_policy.encode_to_vec(), &report.policy_hash, ) .await @@ -392,7 +395,7 @@ async fn global_policy_update_waits_for_endpoint_report_guard() { let policy = mcp_policy_with_versions(&["2025-11-25"]); let updates = [ UpdateConfigRequest { - policy: Some(policy), + policy: Some(openshell_policy::project_base_policy(&policy).unwrap()), global: true, ..Default::default() }, @@ -1052,7 +1055,8 @@ async fn loaded_policy_and_unknown_endpoint_inventory_commit_atomically() { .put_message(&sandbox) .await .expect("store sandbox"); - let active_policy = mcp_policy_with_versions(&["2025-11-25"]); + let active_policy = validate_and_canonicalize_policy(mcp_policy_with_versions(&["2025-11-25"])) + .expect("active policy must canonicalize"); let policy_hash = deterministic_policy_hash(&active_policy); let initial_active = initial_endpoint_status(&active_policy.network_policies["mcp"].endpoints[0]); diff --git a/crates/openshell-server/src/grpc/policy/provisioning_clock.rs b/crates/openshell-server/src/grpc/policy/provisioning_clock.rs index 2097bf37bd..f9ad74a013 100644 --- a/crates/openshell-server/src/grpc/policy/provisioning_clock.rs +++ b/crates/openshell-server/src/grpc/policy/provisioning_clock.rs @@ -76,7 +76,14 @@ pub async fn configuration_change( .spec .as_ref() .and_then(|spec| spec.policy.as_ref()) - .map(openshell_core::policy_identity::deterministic_policy_hash) + .map(|policy| { + openshell_policy::lower_authored_policy(policy.clone()) + .map(|policy| { + openshell_core::policy_identity::deterministic_policy_hash(&policy) + }) + .map_err(|error| error.to_string()) + }) + .transpose()? .unwrap_or_default(); sources.push(("policy".into(), format!("1:{hash}"))); } @@ -235,7 +242,7 @@ mod tests { let policy = openshell_policy::restrictive_default_policy(); let hash = openshell_core::policy_identity::deterministic_policy_hash(&policy); sandbox.spec = Some(openshell_core::proto::SandboxSpec { - policy: Some(policy.clone()), + policy: Some(openshell_policy::project_base_policy(&policy).unwrap()), ..Default::default() }); let first = configuration_change(&store, &sandbox).await.unwrap(); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index a2563babeb..ac9e220c88 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, @@ -2824,7 +2816,9 @@ pub(super) async fn handle_import_provider_profiles( stored.object_id(), stored.object_name(), &workspace, - &stored.encode_to_vec(), + &crate::storage_proto::encode_provider_profile(&stored).map_err(|error| { + Status::internal(format!("encode provider profile failed: {error}")) + })?, profile_labels_json.as_deref(), WriteCondition::MustCreate, ) @@ -2959,7 +2953,9 @@ pub(super) async fn handle_update_provider_profiles( stored.object_id(), stored.object_name(), &workspace, - &stored.encode_to_vec(), + &crate::storage_proto::encode_provider_profile(&stored).map_err(|error| { + Status::internal(format!("encode provider profile failed: {error}")) + })?, labels_json.as_deref(), WriteCondition::MatchResourceVersion(expected_resource_version), ) @@ -3425,7 +3421,16 @@ fn profiles_from_import_items( }); } } - profiles.push((source, ProviderTypeProfile::from_proto(profile))); + match ProviderTypeProfile::try_from_proto(profile) { + Ok(profile) => profiles.push((source, profile)), + Err(error) => diagnostics.push(ProfileValidationDiagnostic { + source, + profile_id: profile.id.clone(), + field: "profile".to_string(), + message: error.to_string(), + severity: "error".to_string(), + }), + } } (profiles, diagnostics) } @@ -3663,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, @@ -5124,6 +5129,9 @@ mod tests { authed_request, test_server_state, test_server_state_without_provider_profiles, }; use crate::grpc::{MAX_MAP_KEY_LEN, MAX_PROVIDER_TYPE_LEN}; + use openshell_core::proto::policy::{ + L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, + }; /// An in-memory store with the example profiles imported at platform scope. /// @@ -5146,14 +5154,13 @@ mod tests { AttachSandboxProviderRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, CreateWorkspaceRequest, DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, - GetProviderRequest, ImportProviderProfilesRequest, L7Allow, L7Rule, - LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, - NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, + GetProviderRequest, ImportProviderProfilesRequest, LintProviderProfilesRequest, + ListProviderProfilesRequest, ListProvidersRequest, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, ProviderCredentialTokenGrant, ProviderCredentialTokenGrantAudienceOverride, ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, ProviderProfileImportItem, RotateProviderCredentialRequest, - Sandbox, SandboxPolicy, SandboxSpec, UpdateProviderProfilesRequest, UpdateProviderRequest, + Sandbox, SandboxSpec, UpdateProviderProfilesRequest, UpdateProviderRequest, }; use openshell_core::{ObjectId, ObjectName}; use tonic::{Code, Request}; @@ -5337,7 +5344,7 @@ mod tests { .iter() .map(|(host, _)| NetworkEndpoint { host: (*host).to_string(), - port: 80, + ports: vec![80], ..Default::default() }) .collect(), @@ -5372,10 +5379,10 @@ 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: openshell_core::proto::NetworkAccessPreset::Full as i32, + access: "full".to_string(), ..Default::default() }]; handle_import_provider_profiles( @@ -5515,10 +5522,10 @@ 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: openshell_core::proto::NetworkAccessPreset::Full as i32, + access: "full".to_string(), ..Default::default() }]; let response = handle_import_provider_profiles( @@ -5605,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( @@ -5875,10 +5882,10 @@ 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: openshell_core::proto::NetworkAccessPreset::Full as i32, + access: "full".to_string(), ..Default::default() }]; let response = handle_update_provider_profiles( @@ -6127,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 @@ -6586,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( @@ -6630,14 +6637,15 @@ mod tests { }), spec: Some(SandboxSpec { providers: vec!["fanout-provider".to_string()], - policy: Some(SandboxPolicy { + policy: Some(openshell_core::proto::policy::PolicyDocument { + version: 1, network_policies: HashMap::from([( "base".to_string(), NetworkPolicyRule { name: "base".to_string(), endpoints: vec![NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, + ports: vec![443], ..Default::default() }], ..Default::default() @@ -6656,8 +6664,8 @@ mod tests { conflicting_profile.resource_version = resource_version; conflicting_profile.endpoints.push(NetworkEndpoint { host: "api.example.com".to_string(), - port: 443, - tls: openshell_core::proto::NetworkTlsMode::Skip as i32, + ports: vec![443], + tls: "skip".to_string(), ..Default::default() }); let response = handle_update_provider_profiles( @@ -6873,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 { @@ -6883,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 { @@ -6904,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"] { @@ -7039,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"] { @@ -9398,7 +9414,12 @@ mod tests { spec: Some(SandboxSpec { providers: vec!["openai-local".to_string()], provider_attachment_epoch: attachment_epoch.clone(), - policy: Some(openshell_policy::restrictive_default_policy()), + policy: Some( + openshell_policy::project_base_policy( + &openshell_policy::restrictive_default_policy(), + ) + .unwrap(), + ), ..Default::default() }), ..Default::default() @@ -9813,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() }]; @@ -9983,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() }]; @@ -10998,10 +11019,10 @@ 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: openshell_core::proto::NetworkAccessPreset::Full as i32, + access: "full".to_string(), ..Default::default() }]; handle_import_provider_profiles( @@ -14830,8 +14851,6 @@ mod tests { #[tokio::test] async fn provider_with_global_profile_resolves_platform_scoped_profile() { use crate::persistence::{ObjectName, WriteCondition}; - use prost::Message; - let store = test_store().await; let stored = stored_provider_profile_for_workspace(custom_profile("global-custom"), ""); @@ -14841,7 +14860,7 @@ mod tests { stored.object_id(), stored.object_name(), "", - &stored.encode_to_vec(), + &crate::storage_proto::encode_provider_profile(&stored).unwrap(), None, WriteCondition::MustCreate, ) diff --git a/crates/openshell-server/src/grpc/provider_readiness_tests.rs b/crates/openshell-server/src/grpc/provider_readiness_tests.rs index 1bdf4aa63e..d168b9b827 100644 --- a/crates/openshell-server/src/grpc/provider_readiness_tests.rs +++ b/crates/openshell-server/src/grpc/provider_readiness_tests.rs @@ -784,7 +784,12 @@ async fn attach_waiting_for_update_captures_published_revision_and_becomes_ready ..Default::default() }), spec: Some(SandboxSpec { - policy: Some(openshell_policy::restrictive_default_policy()), + policy: Some( + openshell_policy::project_base_policy( + &openshell_policy::restrictive_default_policy(), + ) + .unwrap(), + ), ..Default::default() }), ..Default::default() @@ -932,7 +937,12 @@ async fn status_rejects_oversized_provider_name_before_persisting_receipt() { ..Default::default() }), spec: Some(SandboxSpec { - policy: Some(openshell_policy::restrictive_default_policy()), + policy: Some( + openshell_policy::project_base_policy( + &openshell_policy::restrictive_default_policy(), + ) + .unwrap(), + ), ..Default::default() }), ..Default::default() @@ -972,7 +982,12 @@ async fn status_accepts_maximum_provider_name_and_receipt_only_lookup() { ..Default::default() }), spec: Some(SandboxSpec { - policy: Some(openshell_policy::restrictive_default_policy()), + policy: Some( + openshell_policy::project_base_policy( + &openshell_policy::restrictive_default_policy(), + ) + .unwrap(), + ), ..Default::default() }), ..Default::default() @@ -1055,7 +1070,12 @@ async fn observation_fixture() -> ( ..Default::default() }), spec: Some(SandboxSpec { - policy: Some(openshell_policy::restrictive_default_policy()), + policy: Some( + openshell_policy::project_base_policy( + &openshell_policy::restrictive_default_policy(), + ) + .unwrap(), + ), provider_attachment_epoch: Uuid::new_v4().to_string(), ..Default::default() }), @@ -1191,7 +1211,12 @@ async fn stored_change_is_bound_to_its_sandbox_and_provider() { }), spec: Some(SandboxSpec { provider_attachment_epoch: Uuid::new_v4().to_string(), - policy: Some(openshell_policy::restrictive_default_policy()), + policy: Some( + openshell_policy::project_base_policy( + &openshell_policy::restrictive_default_policy(), + ) + .unwrap(), + ), ..Default::default() }), ..Default::default() @@ -1243,7 +1268,12 @@ async fn detach_receipt_persists_but_gateway_restart_requires_fresh_installation }), spec: Some(SandboxSpec { provider_attachment_epoch: Uuid::new_v4().to_string(), - policy: Some(openshell_policy::restrictive_default_policy()), + policy: Some( + openshell_policy::project_base_policy( + &openshell_policy::restrictive_default_policy(), + ) + .unwrap(), + ), ..Default::default() }), ..Default::default() diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index f3ba04a2c8..60e8224749 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -489,11 +489,22 @@ async fn handle_create_sandbox_inner( template.image = state.compute.default_image().to_string(); } - if let Some(ref mut policy) = spec.policy { - super::policy::clear_provider_credentialed_markers(policy); - validate_no_reserved_provider_policy_keys(policy)?; - *policy = validate_and_canonicalize_policy(policy.clone())?; - } + let internal_policy = spec + .policy + .take() + .map(super::policy::lower_public_policy) + .transpose()? + .map(|mut policy| { + super::policy::clear_provider_credentialed_markers(&mut policy); + validate_no_reserved_provider_policy_keys(&policy)?; + validate_and_canonicalize_policy(policy) + }) + .transpose()?; + spec.policy = internal_policy + .as_ref() + .map(openshell_policy::project_base_policy) + .transpose() + .map_err(|error| Status::internal(format!("failed to project public policy: {error}")))?; // Process identity and MCP default materialization can increase the // protobuf size. Recheck the exact canonical spec before any middleware or @@ -501,7 +512,7 @@ async fn handle_create_sandbox_inner( // above so requests that are already oversized still fail before I/O. validate_sandbox_spec(&request.name, &spec)?; - if let Some(ref policy) = spec.policy { + if let Some(ref policy) = internal_policy { validate_policy_safety(policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), policy).await?; } @@ -509,7 +520,7 @@ async fn handle_create_sandbox_inner( state, &workspace, &spec.providers, - spec.policy.as_ref(), + internal_policy.as_ref(), ) .await?; @@ -1242,11 +1253,16 @@ pub(super) async fn handle_attach_sandbox_provider( &candidate_spec.providers, ) .await?; + let candidate_internal_policy = candidate_spec + .policy + .clone() + .map(super::policy::lower_public_policy) + .transpose()?; super::policy::validate_candidate_sandbox_credential_policy( state, &workspace, &candidate_spec.providers, - candidate_spec.policy.as_ref(), + candidate_internal_policy.as_ref(), ) .await?; @@ -3345,6 +3361,37 @@ mod tests { use openshell_core::proto::GpuResourceRequirements; use openshell_core::proto::datamodel::v1::ObjectMeta; + fn authored_policy( + mut policy: openshell_core::proto::SandboxPolicy, + ) -> openshell_core::proto::policy::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], + ) -> 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 + .as_mut() + .unwrap() + .versions = versions + .iter() + .map(|version| (*version).to_string()) + .collect(); + policy + } + + fn authored_rule( + rule: openshell_core::proto::NetworkPolicyRule, + ) -> openshell_core::proto::policy::NetworkPolicyRule { + openshell_policy::project_authored_rule("test-rule", &rule) + .expect("test rule must be authorable") + } + // ---- shell_escape ---- #[test] @@ -3384,7 +3431,10 @@ mod tests { request_id: String::new(), spec: Some(SandboxSpec { providers: vec!["github".to_string()], - policy: Some(openshell_core::proto::SandboxPolicy::default()), + policy: Some(openshell_core::proto::policy::PolicyDocument { + version: 1, + ..Default::default() + }), ..SandboxSpec::default() }), workload_template: "gpu-kata".to_string(), @@ -3396,7 +3446,10 @@ mod tests { let created = Sandbox { spec: Some(SandboxSpec { providers: vec!["github".to_string()], - policy: Some(openshell_core::proto::SandboxPolicy::default()), + policy: Some(openshell_core::proto::policy::PolicyDocument { + version: 1, + ..Default::default() + }), resource_requirements: Some(ResourceRequirements { gpu: Some(GpuResourceRequirements { count: Some(1) }), }), @@ -3788,7 +3841,10 @@ mod tests { }), spec: Some(SandboxSpec { log_level: "debug".to_string(), - policy: Some(openshell_core::proto::SandboxPolicy::default()), + policy: Some(openshell_core::proto::policy::PolicyDocument { + version: 1, + ..Default::default() + }), providers, ..Default::default() }), @@ -4160,7 +4216,7 @@ mod tests { .unwrap(); policy.network_policies.insert( "gcp_storage".to_string(), - openshell_core::proto::NetworkPolicyRule { + authored_rule(openshell_core::proto::NetworkPolicyRule { name: "gcp_storage".to_string(), endpoints: vec![openshell_core::proto::NetworkEndpoint { host: "storage.googleapis.com".to_string(), @@ -4171,7 +4227,7 @@ mod tests { ..Default::default() }], ..Default::default() - }, + }), ); state.store.put_message(&sandbox).await.unwrap(); @@ -4580,7 +4636,7 @@ mod tests { request_id: String::new(), name: "reserved-policy-key".to_string(), spec: Some(SandboxSpec { - policy: Some(policy), + policy: Some(authored_policy(policy)), ..Default::default() }), labels: HashMap::new(), @@ -4689,7 +4745,10 @@ mod tests { authed_request(CreateSandboxRequest { name: "mcp-canonical".to_string(), spec: Some(SandboxSpec { - policy: Some(mcp_policy_with_versions(&["2025-11-25", "2025-03-26"])), + policy: Some(authored_policy(mcp_policy_with_versions(&[ + "2025-11-25", + "2025-03-26", + ]))), ..Default::default() }), labels: HashMap::new(), @@ -4748,7 +4807,7 @@ mod tests { authed_request(CreateSandboxRequest { name: sandbox_name.to_string(), spec: Some(SandboxSpec { - policy: Some(mcp_policy_with_options(mcp)), + policy: Some(authored_policy(mcp_policy_with_options(mcp))), ..Default::default() }), labels: HashMap::new(), @@ -4834,11 +4893,7 @@ mod tests { ); let raw_size = policy.encoded_len(); - if spelling == "explicit" { - assert_eq!(raw_size, target_size, "{sandbox_name}"); - } else { - assert!(raw_size < target_size, "{sandbox_name}: {raw_size}"); - } + assert!(raw_size < target_size, "{sandbox_name}: {raw_size}"); let canonical = validate_and_canonicalize_policy(policy.clone()) .expect("defaultable MCP policy must be canonicalizable"); @@ -4849,7 +4904,7 @@ mod tests { authed_request(CreateSandboxRequest { name: sandbox_name.clone(), spec: Some(SandboxSpec { - policy: Some(policy), + policy: Some(authored_policy(policy)), ..Default::default() }), labels: HashMap::new(), @@ -4875,9 +4930,11 @@ mod tests { .expect("sandbox spec") .policy .expect("sandbox policy"); - assert_eq!(stored_policy, canonical, "{sandbox_name}"); + assert_eq!(stored_policy, authored_policy(canonical), "{sandbox_name}"); assert_eq!( - stored_policy.encoded_len(), + openshell_policy::lower_authored_policy(stored_policy) + .expect("stored public policy must lower") + .encoded_len(), max_policy_size, "{sandbox_name}" ); @@ -4919,7 +4976,7 @@ mod tests { authed_request(CreateSandboxRequest { name: sandbox_name.to_string(), spec: Some(SandboxSpec { - policy: Some(mcp_policy_with_versions(versions)), + policy: Some(authored_mcp_policy_with_versions(versions)), ..Default::default() }), labels: HashMap::new(), @@ -5021,7 +5078,7 @@ mod tests { request_id: String::new(), name: "partial-id".to_string(), spec: Some(SandboxSpec { - policy: Some(policy), + policy: Some(authored_policy(policy)), ..Default::default() }), labels: HashMap::new(), @@ -5091,7 +5148,7 @@ mod tests { request_id: String::new(), name: "kube-partial-id".to_string(), spec: Some(SandboxSpec { - policy: Some(policy), + policy: Some(authored_policy(policy)), ..Default::default() }), labels: HashMap::new(), @@ -5820,7 +5877,7 @@ mod tests { name: "from-template".to_string(), spec: Some(SandboxSpec { providers: vec!["work-github".to_string()], - policy: Some(policy), + policy: Some(authored_policy(policy)), command: vec!["echo".to_string(), "template-create".to_string()], tty: false, ..Default::default() diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 4218f296da..5af10b2cd0 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -254,18 +254,31 @@ fn validate_sandbox_provider_count(spec: &SandboxSpec) -> Result<(), Status> { fn validate_sandbox_policy_size(spec: &SandboxSpec) -> Result<(), Status> { if let Some(ref policy) = spec.policy { - let size = policy.encoded_len(); - if size > MAX_POLICY_SIZE { - return Err(invalid_argument( - "spec.policy", - format!("policy serialized size exceeds maximum ({size} > {MAX_POLICY_SIZE})"), - )); - } + let policy = openshell_policy::lower_authored_policy(policy.clone()).map_err(|error| { + invalid_argument("spec.policy", format!("invalid authored policy: {error}")) + })?; + validate_canonical_policy_size(&policy, "spec.policy")?; } Ok(()) } +/// Bound the internal protobuf representation that the gateway hashes and +/// persists. Call this after canonicalization when defaults can grow a policy. +pub(super) fn validate_canonical_policy_size( + policy: &ProtoSandboxPolicy, + field: &'static str, +) -> Result<(), Status> { + let size = policy.encoded_len(); + if size > MAX_POLICY_SIZE { + return Err(invalid_argument( + field, + format!("policy serialized size exceeds maximum ({size} > {MAX_POLICY_SIZE})"), + )); + } + Ok(()) +} + fn validate_main_process_command(command: &[String]) -> Result<(), Status> { if command.len() > MAX_MAIN_PROCESS_ARGS { return Err(invalid_argument( @@ -1403,10 +1416,10 @@ mod tests { #[test] fn validate_sandbox_spec_rejects_oversized_policy() { - use openshell_core::proto::NetworkPolicyRule; - use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; + use openshell_core::proto::policy::NetworkPolicyRule; + 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/persistence/legacy_time_wire.rs b/crates/openshell-server/src/persistence/legacy_time_wire.rs index d5f12bab1b..e6637d42aa 100644 --- a/crates/openshell-server/src/persistence/legacy_time_wire.rs +++ b/crates/openshell-server/src/persistence/legacy_time_wire.rs @@ -40,11 +40,11 @@ pub(super) fn migrate(object_type: &str, payload: &[u8]) -> PersistenceResult Option<&'static str> { match object_type { - "sandbox" => Some("openshell.v1.Sandbox"), + "sandbox" => Some("openshell.storage.v1.StoredSandbox"), "provider" => Some("openshell.datamodel.v1.Provider"), "workspace" => Some("openshell.datamodel.v1.Workspace"), "workspace_member" => Some("openshell.v1.WorkspaceMember"), - "provider_profile" => Some("openshell.storage.v1.StoredProviderProfile"), + "provider_profile" => Some("openshell.storage.v1.StoredProviderProfileWire"), "provider_credential_refresh_state" => { Some("openshell.storage.v1.StoredProviderCredentialRefreshStateV2") } diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 0b365ccd0c..d23cc3b5de 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -236,12 +236,54 @@ fn decode_record( record: ObjectRecord, ) -> PersistenceResult { let payload = legacy_time_wire::migrate(T::object_type(), &record.payload)?; + let payload = decode_storage_payload(T::object_type(), &payload)?; let mut message = T::decode(payload.as_slice()) .map_err(|e| PersistenceError::Decode(format!("protobuf decode error: {e}")))?; message.set_resource_version(record.resource_version); Ok(message) } +fn encode_storage_payload(message: &T) -> PersistenceResult> { + let payload = message.encode_to_vec(); + match T::object_type() { + "sandbox" => { + let sandbox = + openshell_core::proto::Sandbox::decode(payload.as_slice()).map_err(|error| { + PersistenceError::Encode(format!("encode sandbox payload failed: {error}")) + })?; + crate::storage_proto::encode_sandbox(&sandbox).map_err(|error| { + PersistenceError::Encode(format!("encode sandbox payload failed: {error}")) + }) + } + "provider_profile" => { + let profile = crate::storage_proto::StoredProviderProfile::decode(payload.as_slice()) + .map_err(|error| { + PersistenceError::Encode(format!("encode provider profile payload failed: {error}")) + })?; + crate::storage_proto::encode_provider_profile(&profile).map_err(|error| { + PersistenceError::Encode(format!("encode provider profile payload failed: {error}")) + }) + } + _ => Ok(payload), + } +} + +fn decode_storage_payload(object_type: &str, payload: &[u8]) -> PersistenceResult> { + match object_type { + "sandbox" => crate::storage_proto::decode_sandbox(payload) + .map(|sandbox| sandbox.encode_to_vec()) + .map_err(|error| { + PersistenceError::Decode(format!("decode sandbox payload failed: {error}")) + }), + "provider_profile" => crate::storage_proto::decode_provider_profile(payload) + .map(|profile| profile.encode_to_vec()) + .map_err(|error| { + PersistenceError::Decode(format!("decode provider profile payload failed: {error}")) + }), + _ => Ok(payload.to_vec()), + } +} + /// Dispatch a method call to the underlying store implementation. /// /// Every `Store` method is a two-arm `match self { Postgres(s) => s.method(...).await, … }` @@ -909,7 +951,7 @@ impl Store { message.object_name(), message.object_workspace(), scope, - &message.encode_to_vec(), + &encode_storage_payload(message)?, labels_json.as_deref(), ) .await @@ -1147,7 +1189,7 @@ impl Store { updated.object_id(), updated.object_name(), updated.object_workspace(), - &updated.encode_to_vec(), + &encode_storage_payload(&updated)?, labels_json.as_deref(), WriteCondition::MatchResourceVersion(cas_version), ) @@ -1283,7 +1325,7 @@ impl Store { message.object_id(), message.object_name(), message.object_workspace(), - &message.encode_to_vec(), + &encode_storage_payload(message)?, labels_json.as_deref(), ) .await diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index c0be94e0c9..34eb607621 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -13,7 +13,6 @@ use crate::policy_store::{ }; use openshell_core::SetResourceVersion; use openshell_core::proto::Sandbox; -use prost::Message; use sqlx::postgres::PgPoolOptions; use sqlx::{Connection, PgPool, Postgres, QueryBuilder, Row}; @@ -1022,7 +1021,11 @@ WHERE object_type = 'sandbox' AND id = $1 AND resource_version = $4 ", ) .bind(&write.sandbox_id) - .bind(sandbox.encode_to_vec()) + .bind( + crate::storage_proto::encode_sandbox(&sandbox).map_err(|error| { + PersistenceError::Encode(format!("encode sandbox payload failed: {error}")) + })?, + ) .bind(now_ms) .bind(i64::try_from(current_version).unwrap_or(i64::MAX)) .execute(&mut *tx) diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index d9f22bcbbe..6ebf768909 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -14,7 +14,6 @@ use crate::policy_store::{ use openshell_core::SetResourceVersion; use openshell_core::paths::set_file_owner_only; use openshell_core::proto::Sandbox; -use prost::Message; use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection, SqlitePoolOptions}; use sqlx::{Connection, QueryBuilder, Row, Sqlite, SqlitePool}; use std::path::{Path, PathBuf}; @@ -1158,7 +1157,11 @@ WHERE "object_type" = 'sandbox' AND "id" = ?1 AND "resource_version" = ?4 "#, ) .bind(&write.sandbox_id) - .bind(sandbox.encode_to_vec()) + .bind( + crate::storage_proto::encode_sandbox(&sandbox).map_err(|error| { + PersistenceError::Encode(format!("encode sandbox payload failed: {error}")) + })?, + ) .bind(now_ms) .bind(i64::try_from(current_version).unwrap_or(i64::MAX)) .execute(&mut *tx) diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index 49fa192094..be8e4580cb 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -1133,7 +1133,7 @@ async fn policy_atomic_write_commits_revision_provenance_and_sandbox_projection( assert_eq!(updated.metadata.as_ref().unwrap().annotations, provenance); assert_eq!( updated.spec.as_ref().unwrap().policy.as_ref(), - Some(&policy) + Some(&openshell_policy::project_base_policy(&policy).unwrap()) ); let revision = store .get_latest_policy("sandbox-atomic") diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index cf6e23ef51..9e44c98ab5 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -91,25 +91,31 @@ pub fn project_policy_revision_onto_sandbox( } let payload = crate::persistence::migrate_legacy_time_fields("sandbox", payload)?; - let mut sandbox = Sandbox::decode(payload.as_slice()) + let mut sandbox = crate::storage_proto::decode_sandbox(payload.as_slice()) .map_err(|e| PersistenceError::Decode(format!("decode sandbox payload failed: {e}")))?; sandbox.set_resource_version(current_resource_version); let mut changed = false; let startup_blocked = permits_initial_static_policy_repair(&sandbox); if let Some(backfill_policy) = write.backfill_policy.as_ref() { + let public_backfill = + openshell_policy::project_base_policy(backfill_policy).map_err(|e| { + PersistenceError::Decode(format!( + "project policy revision onto sandbox failed: {e}" + )) + })?; let spec = sandbox .spec .as_mut() .ok_or_else(|| PersistenceError::Decode("sandbox payload missing spec".to_string()))?; match spec.policy.as_ref() { None => { - spec.policy = Some(backfill_policy.clone()); + spec.policy = Some(public_backfill); changed = true; } - Some(current) if current == backfill_policy => {} + Some(current) if current == &public_backfill => {} Some(_) if startup_blocked => { - spec.policy = Some(backfill_policy.clone()); + spec.policy = Some(public_backfill); changed = true; } Some(_) => { @@ -659,7 +665,7 @@ mod tests { for state in [Admission::Pending, Admission::Rejected, Admission::Accepted] { let sandbox = Sandbox { spec: Some(SandboxSpec { - policy: Some(baseline.clone()), + policy: Some(openshell_policy::project_base_policy(&baseline).unwrap()), ..Default::default() }), status: Some(SandboxStatus { @@ -672,12 +678,16 @@ 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); - assert_eq!(projected.spec.unwrap().policy, Some(replacement.clone())); + assert_eq!( + projected.spec.unwrap().policy, + Some(openshell_policy::project_base_policy(&replacement).unwrap()) + ); } else { assert!( matches!(result, Err(PersistenceError::Conflict { .. })), diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 3137036721..fb8fe06f17 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -552,12 +552,15 @@ fn build_effective_profiles( .iter() .filter(|sp| sp.scope == ProfileScope::Platform) .map(|sp| { - ( - source_id.to_string(), - ProviderTypeProfile::from_proto(&sp.profile), - ) + ProviderTypeProfile::try_from_proto(&sp.profile) + .map(|profile| (source_id.to_string(), profile)) }) - .collect(); + .collect::>() + .map_err(|error| { + Status::failed_precondition(format!( + "provider profile source '{source_id}' is invalid: {error}" + )) + })?; if !platform.is_empty() { validate_source_profiles(source_id, &platform)?; } @@ -566,12 +569,15 @@ fn build_effective_profiles( .iter() .filter(|sp| sp.scope == ProfileScope::Workspace) .map(|sp| { - ( - source_id.to_string(), - ProviderTypeProfile::from_proto(&sp.profile), - ) + ProviderTypeProfile::try_from_proto(&sp.profile) + .map(|profile| (source_id.to_string(), profile)) }) - .collect(); + .collect::>() + .map_err(|error| { + Status::failed_precondition(format!( + "provider profile source '{source_id}' is invalid: {error}" + )) + })?; if !workspace.is_empty() { validate_source_profiles(source_id, &workspace)?; } @@ -580,12 +586,15 @@ fn build_effective_profiles( .profiles .iter() .map(|sp| { - ( - source_id.to_string(), - ProviderTypeProfile::from_proto(&sp.profile), - ) + ProviderTypeProfile::try_from_proto(&sp.profile) + .map(|profile| (source_id.to_string(), profile)) }) - .collect::>(); + .collect::, _>>() + .map_err(|error| { + Status::failed_precondition(format!( + "provider profile source '{source_id}' is invalid: {error}" + )) + })?; validate_source_profiles(source_id, &source_profiles)?; } @@ -913,19 +922,19 @@ mod tests { let mut profile = profile(id); profile .endpoints - .push(openshell_core::proto::NetworkEndpoint { + .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::McpOptions { + mcp: Some(openshell_core::proto::policy::McpConfig { versions: versions .iter() .map(|version| (*version).to_string()) .collect(), ..Default::default() }), - rules: vec![openshell_core::proto::L7Rule { - allow: Some(openshell_core::proto::L7Allow { + rules: vec![openshell_core::proto::policy::L7Rule { + allow: Some(openshell_core::proto::policy::L7Allow { method: "tools/list".to_string(), ..Default::default() }), @@ -939,13 +948,13 @@ mod tests { let mut profile = profile(id); profile .endpoints - .push(openshell_core::proto::NetworkEndpoint { + .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::L7Rule { - allow: Some(openshell_core::proto::L7Allow { + rules: vec![openshell_core::proto::policy::L7Rule { + allow: Some(openshell_core::proto::policy::L7Allow { method: "tools/list".to_string(), ..Default::default() }), @@ -1060,7 +1069,7 @@ mod tests { fn mcp_profile_normalization_preserves_malformed_explicit_evidence() { let mut malformed = profile_with_mcp_versions( "malformed-version-profile", - &["latest", "2025-11-25", "2025-11-25"], + &["2025-11-25", "2025-11-25", "latest"], ); malformed.description = "unrelated source-owned field".to_string(); let original = malformed.clone(); @@ -1089,7 +1098,7 @@ mod tests { assert!( error .message() - .contains("duplicate MCP protocol version '2025-11-25'"), + .contains("duplicate protocol version '2025-11-25'"), "validation must reject the preserved duplicate before the later unsupported alias: {error}" ); } diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index bd3f4790ac..31957b9dd6 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -18,11 +18,156 @@ include!(concat!(env!("OUT_DIR"), "/openshell.storage.v1.rs")); pub(crate) const STORAGE_FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/storage_descriptor.bin")); +use openshell_core::proto::{ + ProviderProfile, ProviderProfileCredential, ProviderProfileDiscovery, ResourceRequirements, + Sandbox, SandboxSpec, SandboxStatus, SandboxTemplate, SandboxWorkloadTemplateProvenance, +}; use openshell_core::{ GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, }; +use prost::Message; use std::collections::HashMap; +pub(crate) fn encode_sandbox(sandbox: &Sandbox) -> Result, String> { + let spec = sandbox + .spec + .as_ref() + .map(|spec| -> Result { + Ok(StoredSandboxSpec { + log_level: spec.log_level.clone(), + environment: spec.environment.clone(), + template: spec.template.clone(), + policy: spec + .policy + .clone() + .map(openshell_policy::lower_authored_policy) + .transpose() + .map_err(|error| error.to_string())?, + providers: spec.providers.clone(), + resource_requirements: spec.resource_requirements.clone(), + command: spec.command.clone(), + tty: spec.tty, + provider_attachment_epoch: spec.provider_attachment_epoch.clone(), + }) + }) + .transpose()?; + + Ok(StoredSandbox { + metadata: sandbox.metadata.clone(), + spec, + status: sandbox.status.clone(), + created_from_workload_template: sandbox.created_from_workload_template.clone(), + } + .encode_to_vec()) +} + +pub(crate) fn decode_sandbox(payload: &[u8]) -> Result { + let stored = StoredSandbox::decode(payload).map_err(|error| error.to_string())?; + let spec = stored + .spec + .map(|spec| -> Result { + Ok(SandboxSpec { + log_level: spec.log_level, + environment: spec.environment, + template: spec.template, + policy: spec + .policy + .as_ref() + .map(openshell_policy::project_base_policy) + .transpose() + .map_err(|error| error.to_string())?, + providers: spec.providers, + resource_requirements: spec.resource_requirements, + command: spec.command, + tty: spec.tty, + provider_attachment_epoch: spec.provider_attachment_epoch, + }) + }) + .transpose()?; + + Ok(Sandbox { + metadata: stored.metadata, + spec, + status: stored.status, + created_from_workload_template: stored.created_from_workload_template, + }) +} + +pub(crate) fn encode_provider_profile(stored: &StoredProviderProfile) -> Result, String> { + let profile = stored + .profile + .as_ref() + .map(|profile| -> Result { + let rule = openshell_policy::lower_authored_rule( + "provider-profile", + openshell_core::proto::policy::NetworkPolicyRule { + name: "provider-profile".to_string(), + endpoints: profile.endpoints.clone(), + binaries: profile.binaries.clone(), + }, + ) + .map_err(|error| error.to_string())?; + Ok(StoredProviderProfileData { + id: profile.id.clone(), + display_name: profile.display_name.clone(), + description: profile.description.clone(), + category: profile.category, + credentials: profile.credentials.clone(), + endpoints: rule.endpoints, + binaries: rule.binaries, + inference_capable: profile.inference_capable, + discovery: profile.discovery.clone(), + resource_version: profile.resource_version, + annotations: profile.annotations.clone(), + source: profile.source.clone(), + scope: profile.scope.clone(), + }) + }) + .transpose()?; + Ok(StoredProviderProfileWire { + metadata: stored.metadata.clone(), + profile, + } + .encode_to_vec()) +} + +pub(crate) fn decode_provider_profile(payload: &[u8]) -> Result { + let stored = StoredProviderProfileWire::decode(payload).map_err(|error| error.to_string())?; + let profile = stored + .profile + .map(|profile| -> Result { + let rule = openshell_policy::project_authored_rule( + "provider-profile", + &openshell_core::proto::NetworkPolicyRule { + name: "provider-profile".to_string(), + endpoints: profile.endpoints, + binaries: profile.binaries, + }, + ) + .map_err(|error| error.to_string())?; + Ok(ProviderProfile { + id: profile.id, + display_name: profile.display_name, + description: profile.description, + category: profile.category, + credentials: profile.credentials, + endpoints: rule.endpoints, + binaries: rule.binaries, + inference_capable: profile.inference_capable, + discovery: profile.discovery, + resource_version: profile.resource_version, + annotations: profile.annotations, + source: profile.source, + scope: profile.scope, + }) + }) + .transpose()?; + Ok(StoredProviderProfile { + metadata: stored.metadata, + profile, + }) +} + impl ObjectId for StoredProviderProfile { fn object_id(&self) -> &str { self.metadata.as_ref().map_or("", |m| m.id.as_str()) @@ -117,13 +262,13 @@ mod tests { use std::collections::{BTreeMap, BTreeSet, VecDeque}; const STORAGE_V1_SCHEMA_SHA256: &str = - "d68401809d8cea445c35233ef32412bbd041cb2ac5acaf368a0d0bf74d2ddf17"; + "1df02ba6a9656566dea0388ba9fbcf84bb56895db7ec97ffa2d44fa0636e4fd4"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "5e0cddacd16cbbc28d0a3b209bd9158f1ad3a0affd3fead80dae66bf08a13d86"; + "3254980f1f6a8503f9c338666ad60e0d901b1c63fabd1ed6fc6f0b362f6d9cfe"; const DURABLE_SCHEMA_SHA256: &str = - "9eeaa29dfba187bff69fb7bc4f9a13a0f1d7be3f7049a38c8f0e20ce77ec7d8b"; + "23c871a4cb4390be6e7d3ba7bfde3284f803927eac72f19a0e52286457edce7d"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "a6e97fdde30c439ffaa03c2952a43033f8ea338fed6b1456ebe2d7d8af14e834"; + "6070da18b3775d62302d78837d22e4888b0e82a1ee638c09906b0d438efde24e"; // A persisted Sandbox without endpoint status retains its lifecycle fields; // the absent repeated field decodes empty and needs no database rewrite. const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964"; @@ -143,7 +288,7 @@ mod tests { // Field 1 is ignored while the remaining durable status fields retain their tags. const PRE_CANONICAL_SANDBOX_REFERENCE_STATUS: &str = "0a0b6c65676163792d6e616d6512056167656e7430023807"; - const STORAGE_MESSAGE_NAMES: [&str; 9] = [ + const STORAGE_MESSAGE_NAMES: [&str; 13] = [ "DraftChunkPayload", "PolicyRevisionPayload", "StoredConfigUpdateOperation", @@ -152,7 +297,11 @@ mod tests { "StoredProviderCredentialRefreshState", "StoredProviderCredentialRefreshStateV2", "StoredProviderProfile", + "StoredProviderProfileData", + "StoredProviderProfileWire", "StoredRefreshMaterialDeletion", + "StoredSandbox", + "StoredSandboxSpec", ]; const PROVIDER_READINESS_RPC_SIGNATURES: [&str; 2] = [ "openshell.v1.OpenShell/GetSandboxProviderStatus|.openshell.v1.GetSandboxProviderStatusRequest|.openshell.v1.GetSandboxProviderStatusResponse|false|false", @@ -170,8 +319,8 @@ mod tests { ".openshell.storage.v1.PolicyRevisionPayload", ".openshell.storage.v1.StoredConfigUpdateOperation", ".openshell.storage.v1.StoredProviderCredentialRefreshStateV2", - ".openshell.storage.v1.StoredProviderProfile", - ".openshell.v1.Sandbox", + ".openshell.storage.v1.StoredProviderProfileWire", + ".openshell.storage.v1.StoredSandbox", ".openshell.v1.SandboxWorkloadTemplate", ".openshell.v1.ServiceEndpoint", ".openshell.v1.SshSession", @@ -575,9 +724,9 @@ mod tests { overlap_hash.as_str(), ), ( - (299, 24), + (327, 24), (92, 19), - (80, 19), + (75, 19), PUBLIC_RPC_SCHEMA_SHA256, DURABLE_SCHEMA_SHA256, PUBLIC_DURABLE_OVERLAP_SHA256 @@ -621,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}; @@ -642,6 +844,179 @@ mod tests { assert!(status.endpoint_statuses.is_empty()); } + #[test] + fn sandbox_storage_keeps_policy_internal_and_projects_on_read() { + let policy = openshell_policy::parse_authored_policy( + r#" +version: 1 +network_policies: + mcp: + name: mcp + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + enforcement: enforce + rules: + - allow: + method: tools/call + tool: { glob: read_status } + binaries: + - path: /usr/bin/python3 +"#, + ) + .unwrap(); + let sandbox = Sandbox { + spec: Some(SandboxSpec { + policy: Some(policy.clone()), + ..Default::default() + }), + ..Default::default() + }; + + let payload = encode_sandbox(&sandbox).unwrap(); + 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( + r#" +version: 1 +network_policies: + profile: + name: profile + endpoints: + - host: mcp.example.com + ports: [443] + protocol: mcp + rules: + - allow: + method: tools/call + tool: { glob: read_status } + binaries: + - path: /usr/bin/python3 +"#, + ) + .unwrap(); + let authored_rule = policy.network_policies["profile"].clone(); + let stored = StoredProviderProfile { + profile: Some(ProviderProfile { + id: "profile".to_string(), + display_name: "Profile".to_string(), + endpoints: authored_rule.endpoints.clone(), + binaries: authored_rule.binaries.clone(), + ..Default::default() + }), + ..Default::default() + }; + + let payload = encode_provider_profile(&stored).unwrap(); + let wire = StoredProviderProfileWire::decode(payload.as_slice()).unwrap(); + let internal = wire.profile.unwrap(); + assert!(internal.endpoints[0].mcp.is_some()); + + let decoded = decode_provider_profile(&payload).unwrap(); + let profile = decoded.profile.unwrap(); + let decoded_rule = openshell_core::proto::policy::NetworkPolicyRule { + name: authored_rule.name.clone(), + endpoints: profile.endpoints, + binaries: profile.binaries, + }; + assert_eq!( + openshell_policy::lower_authored_rule("profile", decoded_rule).unwrap(), + openshell_policy::lower_authored_rule("profile", authored_rule).unwrap() + ); + } + + #[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( @@ -669,7 +1044,7 @@ mod tests { assert_eq!(deletion.material_key, "old"); assert_eq!(deletion.handle.expect("handle").driver, "test"); - let profile = StoredProviderProfile::decode(legacy_bytes(V0_0_116_PROFILE).as_slice()) + let profile = decode_provider_profile(legacy_bytes(V0_0_116_PROFILE).as_slice()) .expect("legacy provider profile must decode"); let profile = profile.profile.expect("profile"); assert_eq!(profile.id, "profile"); diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 14766c7df8..d11fe5747e 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 @@ -3487,7 +3487,7 @@ network_policies: rules: - allow: method: tools/call - tool: read_status + tool: { glob: read_status } binaries: - { path: /usr/bin/curl } "#; @@ -3649,10 +3649,13 @@ network_policies: "GET" }; let path = if protocol == "rest" { "path: /**" } else { "" }; - let deny_matcher = if protocol == "rest" { - "\"read_sec*\"" + let (allow_matcher, deny_matcher) = if protocol == "rest" { + ("{glob: \"read_*\"}", "{glob: \"read_sec*\"}") } else { - "{any: [read_secret, read_private]}" + ( + "{matcher: {glob: \"read_*\"}}", + "{matcher: {any: {values: [read_secret, read_private]}}}", + ) }; let source = format!( r#" @@ -3662,14 +3665,14 @@ network_policies: name: matchers endpoints: - host: matchers.parity.test - port: 443 + ports: [443] protocol: {protocol} enforcement: enforce rules: - allow: method: {method} {path} - {selector}: {{name: "read_*"}} + {selector}: {{name: {allow_matcher}}} deny_rules: - method: {method} {path} @@ -3689,11 +3692,14 @@ network_policies: endpoint.advisor_proposed = true; let proto = openshell_policy::validate_and_canonicalize_sandbox_policy(proto) .expect("runtime provenance fixture must canonicalize"); - let mut data: serde_json::Value = serde_yml::from_str(&source).unwrap(); + let mut data: serde_json::Value = + serde_json::from_str(&proto_to_opa_data_json(&proto, 0)).unwrap(); let endpoint = &mut data["network_policies"]["matchers"]["endpoints"][0]; + endpoint.as_object_mut().unwrap().remove("endpoint_id"); + endpoint.as_object_mut().unwrap().remove("policy_hash"); endpoint["provider_credentialed"] = true.into(); endpoint["advisor_proposed"] = true.into(); - // Versionless data and runtime provenance are accepted OPA inputs. + // Versionless internal data and runtime provenance are accepted OPA inputs. data.as_object_mut().unwrap().remove("version"); let yaml_engine = OpaEngine::from_strings(TEST_POLICY, &data.to_string()).unwrap(); let proto_engine = OpaEngine::from_proto(&proto).unwrap(); @@ -3771,7 +3777,7 @@ network_policies: name: parity endpoints: - host: sql-l4.parity.test - port: 443 + ports: [443] protocol: "{protocol}" {fields} binaries: @@ -8735,7 +8741,7 @@ network_policies: name: native_tcp endpoints: - host: database.example.com - port: 5432 + ports: [5432] protocol: tcp binaries: - path: /usr/bin/client @@ -8783,7 +8789,7 @@ network_policies: name: native_tcp endpoints: - host: database.example.com - port: 5432 + ports: [5432] protocol: tcp credential_binding: provider: database @@ -10971,7 +10977,7 @@ network_policies: grant: endpoints: - host: example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce rules: [{{ allow: {{ method: GET, path: "/**" }} }}] @@ -10979,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 5afa4597ff..bf287177a1 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 @@ -770,9 +778,15 @@ async fn proposal_wait_response( tokio::time::Instant::now() + RELOAD_WAIT_MIN_FLOOR, ); match chunk.proposed_rule.as_ref() { - Some(rule) => { - wait_for_local_policy_to_cover(ctx, rule, reload_deadline).await - } + Some(rule) => match openshell_policy::lower_authored_rule( + &chunk.rule_name, + rule.clone(), + ) { + Ok(rule) => { + wait_for_local_policy_to_cover(ctx, &rule, reload_deadline).await + } + Err(_) => false, + }, None => false, } } else { @@ -1068,11 +1082,14 @@ fn policy_chunk_from_add_rule( .map(|binary| binary.path.clone()) .unwrap_or_default(); + let proposed_rule = openshell_policy::project_authored_rule(&rule_name, &rule) + .map_err(|error| format!("failed to project proposed rule: {error}"))?; + Ok(PolicyChunk { id: String::new(), status: "pending".to_string(), rule_name, - proposed_rule: Some(rule), + proposed_rule: Some(proposed_rule), rationale: intent_summary.to_string(), security_notes: String::new(), confidence: 0.75, @@ -1132,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 @@ -1355,8 +1369,6 @@ struct NetworkPolicyRuleJson { struct NetworkEndpointJson { host: String, #[serde(default)] - port: u32, - #[serde(default)] ports: Vec, #[serde(default)] protocol: String, @@ -1427,7 +1439,7 @@ mod tests { "endpoints": [ { "host": "api.github.com", - "port": 443, + "ports": [443], "protocol": "rest", "tls": "terminate", "enforcement": "enforce", @@ -1461,10 +1473,9 @@ 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_eq!(rule.endpoints[0].ports, vec![443]); assert_eq!(rule.endpoints[0].protocol, "rest"); - assert!(rule.endpoints[0].advisor_proposed); + assert!(openshell_policy::lower_authored_rule(&chunks[0].rule_name, rule.clone()).is_ok()); assert_eq!(rule.binaries[0].path, "/usr/bin/gh"); assert_eq!( rule.endpoints[0].rules[0].allow.as_ref().unwrap().path, @@ -1483,7 +1494,7 @@ mod tests { "endpoints": [ { "host": "api.github.com", - "port": 443, + "ports": [443], "rules": [ { "allow": { @@ -1508,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#"{{ @@ -1534,7 +1545,7 @@ mod tests { "addRule": { "ruleName": "explicit_proxy", "rule": { - "endpoints": [{"host":"api.example.com","port":443}] + "endpoints": [{"host":"api.example.com","ports":[443]}] } } }] @@ -1543,10 +1554,7 @@ mod tests { let chunks = proposal_chunks_from_body(body).unwrap(); let endpoint = &chunks[0].proposed_rule.as_ref().unwrap().endpoints[0]; assert!(endpoint.protocol.is_empty()); - assert_eq!( - endpoint.tls, - openshell_core::proto::NetworkTlsMode::Unspecified as i32 - ); + assert!(endpoint.tls.is_empty()); } #[test] @@ -1997,28 +2005,34 @@ mod tests { id: "ignored".to_string(), rule_name: "github_write".to_string(), binary: "/usr/bin/curl".to_string(), - proposed_rule: Some(NetworkPolicyRule { - name: "github_write".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - rules: vec![L7Rule { - allow: Some(L7Allow { - method: "PUT".to_string(), - path: "/repos/foo/bar/contents/x.md".to_string(), + proposed_rule: Some( + openshell_policy::project_authored_rule( + "github_write", + &NetworkPolicyRule { + name: "github_write".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "PUT".to_string(), + path: "/repos/foo/bar/contents/x.md".to_string(), + ..Default::default() + }), + }], ..Default::default() - }), - }], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - }], - }), + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + }], + }, + ) + .unwrap(), + ), ..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 4a48c0704a..874082ad7b 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::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::SandboxPolicy, + policy: &openshell_core::proto::policy::PolicyDocument, workspace: &str, ) -> Result { openshell_core::grpc_client::sync_policy_and_fetch_snapshot( @@ -2397,8 +2397,9 @@ async fn load_policy_with_gateway( // Sync and re-fetch over a single connection to avoid extra // TLS handshakes. let ws = snapshot.workspace.clone(); + let authored_discovered = openshell_policy::project_base_policy(&discovered)?; snapshot = grpc_retry("Image policy synchronization", || { - gateway.sync(sandbox, &discovered, &ws) + gateway.sync(sandbox, &authored_discovered, &ws) }) .await?; if let Some(policy) = snapshot.policy.clone() { @@ -2429,8 +2430,9 @@ async fn load_policy_with_gateway( let enriched = enrich_proto_baseline_paths(&mut proto_policy); let sync_policy = proto_sync_payload_for_enriched_policy(&proto_policy, enriched); if let Some(sync_policy) = sync_policy { + let authored_sync_policy = openshell_policy::project_base_policy(&sync_policy)?; let canonical = grpc_retry("Enriched policy synchronization", || { - gateway.sync(sandbox, &sync_policy, &snapshot.workspace) + gateway.sync(sandbox, &authored_sync_policy, &snapshot.workspace) }) .await?; proto_policy = canonical.policy.clone().ok_or_else(|| { @@ -5108,7 +5110,7 @@ network_policies: test: name: test endpoints: - - { host: example.com, port: 443 } + - { host: example.com, ports: [443] } binaries: - { path: /usr/bin/curl } "#, @@ -5199,7 +5201,7 @@ network_policies: name: redis endpoints: - host: redis.example.com - port: 6379 + ports: [6379] protocol: tcp binaries: - path: /usr/bin/redis-cli @@ -5237,6 +5239,7 @@ network_policies: struct TestStartupGateway { desired: Arc>, reports: UnboundedSender, + reported_errors: Arc>>, reject_next_accept: Arc, snapshot_error: Option, report_error: Option, @@ -5271,7 +5274,7 @@ network_policies: async fn sync( &self, _sandbox: &str, - _policy: &openshell_core::proto::SandboxPolicy, + _policy: &openshell_core::proto::policy::PolicyDocument, _workspace: &str, ) -> Result { self.snapshot("").await @@ -5282,7 +5285,7 @@ network_policies: _instance_id: &str, snapshot: Option<&openshell_core::grpc_client::SettingsPollResult>, state: openshell_core::proto::ConfigurationAdmissionState, - _error: &str, + error: &str, ) -> Result<()> { use openshell_core::proto::ConfigurationAdmissionState; if let Some(code) = self.report_error { @@ -5291,6 +5294,7 @@ network_policies: )); } self.reports.send(state).unwrap(); + self.reported_errors.lock().unwrap().push(error.to_string()); if state == ConfigurationAdmissionState::Accepted { if self.pending_acceptance { return std::future::pending().await; @@ -5347,6 +5351,7 @@ network_policies: openshell_core::proto::PolicySource::Sandbox, ))), reports, + reported_errors: Arc::new(std::sync::Mutex::new(Vec::new())), reject_next_accept: Arc::new(AtomicBool::new(false)), snapshot_error: None, report_error: None, @@ -5423,6 +5428,7 @@ network_policies: openshell_core::proto::PolicySource::Sandbox, ))), reports, + reported_errors: Arc::new(std::sync::Mutex::new(Vec::new())), reject_next_accept: Arc::new(AtomicBool::new(false)), snapshot_error, report_error, @@ -5457,6 +5463,81 @@ network_policies: } } + #[tokio::test(start_paused = true)] + async fn invalid_baked_policy_blocks_startup_until_gateway_policy_is_repaired() { + use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; + + let (reports, mut reported) = tokio::sync::mpsc::unbounded_channel(); + let reported_errors = Arc::new(std::sync::Mutex::new(Vec::new())); + let gateway = TestStartupGateway { + desired: Arc::new(std::sync::Mutex::new(settings_poll_result( + None, + 0, + PolicySource::Sandbox, + ))), + reports, + reported_errors: reported_errors.clone(), + reject_next_accept: Arc::new(AtomicBool::new(false)), + snapshot_error: None, + report_error: None, + pending_snapshot: false, + pending_acceptance: false, + }; + let active_gateway = gateway.clone(); + let handle = tokio::spawn(async move { + load_policy_with_gateway( + Some("sandbox-id".to_string()), + Some("sandbox".to_string()), + Some("http://unused.invalid".to_string()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + Some(ImagePolicyDiscovery::Invalid), + &active_gateway, + ) + .await + }); + + assert_eq!( + reported.recv().await, + Some(ConfigurationAdmissionState::Pending) + ); + assert_eq!( + reported.recv().await, + Some(ConfigurationAdmissionState::Rejected) + ); + assert!( + !handle.is_finished(), + "invalid image policy must block launch" + ); + assert_eq!( + reported_errors.lock().unwrap().last().map(String::as_str), + Some("Image policy is invalid; replace the sandbox policy to repair configuration") + ); + + let mut repaired = proto_policy_fixture(); + enrich_proto_baseline_paths(&mut repaired); + { + let mut desired = gateway.desired.lock().unwrap(); + desired.policy = Some(repaired); + desired.version = 1; + desired.policy_hash = "hash-v1".to_string(); + desired.config_revision = 100; + desired.configuration_admitted = true; + } + + assert_eq!( + reported.recv().await, + Some(ConfigurationAdmissionState::Accepted) + ); + let bundle = handle + .await + .expect("startup task must not panic") + .expect("repaired gateway policy must unblock startup"); + assert!(bundle.2.is_some(), "accepted bundle must retain the policy"); + } + #[tokio::test] async fn startup_waits_for_repair_and_retries_stale_activation_before_returning() { use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; @@ -5468,6 +5549,7 @@ network_policies: let gateway = TestStartupGateway { desired: Arc::new(std::sync::Mutex::new(rejected)), reports, + reported_errors: Arc::new(std::sync::Mutex::new(Vec::new())), reject_next_accept: Arc::new(AtomicBool::new(true)), snapshot_error: None, report_error: None, diff --git a/crates/openshell-supervisor/src/mechanistic_mapper.rs b/crates/openshell-supervisor/src/mechanistic_mapper.rs index 2556868725..7f806bdfa0 100644 --- a/crates/openshell-supervisor/src/mechanistic_mapper.rs +++ b/crates/openshell-supervisor/src/mechanistic_mapper.rs @@ -209,6 +209,8 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { .first() .map_or_else(|| "connect".to_string(), |d| d.denial_stage.clone()); + let proposed_rule = openshell_policy::project_authored_rule(&rule_name, &proposed_rule) + .expect("mechanistically generated rule must project to the public policy schema"); proposals.push(PolicyChunk { id: String::new(), // Assigned by the gateway on persist status: "pending".to_string(), @@ -532,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. @@ -589,11 +591,8 @@ mod tests { // L7 fields should be set. assert_eq!(ep.protocol, "rest"); // tls field is no longer set (auto-detection handles it). - assert_eq!( - ep.tls, - openshell_core::proto::NetworkTlsMode::Unspecified as i32 - ); - assert_eq!(ep.enforcement, NetworkEnforcementMode::Enforce as i32); + assert!(ep.tls.is_empty()); + assert_eq!(ep.enforcement, "enforce"); // Should have L7 rules. assert!(!ep.rules.is_empty()); diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 7a53343142..f40c60f278 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -3352,25 +3352,27 @@ impl App { } else { endpoint.protocol.as_str() }; - let access = if endpoint.access == 0 { + let access = if endpoint.access.is_empty() { if endpoint.rules.is_empty() { "custom" } else { "rules" } } else { - openshell_policy::network_access_preset_to_str(endpoint.access) - .unwrap_or("unknown") + endpoint.access.as_str() }; let path = if endpoint.path.is_empty() { String::new() } 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/lib.rs b/crates/openshell-tui/src/lib.rs index 1cb2b02232..53c09d6ba8 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1438,7 +1438,12 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { // to "sandbox". For the default image, let the server apply the // sandbox's own default policy. let policy = if has_custom_image { - Some(openshell_policy::restrictive_default_policy()) + Some( + openshell_policy::project_base_policy( + &openshell_policy::restrictive_default_policy(), + ) + .expect("restrictive default policy must project to the public schema"), + ) } else { None }; diff --git a/crates/openshell-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs index df764744c9..025e548a77 100644 --- a/crates/openshell-tui/src/ui/sandbox_draft.rs +++ b/crates/openshell-tui/src/ui/sandbox_draft.rs @@ -4,7 +4,8 @@ //! Network rules panel for the sandbox screen. use crate::app::App; -use openshell_core::proto::{L7Allow, L7DenyRule, L7QueryMatcher, NetworkEndpoint, PolicyChunk}; +use openshell_core::proto::PolicyChunk; +use openshell_core::proto::policy::{L7Allow, L7DenyRule, Matcher, NetworkEndpoint, matcher}; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; @@ -823,18 +824,21 @@ 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()]; - if endpoint.access != 0 { - tags.push(format!( - "access={}", - openshell_policy::network_access_preset_to_str(endpoint.access).unwrap_or("unknown") - )); + if !endpoint.access.is_empty() { + tags.push(format!("access={}", endpoint.access)); } for rule in &endpoint.rules { if let Some(allow) = &rule.allow { @@ -854,18 +858,11 @@ fn format_endpoint_details(endpoint: &NetworkEndpoint) -> Vec { if !endpoint.path.is_empty() { details.push(format!("Path scope: {}", endpoint.path)); } - if endpoint.tls != 0 { - details.push(format!( - "TLS: {}", - openshell_policy::network_tls_mode_to_str(endpoint.tls).unwrap_or("unknown") - )); + if !endpoint.tls.is_empty() { + details.push(format!("TLS: {}", endpoint.tls)); } - if endpoint.enforcement != 0 { - details.push(format!( - "Enforcement: {}", - openshell_policy::network_enforcement_mode_to_str(endpoint.enforcement) - .unwrap_or("unknown") - )); + if !endpoint.enforcement.is_empty() { + details.push(format!("Enforcement: {}", endpoint.enforcement)); } if endpoint.request_body_credential_rewrite { details.push("Request body credential rewrite".to_string()); @@ -957,7 +954,7 @@ fn format_deny_rule(deny: &L7DenyRule) -> String { fn append_query_matchers( parts: &mut Vec, - query: &std::collections::HashMap, + query: &std::collections::HashMap, ) { if query.is_empty() { return; @@ -966,12 +963,14 @@ fn append_query_matchers( entries.sort_by_key(|(key, _)| *key); let formatted = entries .into_iter() - .map(|(key, matcher)| { - if matcher.any.is_empty() { - format!("{key}={}", non_empty_or(&matcher.glob, "*")) - } else { - format!("{key} in [{}]", matcher.any.join(",")) + .map(|(key, matcher)| match matcher.kind.as_ref() { + Some(matcher::Kind::Glob(glob)) => { + format!("{key}={}", non_empty_or(glob, "*")) + } + Some(matcher::Kind::Any(any)) => { + format!("{key} in [{}]", any.values.join(",")) } + None => format!("{key}=*"), }) .collect::>() .join(", "); diff --git a/docs/get-started/tutorials/first-network-policy.mdx b/docs/get-started/tutorials/first-network-policy.mdx index cbf9eaaf92..9d1d926ac5 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 e420bb697a..53693196d9 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 c23e6eaf6c..519379487f 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 4f7ffc7aed..87e1497acd 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 a07bfabdbd..4276897286 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 101d796e5c..3e37aa2edc 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 9168be25e3..d73fc7a98c 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 d892997202..f66c89c7b5 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 5e03c8a927..16c0168eba 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -36,7 +36,14 @@ The YAML root and each present top-level section listed as an object or map must Static fields are set at sandbox creation time. Changing them requires destroying and recreating the sandbox. Dynamic fields can be updated on a running sandbox with `openshell policy update` for incremental merges or `openshell policy set` for full replacement, and take effect without restarting. -For query parameters and MCP tool names, the OPA loader normalizes nonempty string matchers to explicit `glob` objects after validation. For example, `query: { status: "active*" }` becomes `query: { status: { glob: "active*" } }` in runtime endpoint data. The same normalization applies to allow and deny rules and to MCP `tool` aliases. Matchers supported by both YAML and protobuf use the same runtime representation; `any` matchers retain their listed values. Raw OPA data also accepts empty scalar query matchers, which match empty query values and remain unchanged during loading. An empty protobuf glob cannot represent that OPA-only matcher. +Policy files use the public protobuf message shape. Query and MCP tool matchers +must select a `Matcher` oneof with `glob` or `any`. For example, write +`query: { status: { glob: "active*" } }` rather than the legacy scalar +`query: { status: "active*" }`. An `any` matcher is +`{ any: { values: ["open", "pending"] } }`. MCP `params` entries use the +`ParameterMatcher` shape, such as +`name: { matcher: { glob: "search_*" } }`. Scalar matchers, the old +`{ any: [...] }` form, and unwrapped nested parameter objects are rejected. ## Parsing and representation @@ -47,7 +54,21 @@ models. The parser requires `version: 1`, rejects duplicate mapping keys and YAML merge keys, and accepts one document of at most 4 MiB. It limits nesting to 64 levels, total AST nodes to 100,000, parser events to 300,000, cumulative scalar data to 4 MiB, alias expansions to 100 with a 5:1 alias-to-anchor ratio, -and each mapping or sequence to 10,000 entries. +and each mapping or sequence to 10,000 entries. Every mapping key must be a +string. Quote keys such as `"1"`, `"true"`, or `"null"` when those exact +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. + +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 +replacement, global policy replacement, and incremental merges. The gateway +returns `INVALID_ARGUMENT` and does not persist a candidate that exceeds it. Unknown keys in closed schema objects are rejected with their field path before the policy is converted or analyzed. The following maps are intentionally @@ -57,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 @@ -200,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 @@ -209,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. | @@ -230,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 this key to use all MCP endpoint defaults, including the exact `2025-11-25` revision; `mcp: null` is invalid. 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 | Nonempty allowlist of exact supported MCP core revisions: `2025-03-26`, `2025-06-18`, and `2025-11-25`. Omission resolves to the exact allowlist `["2025-11-25"]`; it never means latest or all known revisions. The key must be absent to use this default; `versions: null` and `versions: []` are invalid. 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. For an unsupported revision, omit `protocol` and `mcp` only when deliberate uninspected L4 passthrough is an acceptable weaker boundary. | +| `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:** @@ -260,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`. -- YAML defaulting requires the `mcp` or `mcp.versions` key to be absent. Explicit `mcp: null`, `versions: null`, and `versions: []` values are rejected. At protobuf ingress, an empty repeated `versions` field means omission and resolves to `["2025-11-25"]` 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. @@ -288,7 +304,7 @@ network_policies: gcp_storage: endpoints: - host: storage.googleapis.com - port: 443 + ports: [443] protocol: rest access: full credential_binding: @@ -319,7 +335,7 @@ REST allow rules match HTTP requests by method, path, and optional query paramet |---|---|---|---| | `method` | string | Yes | HTTP method to allow (for example, `GET`, `POST`). `*` matches any method. | | `path` | string | Yes | URL path glob. `*` and `**` match zero or more characters and may cross `/`; `?` matches one character; bracket classes such as `[0-9]` and `[!0]` are supported. | -| `query` | map | No | Query parameter matchers keyed by decoded param name. Matcher value can be a glob string (`tag: "foo-*"`) or an object with `any` (`tag: { any: ["foo-*", "bar-*"] }`). | +| `query` | map | No | Query parameter matchers keyed by decoded param name. Use the protobuf oneof shape: `tag: { glob: "foo-*" }` or `tag: { any: { values: ["foo-*", "bar-*"] } }`. | Example REST allow rules: @@ -329,13 +345,14 @@ rules: method: GET path: /**/info/refs* query: - service: "git-*" + service: { glob: "git-*" } - allow: method: POST path: /**/git-upload-pack query: tag: - any: ["v1.*", "v2.*"] + any: + values: ["v1.*", "v2.*"] ``` ##### WebSocket Allow Rule (`protocol: websocket`) @@ -409,15 +426,15 @@ Use `rules` for MCP allow rules and `deny_rules` for MCP deny rules. Deny rules | Field | Type | Required | Description | |---|---|---|---| | `method` | string | No | MCP method name, such as `initialize`, `tools/list`, `tools/call`, or an unknown extension method. Globs are accepted only for the `tools/` method family, such as `tools/*`. Required unless `mcp.allow_all_known_mcp_methods` is `true`; when that option is true, omitted method uses the endpoint method profile. Do not use `method: "*"` for MCP; omit `method` only when using the allow-all MCP method profile. | -| `tool` | string or matcher | No | Convenience matcher for `tools/call` `params.name`. Supports a glob string or `{ any: [...] }`. Requires `method: tools/call` unless `mcp.allow_all_known_mcp_methods` is `true`; validation fails otherwise. Omit to match every tool. | -| `params` | map | No | MCP currently accepts only `params.name` as a lower-level tool-name matcher. Omit this field when using only `tool`; an explicit `params: null` is invalid in allow and deny rules. Requires `method: tools/call` unless `mcp.allow_all_known_mcp_methods` is `true`; validation fails otherwise. Tool argument matching is not supported yet; allowed tools accept all argument payloads by default. | +| `tool` | matcher | No | Matcher for `tools/call` `params.name`. Use `{ glob: "name" }` or `{ any: { values: [...] } }`. Requires `method: tools/call` unless `mcp.allow_all_known_mcp_methods` is `true`; validation fails otherwise. Omit to match every tool. | +| `params` | map | No | MCP currently accepts only `params.name` as a lower-level tool-name matcher. Use the `ParameterMatcher` shape, for example `name: { matcher: { glob: "search_*" } }`. Requires `method: tools/call` unless `mcp.allow_all_known_mcp_methods` is `true`; validation fails otherwise. Tool argument matching is not supported yet; allowed tools accept all argument payloads by default. | An MCP client first sends `initialize`. After the server returns a successful response, the client sends `notifications/initialized`. After initialization completes and the server advertises the `tools` capability, the client can call an advertised tool. The response does not need an allow rule because these rules inspect messages sent from the client to the server. This example adds both client initialization messages to the existing tool rules. It omits `tools/list` because it assumes the client already knows the tool names; add that method when the client performs discovery. ```yaml showLineNumbers={false} endpoints: - host: mcp.example.com - port: 443 + ports: [443] path: /mcp protocol: mcp enforcement: enforce @@ -430,16 +447,17 @@ endpoints: method: notifications/initialized - allow: method: tools/call - tool: search_web + tool: { glob: search_web } - allow: method: tools/call tool: - any: [create_issue, list_issues] + any: + values: [create_issue, list_issues] deny_rules: - method: tools/call - tool: send_email + tool: { glob: send_email } - method: tools/call - tool: execute_code + tool: { glob: execute_code } ``` This example omits `mcp.versions`, so OpenShell materializes `["2025-11-25"]`. To authorize an intentional compatibility range for an older server, set an explicit nonempty allowlist: @@ -466,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 @@ -500,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 @@ -528,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 @@ -552,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 @@ -578,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 @@ -640,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 @@ -652,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 b0234e9686..9df23b92b5 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 a8b743b5cd..5b542df4d7 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -10,6 +10,12 @@ position: 6 Use this page to apply and iterate policy changes on running sandboxes. For a full field-by-field YAML definition, use the [Policy Schema Reference](/reference/policy-schema). +Policy files use the public protobuf field shape directly. Legacy scalar +matcher shorthands are not accepted. Write matchers as messages, for example +`tool: { glob: read_status }` or `tool: { any: { values: [a, b] } }`. +The local YAML parser accepts at most 4 MiB. The gateway then limits the +canonical protobuf policy to 256 KiB after applying defaults and normalization. + ## Policy Structure A policy has static sections `filesystem_policy`, `landlock`, and `process` that are locked at sandbox creation, and dynamic `network_policies` and `network_middlewares` sections that are hot-reloadable on a running sandbox. @@ -38,7 +44,7 @@ network_policies: name: my-api endpoints: - host: api.example.com - port: 443 + ports: [443] protocol: rest enforcement: enforce access: full @@ -147,7 +153,7 @@ network_policies: name: postgres endpoints: - host: db.internal.example - port: 5432 + ports: [5432] protocol: tcp binaries: - path: /usr/bin/psql @@ -365,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. | @@ -491,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. @@ -627,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. @@ -724,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 } @@ -748,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 @@ -772,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 @@ -804,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: @@ -812,9 +817,10 @@ REST rules can also constrain query parameter values: method: GET path: "/api/v1/download" query: - slug: "skill-*" + slug: { glob: "skill-*" } version: - any: ["1.*", "2.*"] + any: + values: ["1.*", "2.*"] binaries: - { path: /usr/bin/curl } ``` @@ -823,7 +829,7 @@ REST rules can also constrain query parameter values: ### MCP and JSON-RPC matching -MCP endpoints use `protocol: mcp`. The proxy parses sandbox-to-server MCP Streamable HTTP request bodies, validates known MCP request and notification params, can evaluate the MCP method against rule `method`, and can match tool calls with the `tool` alias. Unknown extension methods stay addressable as literal method strings. You may omit the entire `mcp` stanza when using its defaults, or omit only `mcp.versions` when setting another MCP option. OpenShell immediately resolves either form to the exact `["2025-11-25"]` allowlist and stores the materialized list in canonical policy data. Adding another supported revision therefore never widens a normalized policy. Defaulting requires the `mcp` or `mcp.versions` key to be absent; explicit `mcp: null`, `versions: null`, and `versions: []` values are invalid. Use an explicit nonempty allowlist only for intentional compatibility or downgrade control. Supported revisions are `2025-03-26`, `2025-06-18`, and `2025-11-25`; the exact support floor is `2025-03-26`, and this is a closed set rather than a date range. Explicit values must be unique and contain no extra whitespace; OpenShell stores them in semantic order. Moving aliases such as `draft` or `latest` are rejected because they could change policy meaning without a policy edit; omission never means all known versions. The sessionless `2026-07-28` revision is not accepted until OpenShell supports its distinct per-request contract. A version identifies a core MCP revision only; there is no policy syntax for separately named SEP overlays. For every MCP HTTP 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 the allowlist. Duplicate, empty, and unsupported header values receive `400 Bad Request`; a supported revision outside the allowlist receives `403 Forbidden`. OpenShell checks the request again after middleware changes it and before forwarding. Generic JSON-RPC endpoints do not use this header and continue to evaluate only `method`. `mcp.allow_all_known_mcp_methods` defaults to `false`, so endpoints require explicit MCP method rules. Set it to `true` to enable the endpoint method profile; in that mode, rules can omit `method`, and tool selectors are normalized to `tools/call` internally. By default, MCP `tools/call` tool names must match `^[A-Za-z0-9_.-]{1,128}$`; set `mcp.strict_tool_names: false` on that endpoint only when a server intentionally uses names outside the MCP-recommended pattern. Wildcard `tool` matchers require `mcp.strict_tool_names` to remain enabled. +MCP endpoints use `protocol: mcp`. The proxy parses sandbox-to-server MCP Streamable HTTP request bodies, validates known MCP request and notification params, evaluates `method`, and can match `tools/call` names with `tool`. Omitted, `null`, and empty `mcp.versions` values resolve to the exact `["2025-11-25"]` allowlist because policy YAML follows protobuf presence semantics. Use a nonempty list only for intentional compatibility or downgrade control. Supported revisions are `2025-03-26`, `2025-06-18`, and `2025-11-25`; values must be unique and contain no extra whitespace. For every MCP HTTP request except a valid standalone `initialize`, OpenShell selects the revision from one `MCP-Protocol-Version` header or the MCP specification's `2025-03-26` compatibility fallback. The selected revision must appear in the allowlist. `mcp.allow_all_known_mcp_methods` defaults to `false`, and `mcp.strict_tool_names` defaults to `true`. The current registry declares these profile facts for later runtime enforcement: @@ -848,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 @@ -859,14 +865,15 @@ An MCP client first sends `initialize`. After the server returns a successful re method: notifications/initialized - allow: method: tools/call - tool: read_status + tool: { glob: read_status } - allow: method: tools/call tool: - any: [submit_report, list_reports] + any: + values: [submit_report, list_reports] deny_rules: - method: tools/call - tool: delete_resource + tool: { glob: delete_resource } binaries: - { path: /usr/bin/python3 } ``` @@ -884,7 +891,7 @@ Use `protocol: json-rpc` and `method` when you need generic JSON-RPC 2.0 matchin Generic JSON-RPC policy `params` matchers are not supported. Generic JSON-RPC policy rules match only the JSON-RPC method. For batch requests, OpenShell evaluates each JSON-RPC call independently and denies the whole batch if any call is denied. -For MCP, `tool` accepts a string glob or `{ any: [...] }` matcher for `tools/call` `params.name`. Rules that use `tool` or lower-level `params.name` must set `method: tools/call` unless `mcp.allow_all_known_mcp_methods: true` enables the endpoint method profile. MCP method globs are accepted only for the `tools/` method family, such as `tools/*`; omit `method` instead of writing `method: "*"` only when the endpoint method profile should allow all MCP methods. Omit `tool` to allow all tools for a `tools/call` method rule. OpenShell does not support MCP tool argument matching yet; allowed tools accept all argument payloads by default. Other MCP `params` keys are rejected. For batch requests, OpenShell evaluates each JSON-RPC call independently and denies the whole batch if any call is denied. +For MCP, `tool` uses the protobuf matcher shape: `{ glob: "name" }` or `{ any: { values: [...] } }`. Rules that use `tool` or `params.name` must set `method: tools/call` unless `mcp.allow_all_known_mcp_methods: true` enables the endpoint method profile. Omit `tool` to allow all tools for a `tools/call` method rule. For batch requests, OpenShell evaluates each JSON-RPC call independently and denies the whole batch if any call is denied. ### GraphQL matching @@ -897,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 @@ -929,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/configs/policy-document-default.yaml b/e2e/configs/policy-document-default.yaml new file mode 100644 index 0000000000..466e640425 --- /dev/null +++ b/e2e/configs/policy-document-default.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Minimal explicit policy for backend lifecycle suites that cannot consume the +# locally migrated community-image fixture. It retains only the community +# image's filesystem, Landlock, and process baseline; tests that need network +# access pass their own purpose-built policies. +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox diff --git a/e2e/docker/Dockerfile.external-kubernetes-gateway b/e2e/docker/Dockerfile.external-kubernetes-gateway index 20738a512a..6a37e66c2b 100644 --- a/e2e/docker/Dockerfile.external-kubernetes-gateway +++ b/e2e/docker/Dockerfile.external-kubernetes-gateway @@ -9,6 +9,8 @@ FROM ${GATEWAY_BASE_IMAGE} ARG TARGETARCH ARG SUPERVISOR_IMAGE=ghcr.io/nvidia/openshell/supervisor:latest ARG SANDBOX_RUNTIME_IMAGE=ghcr.io/nvidia/openshell/sandbox:latest +ARG SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +ARG SANDBOX_IMAGE_PULL_POLICY=if_not_present COPY deploy/docker/.build/prebuilt-binaries/${TARGETARCH}/openshell-gateway /usr/local/bin/openshell-gateway COPY deploy/docker/.build/prebuilt-binaries/${TARGETARCH}/openshell-driver-kubernetes /usr/local/bin/openshell-driver-kubernetes @@ -17,8 +19,8 @@ ENV OPENSHELL_COMPUTE_DRIVER=kubernetes \ OPENSHELL_GATEWAY_ID=openshell \ OPENSHELL_SANDBOX_NAMESPACE=openshell \ OPENSHELL_K8S_SANDBOX_SERVICE_ACCOUNT=openshell-sandbox \ - OPENSHELL_SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest \ - OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=if_not_present \ + OPENSHELL_SANDBOX_IMAGE=${SANDBOX_IMAGE} \ + OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=${SANDBOX_IMAGE_PULL_POLICY} \ OPENSHELL_GRPC_ENDPOINT=http://openshell.openshell.svc.cluster.local:8080 \ OPENSHELL_SUPERVISOR_IMAGE=${SUPERVISOR_IMAGE} \ OPENSHELL_SUPERVISOR_IMAGE_PULL_POLICY=if_not_present \ diff --git a/e2e/docker/Dockerfile.policy-document-community-base b/e2e/docker/Dockerfile.policy-document-community-base new file mode 100644 index 0000000000..4486c57c52 --- /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 0d1b9e5c3a..5e9f9a7be8 100644 --- a/e2e/mcp-conformance/render-policy.py +++ b/e2e/mcp-conformance/render-policy.py @@ -78,7 +78,7 @@ 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 c80c6bbdc4..19637a934c 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 6452cb01cc..99ae0d69aa 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 7a117d27ff..c923028f5b 100644 --- a/e2e/python/test_policy_validation.py +++ b/e2e/python/test_policy_validation.py @@ -16,7 +16,7 @@ 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 @@ # 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 d0c63fc2c4..f0c2ff1a2a 100644 --- a/e2e/python/test_sandbox_landlock.py +++ b/e2e/python/test_sandbox_landlock.py @@ -22,7 +22,7 @@ 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 @@ # 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 80d9963e02..a4fb4b7c7c 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -15,7 +15,7 @@ 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 @@ 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 write_allowed_files() -> str: ) 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 43badec291..da5fbc2b84 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -23,7 +23,7 @@ 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 call_native_openai(host: str, port: int) -> str: ) 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 call_native_anthropic(host: str, port: int) -> str: ) 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 0461542e0d..5902880240 100644 --- a/e2e/python/test_sandbox_venv.py +++ b/e2e/python/test_sandbox_venv.py @@ -19,7 +19,7 @@ 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/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 8f687e5fed..e69ad44a68 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -53,6 +53,12 @@ E2E_TEST_OVERRIDE="${OPENSHELL_E2E_VM_TEST:-}" E2E_FEATURES="${OPENSHELL_E2E_VM_FEATURES-e2e-vm}" SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}}" +# The published community image predates the strict public PolicyDocument +# contract. VM e2e pulls that image directly from its registry, so give the +# lifecycle-oriented suite an explicit compatible policy instead of weakening +# production parsing or requiring a local registry just for the fixture. +export OPENSHELL_SANDBOX_POLICY="${OPENSHELL_SANDBOX_POLICY:-${ROOT}/e2e/configs/policy-document-default.yaml}" + # The VM driver places `compute-driver.sock` under `[openshell.drivers.vm].state_dir`. # AF_UNIX SUN_LEN is 104 bytes on macOS (108 on Linux), so paths anchored # in the workspace's `target/` blow the limit on typical developer diff --git a/e2e/rust/tests/community_image.rs b/e2e/rust/tests/community_image.rs index 2fab46385c..8c1b50d74f 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 7281885255..c81dd9050e 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 2a1b06d277..1bc71a009a 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 b46dac1313..ca76c89438 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 e346ac169a..382a05ed2c 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 cf7b9e657e..02d8aeb1b3 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 70c0f1af25..3883e0ea33 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 3fc1c12a30..4f78f718fb 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 ae1e16601f..c8833bf697 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 e8727bbce3..52fa567e1f 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 3577e20a3c..f673a63ede 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 ef1a08f0b4..dbd83f2bc5 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 0491e80c3f..77554ca7ba 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 58278faa5a..e4a285fc63 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 a12281f3e4..f7704815bd 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 c4e06f029f..484ed18509 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 d4c0094d2a..43fbd60bb1 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 1e34e71d0d..b44c692ad9 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 d95841d07f..ba619edc6a 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 7334d31f72..ca332cf8c2 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/e2e/with-kube-gateway.sh b/e2e/with-kube-gateway.sh index c4ed805293..5428a4790d 100755 --- a/e2e/with-kube-gateway.sh +++ b/e2e/with-kube-gateway.sh @@ -31,6 +31,9 @@ # Set OPENSHELL_E2E_KUBE_EXTRA_VALUES to one or more colon-separated Helm values # files, relative to the repository root or absolute, to layer additional chart # configuration on top of ci/values-skaffold.yaml. +# Set OPENSHELL_E2E_KUBE_SANDBOX_IMAGE to test a non-default workload image. The +# local kind/k3d path otherwise derives a strict PolicyDocument fixture from the +# published community base image, whose baked policy predates that contract. # # Image source: # - Ephemeral k3d mode builds local @@ -803,6 +806,33 @@ else fi REGISTRY_VALUE="${REGISTRY_VALUE%/}" +prepare_policy_document_community_image() { + local source_image=$1 + local source_id source_digest fixture_image + + echo "Refreshing latest sandbox image ${source_image}..." >&2 + docker pull "${source_image}" >&2 + 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 + docker build \ + --pull=false \ + --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}" +} + # Resolve a host-gateway IP that sandbox pods can dial to reach test fixtures # running on the developer/CI host (HTTP fixtures bound to 0.0.0.0 plus sibling # Docker containers with published ports). The Helm chart wires this into pod @@ -892,6 +922,26 @@ elif [[ "${KUBE_CONTEXT}" == k3d-* ]] && command -v k3d >/dev/null 2>&1; then import_cluster_name="${candidate}" fi fi + +DEFAULT_SANDBOX_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +KUBE_SANDBOX_IMAGE="${OPENSHELL_E2E_KUBE_SANDBOX_IMAGE:-${DEFAULT_SANDBOX_IMAGE}}" +KUBE_SANDBOX_IMAGE_PULL_POLICY="${OPENSHELL_E2E_KUBE_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}" +POLICY_DOCUMENT_FIXTURE_IMAGE="" +if [ "${KUBE_SANDBOX_IMAGE}" = "${DEFAULT_SANDBOX_IMAGE}" ]; then + if [ -z "${import_cluster_name}" ] && ! [[ "${KUBE_CONTEXT}" == kind-* ]]; then + echo "ERROR: the published community base image still contains a legacy policy." >&2 + echo " Set OPENSHELL_E2E_KUBE_SANDBOX_IMAGE to a PolicyDocument-compatible image for this cluster." >&2 + exit 2 + fi + require_cmd docker + POLICY_DOCUMENT_FIXTURE_IMAGE="$(prepare_policy_document_community_image "${KUBE_SANDBOX_IMAGE}")" + export OPENSHELL_COMMUNITY_REGISTRY="openshell/e2e-community-sandboxes" + KUBE_SANDBOX_IMAGE="${OPENSHELL_COMMUNITY_REGISTRY}/base:latest" + docker image tag "${POLICY_DOCUMENT_FIXTURE_IMAGE}" "${KUBE_SANDBOX_IMAGE}" + export OPENSHELL_E2E_COMMUNITY_BASE_IMAGE="${KUBE_SANDBOX_IMAGE}" + KUBE_SANDBOX_IMAGE_PULL_POLICY=never +fi + if [ "${OPENSHELL_E2E_KUBE_BUILD_IMAGES}" = "1" ]; then require_cmd docker echo "Building local Kubernetes e2e images (${REGISTRY_VALUE}/{gateway,sandbox,supervisor}:${IMAGE_TAG_VALUE})..." @@ -925,6 +975,8 @@ if [ "${OPENSHELL_E2E_KUBE_BUILD_IMAGES}" = "1" ]; then --build-arg "TARGETARCH=${external_arch}" \ --build-arg "SUPERVISOR_IMAGE=${REGISTRY_VALUE}/supervisor:${IMAGE_TAG_VALUE}" \ --build-arg "SANDBOX_RUNTIME_IMAGE=${REGISTRY_VALUE}/sandbox:${IMAGE_TAG_VALUE}" \ + --build-arg "SANDBOX_IMAGE=${KUBE_SANDBOX_IMAGE}" \ + --build-arg "SANDBOX_IMAGE_PULL_POLICY=${KUBE_SANDBOX_IMAGE_PULL_POLICY}" \ --tag "${REGISTRY_VALUE}/gateway:${IMAGE_TAG_VALUE}" \ --file "${ROOT}/e2e/docker/Dockerfile.external-kubernetes-gateway" \ "${ROOT}" @@ -983,6 +1035,20 @@ elif [ "${OPENSHELL_E2E_KUBE_BUILD_IMAGES}" = "1" ] \ kind load docker-image "${image}" --name "${kind_cluster_name}" done fi +if [ -n "${POLICY_DOCUMENT_FIXTURE_IMAGE}" ]; then + if [ -n "${import_cluster_name}" ]; then + echo "Importing ${KUBE_SANDBOX_IMAGE} into k3d cluster ${import_cluster_name}..." + k3d image import "${KUBE_SANDBOX_IMAGE}" --cluster "${import_cluster_name}" \ + --mode direct >/dev/null + elif [[ "${KUBE_CONTEXT}" == kind-* ]] && command -v kind >/dev/null 2>&1; then + kind_cluster_name="${KUBE_CONTEXT#kind-}" + echo "Loading ${KUBE_SANDBOX_IMAGE} into kind cluster ${kind_cluster_name}..." + kind load docker-image "${KUBE_SANDBOX_IMAGE}" --name "${kind_cluster_name}" + else + echo "ERROR: cannot load the PolicyDocument sandbox fixture into ${KUBE_CONTEXT}." >&2 + exit 2 + fi +fi # The Kubernetes compute driver creates and watches Sandbox CRs reconciled # by the upstream agent-sandbox-controller. Without the CRD + controller, @@ -1009,6 +1075,8 @@ fi helm_extra_args=() helm_post_renderer_args=() helm_extra_args+=(--set "server.telemetryEnabled=${OPENSHELL_TELEMETRY_ENABLED}") +helm_extra_args+=(--set "server.sandboxImage=${KUBE_SANDBOX_IMAGE}") +helm_extra_args+=(--set "server.sandboxImagePullPolicy=${KUBE_SANDBOX_IMAGE_PULL_POLICY}") if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then if [ "${OPENSHELL_E2E_KUBE_BUILD_IMAGES}" != "1" ]; then echo "ERROR: external Kubernetes driver e2e requires OPENSHELL_E2E_KUBE_BUILD_IMAGES=1." >&2 diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index f851c92957..c5c427914c 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -474,6 +474,31 @@ ensure_podman_supervisor_image() { exit 2 } +prepare_policy_document_community_image() { + local source_image=$1 + local source_id source_digest fixture_image + + source_id="$(podman_cmd 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 ! podman_cmd image exists "${fixture_image}" 2>/dev/null; then + echo "Preparing PolicyDocument E2E fixture from ${source_image}..." >&2 + podman_cmd build \ + --pull=never \ + --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}" +} + ensure_podman_sandbox_runtime_image() { local image=$1 @@ -629,6 +654,12 @@ if ! podman_cmd image exists "${SANDBOX_IMAGE_REQUEST}" 2>/dev/null; then echo "Pulling ${SANDBOX_IMAGE_REQUEST}..." podman_cmd pull "${SANDBOX_IMAGE_REQUEST}" fi +if [ "${SANDBOX_IMAGE_REQUEST}" = "${DEFAULT_SANDBOX_IMAGE}" ]; then + SANDBOX_IMAGE_REQUEST="$(prepare_policy_document_community_image "${SANDBOX_IMAGE_REQUEST}")" + export OPENSHELL_E2E_COMMUNITY_BASE_IMAGE="${SANDBOX_IMAGE_REQUEST}" + export OPENSHELL_COMMUNITY_REGISTRY="openshell/e2e-community-sandboxes" + podman_cmd tag "${SANDBOX_IMAGE_REQUEST}" "${OPENSHELL_COMMUNITY_REGISTRY}/base:latest" +fi SANDBOX_IMAGE_ID="$(podman_cmd image inspect --format '{{.Id}}' "${SANDBOX_IMAGE_REQUEST}")" SANDBOX_IMAGE_ID="${SANDBOX_IMAGE_ID#sha256:}" SANDBOX_IMAGE_DIGEST="$(podman_cmd image inspect --format '{{.Digest}}' "${SANDBOX_IMAGE_REQUEST}")" diff --git a/examples/agent-driven-policy-management/policy.template.yaml b/examples/agent-driven-policy-management/policy.template.yaml index 01f2d6e3b0..b99277b008 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 9aeef0fec9..ca3992e64f 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" @@ -1140,8 +1157,14 @@ version = "0.0.0" dependencies = [ "miette", "noyalib", + "prost", + "prost-protovalidate", + "prost-reflect", + "prost-types", + "protoc-bin-vendored", "serde", "serde_json", + "tonic-prost-build", ] [[package]] @@ -1346,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" @@ -1354,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" @@ -1515,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" @@ -1589,7 +1684,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1929,7 +2024,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2398,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 1ffe34a9f1..be727f48e8 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 4b90aa7c3b..e3f53e37fd 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 1296919548..0dbd6f4b9d 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 e4ddcbca80..c2f6df389a 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 96a9942e38..1f58035957 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -21,10 +21,10 @@ use openshell_core::proto::gateway_interceptor::v1::{ interceptor_evaluation, }; use openshell_core::proto::{ - ListSandboxesRequest, ProviderProfile, Sandbox, SandboxPhase, SandboxPolicy, - UpdateConfigRequest, open_shell_client::OpenShellClient, + ListSandboxesRequest, ProviderProfile, Sandbox, SandboxPhase, UpdateConfigRequest, + open_shell_client::OpenShellClient, policy::PolicyDocument, }; -use openshell_policy::parse_sandbox_policy; +use openshell_policy::parse_authored_policy; use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; use policy_hash::{ HASH_ALGORITHM, canonical_policy_hash, canonical_profile_hash, @@ -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.sandbox.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_sandbox_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 7344d5209e..306ebaec8a 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, 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.sandbox.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) } @@ -141,9 +141,9 @@ fn hex_encode(bytes: &[u8]) -> String { mod tests { use std::collections::HashMap; - use openshell_core::proto::{ - GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, NetworkEndpoint, - NetworkPolicyRule, + use openshell_core::proto::policy::{ + GraphqlOperation, L7Allow, L7DenyRule, L7Rule, Matcher, NetworkEndpoint, NetworkPolicyRule, + ParameterMatcher, matcher, parameter_matcher, }; use super::*; @@ -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() } } @@ -287,11 +287,20 @@ mod tests { } } - impl MapValue for L7QueryMatcher { + impl MapValue for Matcher { fn from_test_value(value: &str) -> Self { Self { - glob: value.to_string(), - ..Self::default() + kind: Some(matcher::Kind::Glob(value.to_string())), + } + } + } + + impl MapValue for ParameterMatcher { + fn from_test_value(value: &str) -> Self { + Self { + kind: Some(parameter_matcher::Kind::Matcher(Matcher::from_test_value( + value, + ))), } } } diff --git a/examples/governance-interceptor/src/smoke_client.rs b/examples/governance-interceptor/src/smoke_client.rs index 8a73522617..65e66bc228 100644 --- a/examples/governance-interceptor/src/smoke_client.rs +++ b/examples/governance-interceptor/src/smoke_client.rs @@ -62,10 +62,11 @@ async fn main() -> Result<(), Box> { }) .await? .into_inner(); - let mut widened_policy = before - .policy - .clone() - .ok_or("governed sandbox did not have an active policy")?; + let mut widened_policy = openshell_policy::project_base_policy( + &before + .policy + .ok_or("governed sandbox did not have an active policy")?, + )?; let mut added_rule = widened_policy .network_policies .values() diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs index 1df9c25727..8be9f0c30e 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"#, @@ -545,7 +545,7 @@ fn profile_signature_rejects_missing_hash_algorithm() { fn policy_patch_uses_protobuf_json_names() { let service = service(); let state = policy_state(&service); - assert!(state.policy.get("filesystem").is_some()); + assert!(state.policy.get("filesystemPolicy").is_some()); assert!(state.policy.get("networkPolicies").is_some()); assert!(state.policy.get("filesystem_policy").is_none()); assert!(state.policy.get("network_policies").is_none()); @@ -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 526a813bb9..abf76dcec1 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 25a2e635b9..2ad1b9f2dc 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 30be728754..2927909205 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 3c850f45a1..fd264cbc18 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 aae18d7aef..d2a5d6b36c 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 ce6b16bfb3..653c81703f 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 a17b359ebc..ea0dae6ed2 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 b7d1cf30e5..5731770ee5 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 48e5dbe6c8..c1c5c15697 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 caadfe2b3a..99bac85b05 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 381cb63c40..8d8b659f38 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -99,6 +99,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "aws-lc-rs" version = "1.18.1" @@ -210,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" @@ -424,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]] @@ -445,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" @@ -1120,6 +1143,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.37.3" @@ -1209,8 +1241,14 @@ version = "0.0.0" dependencies = [ "miette", "noyalib", + "prost", + "prost-protovalidate", + "prost-reflect", + "prost-types", + "protoc-bin-vendored", "serde", "serde_json", + "tonic-prost-build", ] [[package]] @@ -1232,6 +1270,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "owo-colors" version = "4.4.0" @@ -1390,6 +1437,71 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" @@ -1524,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" @@ -1598,7 +1730,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1727,6 +1859,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1913,7 +2055,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2338,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 da08607f27..75c7698954 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 197e184191..cb2a9df22a 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/flake.nix b/flake.nix index 0aaf9273c7..97f5f889c0 100644 --- a/flake.nix +++ b/flake.nix @@ -58,6 +58,8 @@ git # Required to find packages. pkg-config + # Rust Protovalidate compiles its bundled constraint descriptors. + protobuf # Coverage. lcov kubernetes-helm diff --git a/proto/buf/validate/README.md b/proto/buf/validate/README.md new file mode 100644 index 0000000000..96d264f8b9 --- /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 0000000000..8aeb37a483 --- /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 50ec3c799f..3953390453 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -10,6 +10,7 @@ import "google/protobuf/duration.proto"; import "google/protobuf/struct.proto"; import "google/protobuf/timestamp.proto"; import "options.proto"; +import "policy.proto"; import "sandbox.proto"; // OpenShell service provides sandbox, provider, and runtime management capabilities. @@ -958,7 +959,7 @@ message SandboxSpec { // Container or VM template used to provision the sandbox. SandboxTemplate template = 6; // Required sandbox policy configuration. - openshell.sandbox.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 @@ -2331,8 +2332,8 @@ message ProviderProfile { string description = 3; ProviderProfileCategory category = 4; repeated ProviderProfileCredential credentials = 5; - repeated openshell.sandbox.v1.NetworkEndpoint endpoints = 6; - repeated openshell.sandbox.v1.NetworkBinary binaries = 7; + repeated openshell.policy.v1.NetworkEndpoint endpoints = 6; + repeated openshell.policy.v1.NetworkBinary binaries = 7; bool inference_capable = 8; ProviderProfileDiscovery discovery = 9; // Storage resource version for custom profiles. Built-in profiles and new @@ -2541,7 +2542,7 @@ message UpdateConfigRequest { // // Global scope (`global=true`): // - applies to all sandboxes in full (no merge). - openshell.sandbox.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. @@ -2586,7 +2587,7 @@ message PolicyMergeOperation { message AddNetworkRule { string rule_name = 1; - openshell.sandbox.v1.NetworkPolicyRule rule = 2; + openshell.policy.v1.NetworkPolicyRule rule = 2; } message RemoveNetworkEndpoint { @@ -2609,21 +2610,21 @@ message L7RuleTarget { // endpoint without a path selector. This is not the appended request path. optional string path = 4; // Declare either a nonempty binary list or any_binary, never both. - repeated openshell.sandbox.v1.NetworkBinary binaries = 5; + repeated openshell.policy.v1.NetworkBinary binaries = 5; bool any_binary = 6; } message AddDenyRules { reserved 1, 2; reserved "host", "port"; - repeated openshell.sandbox.v1.L7DenyRule deny_rules = 3; + repeated openshell.policy.v1.L7DenyRule deny_rules = 3; L7RuleTarget target = 4; } message AddAllowRules { reserved 1, 2; reserved "host", "port"; - repeated openshell.sandbox.v1.L7Rule rules = 3; + repeated openshell.policy.v1.L7Rule rules = 3; L7RuleTarget target = 4; } @@ -2752,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.sandbox.v1.SandboxPolicy policy = 7; + openshell.policy.v1.PolicyDocument policy = 7; // Immutable provenance supplied with this policy revision. map provenance = 8; } @@ -3053,7 +3054,7 @@ message PolicyChunk { // Proposed network_policies map key. string rule_name = 3; // The proposed network policy rule. - openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 4; + openshell.policy.v1.NetworkPolicyRule proposed_rule = 4; // Human-readable explanation of why this rule is proposed. string rationale = 5; // Security concerns flagged by analysis (empty if none). @@ -3099,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.sandbox.v1.SandboxPolicy current_effective_policy = 23; - openshell.sandbox.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. @@ -3250,7 +3251,7 @@ message EditDraftChunkRequest { // Chunk ID to edit. string chunk_id = 2; // The modified rule (replaces existing proposed_rule). - openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; + openshell.policy.v1.NetworkPolicyRule proposed_rule = 3; string sandbox = 1; // Optional nonzero UUID for durable at-most-once admission. Successful results // can be replayed for 24 hours; see the API errors and retries reference. diff --git a/proto/policy.proto b/proto/policy.proto new file mode 100644 index 0000000000..dc2ebe426a --- /dev/null +++ b/proto/policy.proto @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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 PolicyDocument { + // Authored policy schema version. The only supported value is 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; + // Landlock compatibility policy. + LandlockPolicy landlock = 3; + // Process identity policy. + ProcessPolicy process = 4; + // Network rules keyed by stable policy-local name. + 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 [(buf.validate.field).map = { + max_pairs: 1024, + keys: {string: {min_len: 1, max_len: 256}} + }]; +} + +message FilesystemPolicy { + bool include_workdir = 1; + repeated string read_only = 2; + repeated string read_write = 3; +} + +message LandlockPolicy { + // Documented values are "best_effort" and "hard_requirement". + string compatibility = 1; +} + +message ProcessPolicy { + string run_as_user = 1; + string run_as_group = 2; +} + +message NetworkPolicyRule { + // Display name. An empty value falls back to the surrounding map key. + string name = 1; + 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 [(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 [(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; + uint32 graphql_max_body_bytes = 14; + string path = 15; + bool websocket_credential_rewrite = 16; + bool request_body_credential_rewrite = 17; + reserved 18; + string credential_signing = 19; + string signing_service = 20; + string signing_region = 21; + JsonRpcConfig json_rpc = 22; + McpConfig mcp = 23; + NetworkCredentialBinding credential_binding = 24; + bool allow_uninspected_credentials = 25; + + // Runtime-only fields intentionally have no public tags. Reserve their + // internal wire numbers so authority cannot be added here accidentally. + reserved 26; + reserved "advisor_proposed", "provider_credentialed"; +} + +message NetworkCredentialBinding { + string provider = 1; +} + +message JsonRpcConfig { + uint32 max_body_bytes = 1; +} + +message McpConfig { + // Empty and omitted are equivalent in protobuf and select the pinned + // default revision. Nonempty values form an exact allowlist. + repeated string versions = 1; + uint32 max_body_bytes = 2; + optional bool strict_tool_names = 3; + optional bool allow_all_known_mcp_methods = 4; +} + +message GraphqlOperation { + string operation_type = 1; + string operation_name = 2; + repeated string fields = 3; +} + +message L7Rule { + L7Allow allow = 1 [(buf.validate.field).required = true]; +} + +message L7Allow { + string method = 1; + string path = 2; + string command = 3; + map query = 4; + string operation_type = 5; + string operation_name = 6; + repeated string fields = 7; + Matcher tool = 8; + map params = 9; +} + +message L7DenyRule { + string method = 1; + string path = 2; + string command = 3; + map query = 4; + string operation_type = 5; + string operation_name = 6; + repeated string fields = 7; + Matcher tool = 8; + map params = 9; +} + +message Matcher { + oneof kind { + 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 [(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 [(buf.validate.field).map = { + min_pairs: 1, + max_pairs: 1024, + keys: {string: {min_len: 1, max_len: 256}} + }]; +} + +message NetworkBinary { + string path = 1 [(buf.validate.field).string = {min_len: 1, max_len: 4096}]; +} + +message NetworkMiddleware { + // Display name. An empty value falls back to the surrounding map key. + string name = 1; + string middleware = 2; + google.protobuf.Struct config = 3; + string on_error = 4; + MiddlewareEndpointSelector endpoints = 5; + int32 order = 6; +} + +message MiddlewareEndpointSelector { + repeated string include = 1; + repeated string exclude = 2; +} diff --git a/providers/anthropic.yaml b/providers/anthropic.yaml index 53c5917425..6d6ff6a2fb 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 a3f13625c7..66f961170b 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 dcfde7f7e8..9313812f89 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 c9572a0a1b..7c466de3f5 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 7a9419a1fc..7e17b00636 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 8f60619381..7a501cb217 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 f4dfb669d7..e3ebdf431d 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 3854d2cdc8..2b107582bd 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 a0994e413e..75c016e691 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 d334aec8a6..42c05b3d20 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 552afd66a6..78e8628c70 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 73c9573e19..45882015e1 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 33ca25ddf0..a71ce78056 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 0000000000..9f91834361 --- /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 0000000000..3a98866a04 --- /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 8cfc8cb338..e08dc0a8b8 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -7,6 +7,7 @@ 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 @@ "GatewayError", "Page", "Pager", + "PolicyDocument", "Sandbox", "SandboxClient", "SandboxError", @@ -57,4 +59,5 @@ "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 0000000000..ffd8f4d095 --- /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 0000000000..d154436f9c --- /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 ec309d87a2..d2b3850e7b 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -30,6 +30,7 @@ ) from .errors import _error_mapping_channel from .mutations import DeletionOutcome, DeletionResult +from .policy import validate_policy_document _ClientCallDetailsBase = namedtuple( "_ClientCallDetailsBase", @@ -766,6 +767,8 @@ def create( 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 @@ def create_from_template( 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 873fb75fbc..9f1c02e8f4 100644 --- a/python/wheel_verifier_test.py +++ b/python/wheel_verifier_test.py @@ -27,12 +27,16 @@ def _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 955bb1e9dc..235337150d 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 c0820e5c5c..5fa10f1de7 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 d0c1f32d49..539a957d11 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 49376fd36a..fec6e71759 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 3d45c0ace7..956b664ce1 100644 --- a/sdk/go/buf.gen.yaml +++ b/sdk/go/buf.gen.yaml @@ -9,24 +9,30 @@ 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 - Mdatamodel.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1 - Msandbox.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1 - Moptions.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1 + - 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 - Mdatamodel.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1 - Msandbox.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1 - Moptions.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1 + - Mpolicy.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1 diff --git a/sdk/go/go.mod b/sdk/go/go.mod index e5d3f921ef..31f7d6df73 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 6f325c4d0c..a72182692c 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 ab915ae7a3..02f3a142c5 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,9 +462,9 @@ func TestConfigUpdate_WithPolicy(t *testing.T) { mock.mu.Unlock() require.NotNil(t, req.GetPolicy()) - assert.Equal(t, uint32(5), req.GetPolicy().GetVersion()) - require.NotNil(t, req.GetPolicy().GetFilesystem()) - assert.Equal(t, []string{"/usr"}, req.GetPolicy().GetFilesystem().GetReadOnly()) + assert.Equal(t, uint32(1), req.GetPolicy().GetVersion()) + require.NotNil(t, req.GetPolicy().GetFilesystemPolicy()) + assert.Equal(t, []string{"/usr"}, req.GetPolicy().GetFilesystemPolicy().GetReadOnly()) } func TestConfigUpdate_RejectsUnrepresentableMiddlewareConfigBeforeRPC(t *testing.T) { @@ -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 558485f178..d5a8a4ac91 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 49c62cda92..eba8d60fbd 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 924bd2fab6..ab271283d7 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 432c859af8..47dabfbaf5 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 } @@ -130,7 +130,12 @@ func copyPolicyNetworkEndpoint(ep types.PolicyNetworkEndpoint) types.PolicyNetwo a := *r.Allow a.Query = copyL7QueryMap(r.Allow.Query) a.Fields = copyStringSlice(r.Allow.Fields) - a.Params = copyL7QueryMap(r.Allow.Params) + a.Params = copyParameterMap(r.Allow.Params) + if r.Allow.Tool != nil { + tool := *r.Allow.Tool + tool.Any = copyStringSlice(tool.Any) + a.Tool = &tool + } rules[i].Allow = &a } } @@ -142,7 +147,12 @@ func copyPolicyNetworkEndpoint(ep types.PolicyNetworkEndpoint) types.PolicyNetwo for i, dr := range ep.DenyRules { dr.Query = copyL7QueryMap(dr.Query) dr.Fields = copyStringSlice(dr.Fields) - dr.Params = copyL7QueryMap(dr.Params) + dr.Params = copyParameterMap(dr.Params) + if dr.Tool != nil { + tool := *dr.Tool + tool.Any = copyStringSlice(tool.Any) + dr.Tool = &tool + } drs[i] = dr } ep.DenyRules = drs @@ -168,6 +178,23 @@ func copyPolicyNetworkEndpoint(ep types.PolicyNetworkEndpoint) types.PolicyNetwo return ep } +func copyParameterMap(src map[string]types.ParameterMatcher) map[string]types.ParameterMatcher { + if src == nil { + return nil + } + dst := make(map[string]types.ParameterMatcher, len(src)) + for key, value := range src { + if value.Matcher != nil { + matcher := *value.Matcher + matcher.Any = copyStringSlice(matcher.Any) + value.Matcher = &matcher + } + value.Object = copyParameterMap(value.Object) + dst[key] = value + } + return dst +} + func copyBoolPtr(p *bool) *bool { if p == nil { return nil @@ -357,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 2c84ecc110..df920871cb 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 5303cdfdf3..9a1fb88a2d 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 d51323ed67..459996e8b2 100644 --- a/sdk/go/openshell/v1/internal/converter/network_policy.go +++ b/sdk/go/openshell/v1/internal/converter/network_policy.go @@ -5,13 +5,13 @@ package converter import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" - sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" ) // --- NetworkPolicyRule --- // NetworkPolicyRuleFromProto converts a proto NetworkPolicyRule to an SDK NetworkPolicyRule. -func NetworkPolicyRuleFromProto(r *sbv1.NetworkPolicyRule) *types.NetworkPolicyRule { +func NetworkPolicyRuleFromProto(r *policyv1.NetworkPolicyRule) *types.NetworkPolicyRule { if r == nil { return nil } @@ -38,23 +38,23 @@ func NetworkPolicyRuleFromProto(r *sbv1.NetworkPolicyRule) *types.NetworkPolicyR } // NetworkPolicyRuleToProto converts an SDK NetworkPolicyRule to a proto NetworkPolicyRule. -func NetworkPolicyRuleToProto(r *types.NetworkPolicyRule) *sbv1.NetworkPolicyRule { +func NetworkPolicyRuleToProto(r *types.NetworkPolicyRule) *policyv1.NetworkPolicyRule { if r == nil { return nil } - result := &sbv1.NetworkPolicyRule{ + result := &policyv1.NetworkPolicyRule{ Name: r.Name, } if len(r.Endpoints) > 0 { - result.Endpoints = make([]*sbv1.NetworkEndpoint, len(r.Endpoints)) + result.Endpoints = make([]*policyv1.NetworkEndpoint, len(r.Endpoints)) for i := range r.Endpoints { result.Endpoints[i] = policyNetworkEndpointToProto(&r.Endpoints[i]) } } if len(r.Binaries) > 0 { - result.Binaries = make([]*sbv1.NetworkBinary, len(r.Binaries)) + result.Binaries = make([]*policyv1.NetworkBinary, len(r.Binaries)) for i := range r.Binaries { - result.Binaries[i] = &sbv1.NetworkBinary{Path: r.Binaries[i].Path} + result.Binaries[i] = &policyv1.NetworkBinary{Path: r.Binaries[i].Path} } } return result @@ -62,10 +62,9 @@ func NetworkPolicyRuleToProto(r *types.NetworkPolicyRule) *sbv1.NetworkPolicyRul // --- PolicyNetworkEndpoint --- -func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetworkEndpoint { +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()), @@ -77,12 +76,12 @@ func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetwor WebsocketCredentialRewrite: ep.GetWebsocketCredentialRewrite(), RequestBodyCredentialRewrite: ep.GetRequestBodyCredentialRewrite(), AllowUninspectedCredentials: ep.GetAllowUninspectedCredentials(), - ProviderCredentialed: ep.GetProviderCredentialed(), - AdvisorProposed: ep.GetAdvisorProposed(), CredentialSigning: ep.GetCredentialSigning(), SigningService: ep.GetSigningService(), SigningRegion: ep.GetSigningRegion(), - JSONRPCMaxBodyBytes: ep.GetJsonRpcMaxBodyBytes(), + } + if jsonRPC := ep.GetJsonRpc(); jsonRPC != nil { + result.JSONRPCMaxBodyBytes = jsonRPC.GetMaxBodyBytes() } if binding := ep.GetCredentialBinding(); binding != nil { result.CredentialBinding = &types.NetworkCredentialBinding{ @@ -124,14 +123,13 @@ func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetwor return result } -func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.NetworkEndpoint { - result := &sbv1.NetworkEndpoint{ +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, @@ -139,15 +137,15 @@ func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.Network WebsocketCredentialRewrite: ep.WebsocketCredentialRewrite, RequestBodyCredentialRewrite: ep.RequestBodyCredentialRewrite, AllowUninspectedCredentials: ep.AllowUninspectedCredentials, - ProviderCredentialed: ep.ProviderCredentialed, - AdvisorProposed: ep.AdvisorProposed, CredentialSigning: ep.CredentialSigning, SigningService: ep.SigningService, SigningRegion: ep.SigningRegion, - JsonRpcMaxBodyBytes: ep.JSONRPCMaxBodyBytes, + } + if ep.JSONRPCMaxBodyBytes != 0 { + result.JsonRpc = &policyv1.JsonRpcConfig{MaxBodyBytes: ep.JSONRPCMaxBodyBytes} } if ep.CredentialBinding != nil { - result.CredentialBinding = &sbv1.NetworkCredentialBinding{ + result.CredentialBinding = &policyv1.NetworkCredentialBinding{ Provider: ep.CredentialBinding.Provider, } } @@ -159,19 +157,19 @@ func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.Network result.AllowedIps = CopyStringSlice(ep.AllowedIPs) } if len(ep.Rules) > 0 { - result.Rules = make([]*sbv1.L7Rule, len(ep.Rules)) + result.Rules = make([]*policyv1.L7Rule, len(ep.Rules)) for i := range ep.Rules { result.Rules[i] = l7RuleToProto(&ep.Rules[i]) } } if len(ep.DenyRules) > 0 { - result.DenyRules = make([]*sbv1.L7DenyRule, len(ep.DenyRules)) + result.DenyRules = make([]*policyv1.L7DenyRule, len(ep.DenyRules)) for i := range ep.DenyRules { result.DenyRules[i] = l7DenyRuleToProto(&ep.DenyRules[i]) } } if len(ep.GraphqlPersistedQueries) > 0 { - result.GraphqlPersistedQueries = make(map[string]*sbv1.GraphqlOperation, len(ep.GraphqlPersistedQueries)) + result.GraphqlPersistedQueries = make(map[string]*policyv1.GraphqlOperation, len(ep.GraphqlPersistedQueries)) for k, v := range ep.GraphqlPersistedQueries { result.GraphqlPersistedQueries[k] = graphqlOperationToProto(&v) } @@ -185,22 +183,24 @@ func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.Network // mcpOptionsFromProto performs a transport conversion only. It leaves an empty // version list empty because checked policy and server ingress own default // materialization and validation. -func mcpOptionsFromProto(m *sbv1.McpOptions) *types.McpOptions { +func mcpOptionsFromProto(m *policyv1.McpConfig) *types.McpOptions { if m == nil { return nil } return &types.McpOptions{ + MaxBodyBytes: m.GetMaxBodyBytes(), StrictToolNames: CopyBoolPtr(m.StrictToolNames), AllowAllKnownMcpMethods: CopyBoolPtr(m.AllowAllKnownMcpMethods), Versions: CopyStringSlice(m.GetVersions()), } } -func mcpOptionsToProto(m *types.McpOptions) *sbv1.McpOptions { +func mcpOptionsToProto(m *types.McpOptions) *policyv1.McpConfig { if m == nil { return nil } - return &sbv1.McpOptions{ + return &policyv1.McpConfig{ + MaxBodyBytes: m.MaxBodyBytes, StrictToolNames: CopyBoolPtr(m.StrictToolNames), AllowAllKnownMcpMethods: CopyBoolPtr(m.AllowAllKnownMcpMethods), Versions: CopyStringSlice(m.Versions), @@ -209,7 +209,7 @@ func mcpOptionsToProto(m *types.McpOptions) *sbv1.McpOptions { // --- L7Rule --- -func l7RuleFromProto(r *sbv1.L7Rule) types.L7Rule { +func l7RuleFromProto(r *policyv1.L7Rule) types.L7Rule { result := types.L7Rule{} if a := r.GetAllow(); a != nil { result.Allow = &types.L7Allow{ @@ -224,16 +224,20 @@ func l7RuleFromProto(r *sbv1.L7Rule) types.L7Rule { result.Allow.Query = l7QueryMapFromProto(q) } if p := a.GetParams(); len(p) > 0 { - result.Allow.Params = l7QueryMapFromProto(p) + result.Allow.Params = l7ParameterMapFromProto(p) + } + if tool := a.GetTool(); tool != nil { + converted := matcherFromProto(tool) + result.Allow.Tool = &converted } } return result } -func l7RuleToProto(r *types.L7Rule) *sbv1.L7Rule { - result := &sbv1.L7Rule{} +func l7RuleToProto(r *types.L7Rule) *policyv1.L7Rule { + result := &policyv1.L7Rule{} if r.Allow != nil { - result.Allow = &sbv1.L7Allow{ + result.Allow = &policyv1.L7Allow{ Method: r.Allow.Method, Path: r.Allow.Path, Command: r.Allow.Command, @@ -245,7 +249,10 @@ func l7RuleToProto(r *types.L7Rule) *sbv1.L7Rule { result.Allow.Query = l7QueryMapToProto(r.Allow.Query) } if len(r.Allow.Params) > 0 { - result.Allow.Params = l7QueryMapToProto(r.Allow.Params) + result.Allow.Params = l7ParameterMapToProto(r.Allow.Params) + } + if r.Allow.Tool != nil { + result.Allow.Tool = matcherToProto(*r.Allow.Tool) } } return result @@ -253,8 +260,8 @@ func l7RuleToProto(r *types.L7Rule) *sbv1.L7Rule { // --- L7DenyRule --- -func l7DenyRuleFromProto(r *sbv1.L7DenyRule) types.L7DenyRule { - return types.L7DenyRule{ +func l7DenyRuleFromProto(r *policyv1.L7DenyRule) types.L7DenyRule { + result := types.L7DenyRule{ Method: r.GetMethod(), Path: r.GetPath(), Command: r.GetCommand(), @@ -262,12 +269,17 @@ func l7DenyRuleFromProto(r *sbv1.L7DenyRule) types.L7DenyRule { OperationName: r.GetOperationName(), Fields: CopyStringSlice(r.GetFields()), Query: l7QueryMapFromProto(r.GetQuery()), - Params: l7QueryMapFromProto(r.GetParams()), + Params: l7ParameterMapFromProto(r.GetParams()), } + if tool := r.GetTool(); tool != nil { + converted := matcherFromProto(tool) + result.Tool = &converted + } + return result } -func l7DenyRuleToProto(r *types.L7DenyRule) *sbv1.L7DenyRule { - result := &sbv1.L7DenyRule{ +func l7DenyRuleToProto(r *types.L7DenyRule) *policyv1.L7DenyRule { + result := &policyv1.L7DenyRule{ Method: r.Method, Path: r.Path, Command: r.Command, @@ -279,38 +291,87 @@ func l7DenyRuleToProto(r *types.L7DenyRule) *sbv1.L7DenyRule { result.Query = l7QueryMapToProto(r.Query) } if len(r.Params) > 0 { - result.Params = l7QueryMapToProto(r.Params) + result.Params = l7ParameterMapToProto(r.Params) + } + if r.Tool != nil { + result.Tool = matcherToProto(*r.Tool) } return result } // --- L7QueryMatcher helpers --- -func l7QueryMapFromProto(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher { +func l7QueryMapFromProto(m map[string]*policyv1.Matcher) map[string]types.L7QueryMatcher { if len(m) == 0 { return nil } result := make(map[string]types.L7QueryMatcher, len(m)) for k, v := range m { if v != nil { - result[k] = types.L7QueryMatcher{ - Glob: v.GetGlob(), - Any: CopyStringSlice(v.GetAny()), - } + result[k] = matcherFromProto(v) } } return result } -func l7QueryMapToProto(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher { +func l7QueryMapToProto(m map[string]types.L7QueryMatcher) map[string]*policyv1.Matcher { if len(m) == 0 { return nil } - result := make(map[string]*sbv1.L7QueryMatcher, len(m)) + result := make(map[string]*policyv1.Matcher, len(m)) for k, v := range m { - result[k] = &sbv1.L7QueryMatcher{ - Glob: v.Glob, - Any: CopyStringSlice(v.Any), + result[k] = matcherToProto(v) + } + return result +} + +func matcherFromProto(m *policyv1.Matcher) types.L7QueryMatcher { + result := types.L7QueryMatcher{Glob: m.GetGlob()} + if anyMatcher := m.GetAny(); anyMatcher != nil { + result.Any = CopyStringSlice(anyMatcher.GetValues()) + } + return result +} + +func matcherToProto(m types.L7QueryMatcher) *policyv1.Matcher { + if len(m.Any) > 0 { + return &policyv1.Matcher{Kind: &policyv1.Matcher_Any{Any: &policyv1.AnyMatcher{ + Values: CopyStringSlice(m.Any), + }}} + } + return &policyv1.Matcher{Kind: &policyv1.Matcher_Glob{Glob: m.Glob}} +} + +func l7ParameterMapFromProto(m map[string]*policyv1.ParameterMatcher) map[string]types.ParameterMatcher { + if len(m) == 0 { + return nil + } + result := make(map[string]types.ParameterMatcher, len(m)) + for key, value := range m { + if matcher := value.GetMatcher(); matcher != nil { + converted := matcherFromProto(matcher) + result[key] = types.ParameterMatcher{Matcher: &converted} + } else if object := value.GetObject(); object != nil { + result[key] = types.ParameterMatcher{Object: l7ParameterMapFromProto(object.GetFields())} + } + } + return result +} + +func l7ParameterMapToProto(m map[string]types.ParameterMatcher) map[string]*policyv1.ParameterMatcher { + if len(m) == 0 { + return nil + } + result := make(map[string]*policyv1.ParameterMatcher, len(m)) + for key, value := range m { + if value.Matcher != nil { + result[key] = &policyv1.ParameterMatcher{Kind: &policyv1.ParameterMatcher_Matcher{ + Matcher: matcherToProto(*value.Matcher), + }} + } else { + result[key] = &policyv1.ParameterMatcher{Kind: &policyv1.ParameterMatcher_Object{ + Object: &policyv1.ParameterObject{Fields: l7ParameterMapToProto(value.Object)}, + }} } } return result @@ -318,7 +379,7 @@ func l7QueryMapToProto(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7Que // --- GraphqlOperation --- -func graphqlOperationFromProto(op *sbv1.GraphqlOperation) types.GraphqlOperation { +func graphqlOperationFromProto(op *policyv1.GraphqlOperation) types.GraphqlOperation { return types.GraphqlOperation{ OperationType: op.GetOperationType(), OperationName: op.GetOperationName(), @@ -326,8 +387,8 @@ func graphqlOperationFromProto(op *sbv1.GraphqlOperation) types.GraphqlOperation } } -func graphqlOperationToProto(op *types.GraphqlOperation) *sbv1.GraphqlOperation { - return &sbv1.GraphqlOperation{ +func graphqlOperationToProto(op *types.GraphqlOperation) *policyv1.GraphqlOperation { + return &policyv1.GraphqlOperation{ OperationType: op.OperationType, OperationName: op.OperationName, Fields: CopyStringSlice(op.Fields), 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 6a4d73dc6f..a8a7a42539 100644 --- a/sdk/go/openshell/v1/internal/converter/network_policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/network_policy_test.go @@ -7,7 +7,7 @@ import ( "testing" v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" - sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,16 +15,15 @@ import ( // --- NetworkPolicyRule round-trip --- func TestNetworkPolicyRuleFromProto(t *testing.T) { - proto := &sbv1.NetworkPolicyRule{ + proto := &policyv1.NetworkPolicyRule{ Name: "web-api", - Endpoints: []*sbv1.NetworkEndpoint{ + 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, @@ -34,35 +33,33 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { WebsocketCredentialRewrite: true, RequestBodyCredentialRewrite: false, AllowUninspectedCredentials: true, - ProviderCredentialed: true, - AdvisorProposed: true, CredentialSigning: "sigv4", SigningService: "bedrock", SigningRegion: "us-west-2", - JsonRpcMaxBodyBytes: 65536, - Mcp: &sbv1.McpOptions{ + JsonRpc: &policyv1.JsonRpcConfig{MaxBodyBytes: 65536}, + Mcp: &policyv1.McpConfig{ StrictToolNames: boolPtr(true), AllowAllKnownMcpMethods: boolPtr(false), }, - Rules: []*sbv1.L7Rule{ + Rules: []*policyv1.L7Rule{ { - Allow: &sbv1.L7Allow{ + Allow: &policyv1.L7Allow{ Method: "GET", Path: "/users", Command: "list", - Query: map[string]*sbv1.L7QueryMatcher{ - "page": {Glob: "[0-9]*", Any: []string{"1", "2"}}, + Query: map[string]*policyv1.Matcher{ + "page": {Kind: &policyv1.Matcher_Any{Any: &policyv1.AnyMatcher{Values: []string{"1", "2"}}}}, }, OperationType: "query", OperationName: "GetUsers", Fields: []string{"id", "name"}, - Params: map[string]*sbv1.L7QueryMatcher{ - "name": {Glob: "my-tool-*"}, + Params: map[string]*policyv1.ParameterMatcher{ + "name": {Kind: &policyv1.ParameterMatcher_Matcher{Matcher: &policyv1.Matcher{Kind: &policyv1.Matcher_Glob{Glob: "my-tool-*"}}}}, }, }, }, }, - DenyRules: []*sbv1.L7DenyRule{ + DenyRules: []*policyv1.L7DenyRule{ { Method: "DELETE", Path: "/admin", @@ -70,15 +67,15 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { OperationType: "mutation", OperationName: "DeleteAll", Fields: []string{"*"}, - Query: map[string]*sbv1.L7QueryMatcher{ - "force": {Glob: "true"}, + Query: map[string]*policyv1.Matcher{ + "force": {Kind: &policyv1.Matcher_Glob{Glob: "true"}}, }, - Params: map[string]*sbv1.L7QueryMatcher{ - "tool": {Glob: "deny-*"}, + Params: map[string]*policyv1.ParameterMatcher{ + "tool": {Kind: &policyv1.ParameterMatcher_Matcher{Matcher: &policyv1.Matcher{Kind: &policyv1.Matcher_Glob{Glob: "deny-*"}}}}, }, }, }, - GraphqlPersistedQueries: map[string]*sbv1.GraphqlOperation{ + GraphqlPersistedQueries: map[string]*policyv1.GraphqlOperation{ "abc123": { OperationType: "query", OperationName: "GetUser", @@ -87,7 +84,7 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { }, }, }, - Binaries: []*sbv1.NetworkBinary{ + Binaries: []*policyv1.NetworkBinary{ {Path: "/usr/bin/curl"}, }, } @@ -99,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) @@ -113,8 +109,6 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { assert.True(t, ep.WebsocketCredentialRewrite) assert.False(t, ep.RequestBodyCredentialRewrite) assert.True(t, ep.AllowUninspectedCredentials) - assert.True(t, ep.ProviderCredentialed) - assert.True(t, ep.AdvisorProposed) assert.Equal(t, "sigv4", ep.CredentialSigning) assert.Equal(t, "bedrock", ep.SigningService) assert.Equal(t, "us-west-2", ep.SigningRegion) @@ -138,10 +132,11 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { assert.Equal(t, "GetUsers", allow.OperationName) assert.Equal(t, []string{"id", "name"}, allow.Fields) require.Contains(t, allow.Query, "page") - assert.Equal(t, "[0-9]*", allow.Query["page"].Glob) + assert.Empty(t, allow.Query["page"].Glob) assert.Equal(t, []string{"1", "2"}, allow.Query["page"].Any) require.Contains(t, allow.Params, "name") - assert.Equal(t, "my-tool-*", allow.Params["name"].Glob) + require.NotNil(t, allow.Params["name"].Matcher) + assert.Equal(t, "my-tool-*", allow.Params["name"].Matcher.Glob) // Deny rules require.Len(t, ep.DenyRules, 1) @@ -155,7 +150,8 @@ func TestNetworkPolicyRuleFromProto(t *testing.T) { require.Contains(t, deny.Query, "force") assert.Equal(t, "true", deny.Query["force"].Glob) require.Contains(t, deny.Params, "tool") - assert.Equal(t, "deny-*", deny.Params["tool"].Glob) + require.NotNil(t, deny.Params["tool"].Matcher) + assert.Equal(t, "deny-*", deny.Params["tool"].Matcher.Glob) // GraphQL persisted queries require.Contains(t, ep.GraphqlPersistedQueries, "abc123") @@ -179,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, @@ -193,8 +188,6 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { WebsocketCredentialRewrite: false, RequestBodyCredentialRewrite: true, AllowUninspectedCredentials: true, - ProviderCredentialed: true, - AdvisorProposed: false, CredentialSigning: "sigv4", SigningService: "bedrock", SigningRegion: "us-east-1", @@ -214,8 +207,8 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { Query: map[string]v1.L7QueryMatcher{ "limit": {Glob: "[0-9]+"}, }, - Params: map[string]v1.L7QueryMatcher{ - "tool": {Glob: "allowed-*"}, + Params: map[string]v1.ParameterMatcher{ + "tool": {Matcher: &v1.L7QueryMatcher{Glob: "allowed-*"}}, }, }, }, @@ -226,8 +219,8 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { Path: "/graphql", OperationType: "mutation", OperationName: "DropDB", - Params: map[string]v1.L7QueryMatcher{ - "tool": {Glob: "denied-*"}, + Params: map[string]v1.ParameterMatcher{ + "tool": {Matcher: &v1.L7QueryMatcher{Glob: "denied-*"}}, }, }, }, @@ -254,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) @@ -264,8 +256,6 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { assert.Equal(t, original.Endpoints[0].AllowEncodedSlash, roundTrip.Endpoints[0].AllowEncodedSlash) assert.Equal(t, original.Endpoints[0].GraphqlMaxBodyBytes, roundTrip.Endpoints[0].GraphqlMaxBodyBytes) assert.Equal(t, original.Endpoints[0].AllowUninspectedCredentials, roundTrip.Endpoints[0].AllowUninspectedCredentials) - assert.Equal(t, original.Endpoints[0].ProviderCredentialed, roundTrip.Endpoints[0].ProviderCredentialed) - assert.Equal(t, original.Endpoints[0].AdvisorProposed, roundTrip.Endpoints[0].AdvisorProposed) assert.Equal(t, original.Endpoints[0].CredentialSigning, roundTrip.Endpoints[0].CredentialSigning) assert.Equal(t, original.Endpoints[0].SigningService, roundTrip.Endpoints[0].SigningService) assert.Equal(t, original.Endpoints[0].SigningRegion, roundTrip.Endpoints[0].SigningRegion) @@ -281,12 +271,12 @@ func TestNetworkPolicyRuleRoundTrip(t *testing.T) { assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Method, roundTrip.Endpoints[0].Rules[0].Allow.Method) assert.Equal(t, original.Endpoints[0].Rules[0].Allow.OperationName, roundTrip.Endpoints[0].Rules[0].Allow.OperationName) assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Query["limit"].Glob, roundTrip.Endpoints[0].Rules[0].Allow.Query["limit"].Glob) - assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Params["tool"].Glob, roundTrip.Endpoints[0].Rules[0].Allow.Params["tool"].Glob) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Params["tool"].Matcher.Glob, roundTrip.Endpoints[0].Rules[0].Allow.Params["tool"].Matcher.Glob) // Deny rules round-trip require.Len(t, roundTrip.Endpoints[0].DenyRules, 1) assert.Equal(t, original.Endpoints[0].DenyRules[0].OperationName, roundTrip.Endpoints[0].DenyRules[0].OperationName) - assert.Equal(t, original.Endpoints[0].DenyRules[0].Params["tool"].Glob, roundTrip.Endpoints[0].DenyRules[0].Params["tool"].Glob) + assert.Equal(t, original.Endpoints[0].DenyRules[0].Params["tool"].Matcher.Glob, roundTrip.Endpoints[0].DenyRules[0].Params["tool"].Matcher.Glob) // GraphQL persisted queries round-trip require.Contains(t, roundTrip.Endpoints[0].GraphqlPersistedQueries, "hash1") @@ -301,14 +291,14 @@ func TestNetworkPolicyRuleToProto_Nil(t *testing.T) { } func TestNetworkPolicyRuleDeepCopy(t *testing.T) { - proto := &sbv1.NetworkPolicyRule{ + proto := &policyv1.NetworkPolicyRule{ Name: "test", - Endpoints: []*sbv1.NetworkEndpoint{ + Endpoints: []*policyv1.NetworkEndpoint{ { AllowedIps: []string{"1.2.3.4"}, Ports: []uint32{80}, - Rules: []*sbv1.L7Rule{ - {Allow: &sbv1.L7Allow{Fields: []string{"f1"}}}, + Rules: []*policyv1.L7Rule{ + {Allow: &policyv1.L7Allow{Fields: []string{"f1"}}}, }, }, }, @@ -327,11 +317,11 @@ func TestNetworkPolicyRuleDeepCopy(t *testing.T) { assert.Equal(t, "f1", rule.Endpoints[0].Rules[0].Allow.Fields[0]) // MCP deep copy - mcpProto := &sbv1.NetworkPolicyRule{ + mcpProto := &policyv1.NetworkPolicyRule{ Name: "mcp-test", - Endpoints: []*sbv1.NetworkEndpoint{ + Endpoints: []*policyv1.NetworkEndpoint{ { - Mcp: &sbv1.McpOptions{ + Mcp: &policyv1.McpConfig{ StrictToolNames: boolPtr(true), }, }, @@ -344,7 +334,7 @@ func TestNetworkPolicyRuleDeepCopy(t *testing.T) { } func TestL7RuleFromProto_NilAllow(t *testing.T) { - proto := &sbv1.L7Rule{Allow: nil} + proto := &policyv1.L7Rule{Allow: nil} result := l7RuleFromProto(proto) assert.Nil(t, result.Allow) } diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 1a473654e9..1fcb4bd9d3 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -5,9 +5,12 @@ 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/types/known/structpb" ) @@ -76,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()), } } @@ -104,17 +107,17 @@ 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 *sbv1.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.GetFilesystem()), + Filesystem: filesystemPolicyFromProto(p.GetFilesystemPolicy()), Landlock: landlockPolicyFromProto(p.GetLandlock()), Process: processPolicyFromProto(p.GetProcess()), } @@ -137,26 +140,370 @@ func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { return result } -// SandboxPolicyToProto converts an SDK SandboxPolicy to a proto 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 + } + return PolicyDocumentFromProto(publicPolicyFromInternalProto(p)) +} + +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) *sbv1.SandboxPolicy { +func PolicyDocumentToProto(p *types.PolicyDocument) *policyv1.PolicyDocument { if p == nil { return nil } - result := &sbv1.SandboxPolicy{ - Version: p.Version, - Filesystem: filesystemPolicyToProto(p.Filesystem), - Landlock: landlockPolicyToProto(p.Landlock), - Process: processPolicyToProto(p.Process), + result := &policyv1.PolicyDocument{ + Version: p.Version, + FilesystemPolicy: filesystemPolicyToProto(p.Filesystem), + Landlock: landlockPolicyToProto(p.Landlock), + Process: processPolicyToProto(p.Process), } if p.NetworkPolicies != nil { - result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies)) + result.NetworkPolicies = make(map[string]*policyv1.NetworkPolicyRule, len(p.NetworkPolicies)) for k, v := range p.NetworkPolicies { result.NetworkPolicies[k] = NetworkPolicyRuleToProto(&v) } } if p.NetworkMiddlewares != nil { - result.NetworkMiddlewares = make(map[string]*sbv1.NetworkMiddlewareConfig, len(p.NetworkMiddlewares)) + result.NetworkMiddlewares = make(map[string]*policyv1.NetworkMiddleware, len(p.NetworkMiddlewares)) for k, v := range p.NetworkMiddlewares { result.NetworkMiddlewares[k] = middlewareConfigToProto(&v) } @@ -164,10 +511,10 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.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) (*sbv1.SandboxPolicy, error) { - result := SandboxPolicyToProto(p) +func PolicyDocumentToProtoChecked(p *types.PolicyDocument) (*policyv1.PolicyDocument, error) { + result := PolicyDocumentToProto(p) if p == nil { return result, nil } @@ -181,10 +528,13 @@ func SandboxPolicyToProtoChecked(p *types.SandboxPolicy) (*sbv1.SandboxPolicy, e } result.NetworkMiddlewares[name].Config = config } + if err := protovalidate.Validate(result); err != nil { + return nil, fmt.Errorf("policy document validation: %w", err) + } return result, nil } -func middlewareConfigFromProto(m *sbv1.NetworkMiddlewareConfig) types.NetworkMiddlewareConfig { +func middlewareConfigFromProto(m *policyv1.NetworkMiddleware) types.NetworkMiddlewareConfig { result := types.NetworkMiddlewareConfig{ Name: m.GetName(), Middleware: m.GetMiddleware(), @@ -203,8 +553,8 @@ func middlewareConfigFromProto(m *sbv1.NetworkMiddlewareConfig) types.NetworkMid return result } -func middlewareConfigToProto(m *types.NetworkMiddlewareConfig) *sbv1.NetworkMiddlewareConfig { - result := &sbv1.NetworkMiddlewareConfig{ +func middlewareConfigToProto(m *types.NetworkMiddlewareConfig) *policyv1.NetworkMiddleware { + result := &policyv1.NetworkMiddleware{ Name: m.Name, Middleware: m.Middleware, OnError: m.OnError, @@ -219,7 +569,7 @@ func middlewareConfigToProto(m *types.NetworkMiddlewareConfig) *sbv1.NetworkMidd } } if m.Endpoints != nil { - result.Endpoints = &sbv1.MiddlewareEndpointSelector{ + result.Endpoints = &policyv1.MiddlewareEndpointSelector{ Include: CopyStringSlice(m.Endpoints.Include), Exclude: CopyStringSlice(m.Endpoints.Exclude), } @@ -227,7 +577,7 @@ func middlewareConfigToProto(m *types.NetworkMiddlewareConfig) *sbv1.NetworkMidd return result } -func filesystemPolicyFromProto(f *sbv1.FilesystemPolicy) *types.FilesystemPolicy { +func filesystemPolicyFromProto(f *policyv1.FilesystemPolicy) *types.FilesystemPolicy { if f == nil { return nil } @@ -238,18 +588,18 @@ func filesystemPolicyFromProto(f *sbv1.FilesystemPolicy) *types.FilesystemPolicy } } -func filesystemPolicyToProto(f *types.FilesystemPolicy) *sbv1.FilesystemPolicy { +func filesystemPolicyToProto(f *types.FilesystemPolicy) *policyv1.FilesystemPolicy { if f == nil { return nil } - return &sbv1.FilesystemPolicy{ + return &policyv1.FilesystemPolicy{ IncludeWorkdir: f.IncludeWorkdir, ReadOnly: CopyStringSlice(f.ReadOnly), ReadWrite: CopyStringSlice(f.ReadWrite), } } -func landlockPolicyFromProto(l *sbv1.LandlockPolicy) *types.LandlockPolicy { +func landlockPolicyFromProto(l *policyv1.LandlockPolicy) *types.LandlockPolicy { if l == nil { return nil } @@ -258,16 +608,16 @@ func landlockPolicyFromProto(l *sbv1.LandlockPolicy) *types.LandlockPolicy { } } -func landlockPolicyToProto(l *types.LandlockPolicy) *sbv1.LandlockPolicy { +func landlockPolicyToProto(l *types.LandlockPolicy) *policyv1.LandlockPolicy { if l == nil { return nil } - return &sbv1.LandlockPolicy{ + return &policyv1.LandlockPolicy{ Compatibility: l.Compatibility, } } -func processPolicyFromProto(p *sbv1.ProcessPolicy) *types.ProcessPolicy { +func processPolicyFromProto(p *policyv1.ProcessPolicy) *types.ProcessPolicy { if p == nil { return nil } @@ -277,11 +627,11 @@ func processPolicyFromProto(p *sbv1.ProcessPolicy) *types.ProcessPolicy { } } -func processPolicyToProto(p *types.ProcessPolicy) *sbv1.ProcessPolicy { +func processPolicyToProto(p *types.ProcessPolicy) *policyv1.ProcessPolicy { if p == nil { return nil } - return &sbv1.ProcessPolicy{ + return &policyv1.ProcessPolicy{ RunAsUser: p.RunAsUser, RunAsGroup: p.RunAsGroup, } @@ -301,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 dde2205773..351e26be06 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) { @@ -85,10 +154,10 @@ func TestPolicyChunkFromProto(t *testing.T) { Binary: "/usr/bin/curl", ValidationResult: "valid", RejectionReason: "", - ProposedRule: &sbv1.NetworkPolicyRule{ + ProposedRule: &policyv1.NetworkPolicyRule{ Name: "web-api", - Endpoints: []*sbv1.NetworkEndpoint{ - {Host: "api.example.com", Port: 443, Protocol: "rest"}, + Endpoints: []*policyv1.NetworkEndpoint{ + {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 TestPolicyDocumentFromProtoNil(t *testing.T) { + assert.Nil(t, PolicyDocumentFromProto(nil)) +} -func TestSandboxPolicyFromProtoNil(t *testing.T) { - assert.Nil(t, SandboxPolicyFromProto(nil)) +func TestPolicyDocumentToProtoNil(t *testing.T) { + assert.Nil(t, PolicyDocumentToProto(nil)) } -func TestSandboxPolicyToProtoNil(t *testing.T) { - assert.Nil(t, SandboxPolicyToProto(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,30 +462,30 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { func TestSandboxPolicyDeepCopy(t *testing.T) { // Build a proto, convert to SDK, mutate proto, verify SDK is isolated. - proto := &sbv1.SandboxPolicy{ + proto := &policyv1.PolicyDocument{ Version: 1, - Filesystem: &sbv1.FilesystemPolicy{ + FilesystemPolicy: &policyv1.FilesystemPolicy{ IncludeWorkdir: true, ReadOnly: []string{"/original"}, ReadWrite: []string{"/tmp"}, }, - NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{ + NetworkPolicies: map[string]*policyv1.NetworkPolicyRule{ "rule1": { Name: "rule1", - Endpoints: []*sbv1.NetworkEndpoint{ - {Host: "original.host", Port: 80}, + Endpoints: []*policyv1.NetworkEndpoint{ + {Host: "original.host", Ports: []uint32{80}}, }, }, }, } - sdk := SandboxPolicyFromProto(proto) + sdk := PolicyDocumentFromProto(proto) require.NotNil(t, sdk) // Mutate proto source after conversion. proto.Version = 99 - proto.Filesystem.ReadOnly[0] = "mutated" - proto.Filesystem.ReadWrite[0] = "mutated" + proto.FilesystemPolicy.ReadOnly[0] = "mutated" + proto.FilesystemPolicy.ReadWrite[0] = "mutated" proto.NetworkPolicies["rule1"].Name = "mutated" proto.NetworkPolicies["rule1"].Endpoints[0].Host = "mutated.host" @@ -289,25 +497,25 @@ 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. sdk.Filesystem.ReadOnly[0] = "sdk-mutated" // Proto output must be unaffected. - assert.Equal(t, "/original", protoOut.Filesystem.ReadOnly[0]) + assert.Equal(t, "/original", protoOut.FilesystemPolicy.ReadOnly[0]) } 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 := &sbv1.SandboxPolicy{ - NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{}, + 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,9 +679,9 @@ func TestSandboxPolicyRevisionFromProto_WithPolicy(t *testing.T) { Version: 1, PolicyHash: "sha256:def", Status: pb.PolicyStatus_POLICY_STATUS_LOADED, - Policy: &sbv1.SandboxPolicy{ + Policy: &policyv1.PolicyDocument{ Version: 2, - Filesystem: &sbv1.FilesystemPolicy{ + 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,10 +823,10 @@ func TestDraftHistoryEntryFromProto_Nil(t *testing.T) { // --- NetworkMiddleware --- -func TestSandboxPolicyFromProto_WithMiddleware(t *testing.T) { - proto := &sbv1.SandboxPolicy{ +func TestPolicyDocumentFromProto_WithMiddleware(t *testing.T) { + proto := &policyv1.PolicyDocument{ Version: 3, - NetworkMiddlewares: map[string]*sbv1.NetworkMiddlewareConfig{ + NetworkMiddlewares: map[string]*policyv1.NetworkMiddleware{ "sigv4-rewriter": { Name: "sigv4-rewriter", Middleware: "aws-sigv4", @@ -631,7 +839,7 @@ func TestSandboxPolicyFromProto_WithMiddleware(t *testing.T) { }) return s }(), - Endpoints: &sbv1.MiddlewareEndpointSelector{ + Endpoints: &policyv1.MiddlewareEndpointSelector{ Include: []string{"*.bedrock.amazonaws.com"}, Exclude: []string{"sts.amazonaws.com"}, }, @@ -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,17 +900,17 @@ func TestSandboxPolicyMiddlewareRoundTrip(t *testing.T) { } func TestSandboxPolicyMiddlewareDeepCopy(t *testing.T) { - proto := &sbv1.SandboxPolicy{ - NetworkMiddlewares: map[string]*sbv1.NetworkMiddlewareConfig{ + proto := &policyv1.PolicyDocument{ + NetworkMiddlewares: map[string]*policyv1.NetworkMiddleware{ "test": { - Endpoints: &sbv1.MiddlewareEndpointSelector{ + Endpoints: &policyv1.MiddlewareEndpointSelector{ Include: []string{"original.com"}, }, }, }, } - 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 12be9618b8..c39c76be71 100644 --- a/sdk/go/openshell/v1/internal/converter/profile.go +++ b/sdk/go/openshell/v1/internal/converter/profile.go @@ -6,7 +6,7 @@ package converter import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" - sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" "google.golang.org/protobuf/types/known/durationpb" ) @@ -83,26 +83,29 @@ 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. -func NetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) *types.NetworkEndpoint { +// 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. -func NetworkEndpointToProto(ep *types.NetworkEndpoint) *sbv1.NetworkEndpoint { +func NetworkEndpointToProto(ep *types.NetworkEndpoint) *policyv1.NetworkEndpoint { if ep == nil { return nil } - return &sbv1.NetworkEndpoint{ + return &policyv1.NetworkEndpoint{ Host: ep.Host, - Port: ep.Port, + Ports: append([]uint32(nil), ep.Ports...), Protocol: ep.Protocol, } } @@ -110,7 +113,7 @@ func NetworkEndpointToProto(ep *types.NetworkEndpoint) *sbv1.NetworkEndpoint { // --- NetworkBinary --- // NetworkBinaryFromProto converts a proto NetworkBinary to an SDK NetworkBinary. -func NetworkBinaryFromProto(b *sbv1.NetworkBinary) *types.NetworkBinary { +func NetworkBinaryFromProto(b *policyv1.NetworkBinary) *types.NetworkBinary { if b == nil { return nil } @@ -120,11 +123,11 @@ func NetworkBinaryFromProto(b *sbv1.NetworkBinary) *types.NetworkBinary { } // NetworkBinaryToProto converts an SDK NetworkBinary to a proto NetworkBinary. -func NetworkBinaryToProto(b *types.NetworkBinary) *sbv1.NetworkBinary { +func NetworkBinaryToProto(b *types.NetworkBinary) *policyv1.NetworkBinary { if b == nil { return nil } - return &sbv1.NetworkBinary{ + return &policyv1.NetworkBinary{ Path: b.Path, } } @@ -421,7 +424,7 @@ func ProviderProfileToProto(p *types.ProviderProfile) *pb.ProviderProfile { // Endpoints if len(p.Endpoints) > 0 { - result.Endpoints = make([]*sbv1.NetworkEndpoint, len(p.Endpoints)) + result.Endpoints = make([]*policyv1.NetworkEndpoint, len(p.Endpoints)) for i := range p.Endpoints { result.Endpoints[i] = NetworkEndpointToProto(&p.Endpoints[i]) } @@ -429,7 +432,7 @@ func ProviderProfileToProto(p *types.ProviderProfile) *pb.ProviderProfile { // Binaries if len(p.Binaries) > 0 { - result.Binaries = make([]*sbv1.NetworkBinary, len(p.Binaries)) + result.Binaries = make([]*policyv1.NetworkBinary, len(p.Binaries)) for i := range p.Binaries { result.Binaries[i] = NetworkBinaryToProto(&p.Binaries[i]) } diff --git a/sdk/go/openshell/v1/internal/converter/profile_test.go b/sdk/go/openshell/v1/internal/converter/profile_test.go index 162542d1a0..8c3fa6a8b4 100644 --- a/sdk/go/openshell/v1/internal/converter/profile_test.go +++ b/sdk/go/openshell/v1/internal/converter/profile_test.go @@ -8,7 +8,7 @@ import ( v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" - sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" @@ -62,9 +62,9 @@ func TestProfileCategoryToProto(t *testing.T) { // --- NetworkEndpoint --- func TestNetworkEndpointFromProto(t *testing.T) { - proto := &sbv1.NetworkEndpoint{ + 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) } @@ -104,7 +104,7 @@ func TestNetworkEndpointToProto_Nil(t *testing.T) { // --- NetworkBinary --- func TestNetworkBinaryFromProto(t *testing.T) { - proto := &sbv1.NetworkBinary{ + proto := &policyv1.NetworkBinary{ Path: "/usr/local/bin/tool", } @@ -470,10 +470,10 @@ func TestProviderProfileFromProto(t *testing.T) { Credentials: []*pb.ProviderProfileCredential{ {Name: "API_KEY", Description: "key", Required: true}, }, - Endpoints: []*sbv1.NetworkEndpoint{ - {Host: "api.anthropic.com", Port: 443, Protocol: "rest"}, + Endpoints: []*policyv1.NetworkEndpoint{ + {Host: "api.anthropic.com", Ports: []uint32{443}, Protocol: "rest"}, }, - Binaries: []*sbv1.NetworkBinary{ + Binaries: []*policyv1.NetworkBinary{ {Path: "/usr/bin/claude"}, }, InferenceCapable: true, @@ -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 1f773c100e..e8e42b84e4 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 598029031a..0045a27576 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox_test.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -10,7 +10,7 @@ import ( v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" - sandboxpb "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" @@ -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", @@ -667,7 +667,7 @@ func TestSandboxRoundTrip(t *testing.T) { } func TestMcpOptionsConversionCopiesVersions(t *testing.T) { - wire := &sandboxpb.McpOptions{ + wire := &policyv1.McpConfig{ Versions: []string{"2025-03-26", "2025-11-25"}, } @@ -685,7 +685,7 @@ func TestMcpOptionsConversionCopiesVersions(t *testing.T) { } func TestMcpOptionsConversionDoesNotMaterializeDefaultVersions(t *testing.T) { - wire := &sandboxpb.McpOptions{Versions: []string{}} + wire := &policyv1.McpConfig{Versions: []string{}} sdk := mcpOptionsFromProto(wire) require.NotNil(t, sdk) @@ -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"}, @@ -736,8 +736,8 @@ func TestSandboxSpecToProto(t *testing.T) { // Policy conversion require.NotNil(t, p.Policy) assert.Equal(t, uint32(2), p.Policy.Version) - require.NotNil(t, p.Policy.Filesystem) - assert.Equal(t, []string{"/etc"}, p.Policy.Filesystem.ReadOnly) + require.NotNil(t, p.Policy.FilesystemPolicy) + assert.Equal(t, []string{"/etc"}, p.Policy.FilesystemPolicy.ReadOnly) } func TestSandboxSpecToProto_Nil(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/setting.go b/sdk/go/openshell/v1/internal/converter/setting.go index 57a0ae1242..c96292e11d 100644 --- a/sdk/go/openshell/v1/internal/converter/setting.go +++ b/sdk/go/openshell/v1/internal/converter/setting.go @@ -7,9 +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 --- @@ -133,8 +136,8 @@ func SandboxConfigFromProto(resp *sbv1.GetSandboxConfigResponse) *v1.SandboxConf PolicyValidationFailureMode: resp.GetPolicyValidationFailureMode(), } - // Convert proto SandboxPolicy to typed SDK SandboxPolicy. - sc.Policy = SandboxPolicyFromProto(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 { @@ -192,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 } @@ -252,9 +255,9 @@ func PolicyMergeOperationToProto(op *v1.PolicyMergeOperation) (*pb.PolicyMergeOp }, } case op.AddDenyRules != nil: - var denyRules []*sbv1.L7DenyRule + var denyRules []*policyv1.L7DenyRule if len(op.AddDenyRules.DenyRules) > 0 { - denyRules = make([]*sbv1.L7DenyRule, len(op.AddDenyRules.DenyRules)) + denyRules = make([]*policyv1.L7DenyRule, len(op.AddDenyRules.DenyRules)) for i := range op.AddDenyRules.DenyRules { denyRules[i] = l7DenyRuleToProto(&op.AddDenyRules.DenyRules[i]) } @@ -266,9 +269,9 @@ func PolicyMergeOperationToProto(op *v1.PolicyMergeOperation) (*pb.PolicyMergeOp }, } case op.AddAllowRules != nil: - var rules []*sbv1.L7Rule + var rules []*policyv1.L7Rule if len(op.AddAllowRules.Rules) > 0 { - rules = make([]*sbv1.L7Rule, len(op.AddAllowRules.Rules)) + rules = make([]*policyv1.L7Rule, len(op.AddAllowRules.Rules)) for i := range op.AddAllowRules.Rules { rules[i] = l7RuleToProto(&op.AddAllowRules.Rules[i]) } @@ -287,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 { @@ -309,9 +366,9 @@ func l7RuleTargetToProto(target *v1.L7RuleTarget) *pb.L7RuleTarget { result.Path = &path } if target.Binaries != nil { - result.Binaries = make([]*sbv1.NetworkBinary, len(target.Binaries)) + result.Binaries = make([]*policyv1.NetworkBinary, len(target.Binaries)) for i, binary := range target.Binaries { - result.Binaries[i] = &sbv1.NetworkBinary{Path: binary.Path} + result.Binaries[i] = &policyv1.NetworkBinary{Path: binary.Path} } } return result diff --git a/sdk/go/openshell/v1/internal/converter/setting_test.go b/sdk/go/openshell/v1/internal/converter/setting_test.go index 5481235137..5afc3afeba 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,10 +459,10 @@ 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.GetFilesystem()) - assert.Equal(t, []string{"/etc"}, req.Policy.GetFilesystem().GetReadOnly()) + 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()) } func TestConfigUpdateToProto_WithDeleteSetting(t *testing.T) { @@ -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 c995eeaf7d..01c5230326 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 fb6e963b90..36ff60564c 100644 --- a/sdk/go/openshell/v1/policy_client_test.go +++ b/sdk/go/openshell/v1/policy_client_test.go @@ -12,7 +12,7 @@ import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" - sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -216,7 +216,7 @@ func TestPolicyGetDraft(t *testing.T) { Stage: "initial", HitCount: 3, Binary: "/usr/bin/curl", - ProposedRule: &sbv1.NetworkPolicyRule{ + ProposedRule: &policyv1.NetworkPolicyRule{ Name: "allow-dns-rule", }, }, @@ -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 32708d58c0..bd604ee868 100644 --- a/sdk/go/openshell/v1/profile_client_test.go +++ b/sdk/go/openshell/v1/profile_client_test.go @@ -10,7 +10,7 @@ import ( "testing" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" - sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -201,10 +201,10 @@ func seedProfile(mock *mockProfileServer, id, displayName string, category pb.Pr Credentials: []*pb.ProviderProfileCredential{ {Name: "api-key", Description: "API Key", Required: true, Refresh: &pb.ProviderCredentialRefresh{}}, }, - Endpoints: []*sbv1.NetworkEndpoint{ - {Host: "localhost", Port: 8080, Protocol: "http"}, + Endpoints: []*policyv1.NetworkEndpoint{ + {Host: "localhost", Ports: []uint32{8080}, Protocol: "http"}, }, - Binaries: []*sbv1.NetworkBinary{ + 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 bd8baf8d14..ab7dc7f757 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 @@ -77,16 +76,12 @@ type PolicyNetworkEndpoint struct { // AllowUninspectedCredentials explicitly permits credential-bearing traffic // on paths OpenShell cannot inspect or rewrite. AllowUninspectedCredentials bool - // ProviderCredentialed is gateway-derived provenance indicating that the - // endpoint belongs to an attached credentialed provider. - ProviderCredentialed bool - AdvisorProposed bool - CredentialSigning string - SigningService string - SigningRegion string - JSONRPCMaxBodyBytes uint32 - Mcp *McpOptions - CredentialBinding *NetworkCredentialBinding + CredentialSigning string + SigningService string + SigningRegion string + JSONRPCMaxBodyBytes uint32 + Mcp *McpOptions + CredentialBinding *NetworkCredentialBinding } // NetworkCredentialBinding binds an endpoint to static credentials from an attached provider. @@ -116,7 +111,8 @@ type L7Allow struct { OperationType string OperationName string Fields []string - Params map[string]L7QueryMatcher + Tool *L7QueryMatcher + Params map[string]ParameterMatcher } // L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL traffic. @@ -128,7 +124,8 @@ type L7DenyRule struct { OperationType string OperationName string Fields []string - Params map[string]L7QueryMatcher + Tool *L7QueryMatcher + Params map[string]ParameterMatcher } // L7QueryMatcher matches query parameters by glob pattern or exact values. @@ -137,8 +134,16 @@ type L7QueryMatcher struct { Any []string } +// ParameterMatcher recursively matches either a scalar value or an object. +// Exactly one of Matcher or Object should be set. +type ParameterMatcher struct { + Matcher *L7QueryMatcher + Object map[string]ParameterMatcher +} + // McpOptions configures MCP-specific policy controls on a network endpoint. type McpOptions struct { + MaxBodyBytes uint32 StrictToolNames *bool AllowAllKnownMcpMethods *bool // Versions lists the exact MCP protocol revisions accepted by the endpoint. diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index 8fb0712b60..b959f23332 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 b7834d2ab0..c9563f2c85 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 4b945a4ec2..b2eafbbc5b 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 7dd8eff2f2..156a9b2348 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 aab21beff6..6b40e4389e 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -12,6 +12,7 @@ package openshellv1 import ( datamodelv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" _ "github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1" + policyv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/policyv1" sandboxv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" @@ -2064,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 *sandboxv1.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 @@ -2134,7 +2135,7 @@ func (x *SandboxSpec) GetTemplate() *SandboxTemplate { return nil } -func (x *SandboxSpec) GetPolicy() *sandboxv1.SandboxPolicy { +func (x *SandboxSpec) GetPolicy() *policyv1.PolicyDocument { if x != nil { return x.Policy } @@ -9676,8 +9677,8 @@ type ProviderProfile struct { Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` Category ProviderProfileCategory `protobuf:"varint,4,opt,name=category,proto3,enum=openshell.v1.ProviderProfileCategory" json:"category,omitempty"` Credentials []*ProviderProfileCredential `protobuf:"bytes,5,rep,name=credentials,proto3" json:"credentials,omitempty"` - Endpoints []*sandboxv1.NetworkEndpoint `protobuf:"bytes,6,rep,name=endpoints,proto3" json:"endpoints,omitempty"` - Binaries []*sandboxv1.NetworkBinary `protobuf:"bytes,7,rep,name=binaries,proto3" json:"binaries,omitempty"` + Endpoints []*policyv1.NetworkEndpoint `protobuf:"bytes,6,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + Binaries []*policyv1.NetworkBinary `protobuf:"bytes,7,rep,name=binaries,proto3" json:"binaries,omitempty"` InferenceCapable bool `protobuf:"varint,8,opt,name=inference_capable,json=inferenceCapable,proto3" json:"inference_capable,omitempty"` Discovery *ProviderProfileDiscovery `protobuf:"bytes,9,opt,name=discovery,proto3" json:"discovery,omitempty"` // Storage resource version for custom profiles. Built-in profiles and new @@ -9761,14 +9762,14 @@ func (x *ProviderProfile) GetCredentials() []*ProviderProfileCredential { return nil } -func (x *ProviderProfile) GetEndpoints() []*sandboxv1.NetworkEndpoint { +func (x *ProviderProfile) GetEndpoints() []*policyv1.NetworkEndpoint { if x != nil { return x.Endpoints } return nil } -func (x *ProviderProfile) GetBinaries() []*sandboxv1.NetworkBinary { +func (x *ProviderProfile) GetBinaries() []*policyv1.NetworkBinary { if x != nil { return x.Binaries } @@ -10913,7 +10914,7 @@ type UpdateConfigRequest struct { // // Global scope (`global=true`): // - applies to all sandboxes in full (no merge). - Policy *sandboxv1.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. @@ -10984,7 +10985,7 @@ func (x *UpdateConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector return nil } -func (x *UpdateConfigRequest) GetPolicy() *sandboxv1.SandboxPolicy { +func (x *UpdateConfigRequest) GetPolicy() *policyv1.PolicyDocument { if x != nil { return x.Policy } @@ -11201,9 +11202,9 @@ func (*PolicyMergeOperation_AddAllowRules) isPolicyMergeOperation_Operation() {} func (*PolicyMergeOperation_RemoveBinary) isPolicyMergeOperation_Operation() {} type AddNetworkRule struct { - state protoimpl.MessageState `protogen:"open.v1"` - RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` - Rule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,2,opt,name=rule,proto3" json:"rule,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + Rule *policyv1.NetworkPolicyRule `protobuf:"bytes,2,opt,name=rule,proto3" json:"rule,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -11245,7 +11246,7 @@ func (x *AddNetworkRule) GetRuleName() string { return "" } -func (x *AddNetworkRule) GetRule() *sandboxv1.NetworkPolicyRule { +func (x *AddNetworkRule) GetRule() *policyv1.NetworkPolicyRule { if x != nil { return x.Rule } @@ -11367,8 +11368,8 @@ type L7RuleTarget struct { // endpoint without a path selector. This is not the appended request path. Path *string `protobuf:"bytes,4,opt,name=path,proto3,oneof" json:"path,omitempty"` // Declare either a nonempty binary list or any_binary, never both. - Binaries []*sandboxv1.NetworkBinary `protobuf:"bytes,5,rep,name=binaries,proto3" json:"binaries,omitempty"` - AnyBinary bool `protobuf:"varint,6,opt,name=any_binary,json=anyBinary,proto3" json:"any_binary,omitempty"` + Binaries []*policyv1.NetworkBinary `protobuf:"bytes,5,rep,name=binaries,proto3" json:"binaries,omitempty"` + AnyBinary bool `protobuf:"varint,6,opt,name=any_binary,json=anyBinary,proto3" json:"any_binary,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -11431,7 +11432,7 @@ func (x *L7RuleTarget) GetPath() string { return "" } -func (x *L7RuleTarget) GetBinaries() []*sandboxv1.NetworkBinary { +func (x *L7RuleTarget) GetBinaries() []*policyv1.NetworkBinary { if x != nil { return x.Binaries } @@ -11446,9 +11447,9 @@ func (x *L7RuleTarget) GetAnyBinary() bool { } type AddDenyRules struct { - state protoimpl.MessageState `protogen:"open.v1"` - DenyRules []*sandboxv1.L7DenyRule `protobuf:"bytes,3,rep,name=deny_rules,json=denyRules,proto3" json:"deny_rules,omitempty"` - Target *L7RuleTarget `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + DenyRules []*policyv1.L7DenyRule `protobuf:"bytes,3,rep,name=deny_rules,json=denyRules,proto3" json:"deny_rules,omitempty"` + Target *L7RuleTarget `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -11483,7 +11484,7 @@ func (*AddDenyRules) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{144} } -func (x *AddDenyRules) GetDenyRules() []*sandboxv1.L7DenyRule { +func (x *AddDenyRules) GetDenyRules() []*policyv1.L7DenyRule { if x != nil { return x.DenyRules } @@ -11499,7 +11500,7 @@ func (x *AddDenyRules) GetTarget() *L7RuleTarget { type AddAllowRules struct { state protoimpl.MessageState `protogen:"open.v1"` - Rules []*sandboxv1.L7Rule `protobuf:"bytes,3,rep,name=rules,proto3" json:"rules,omitempty"` + Rules []*policyv1.L7Rule `protobuf:"bytes,3,rep,name=rules,proto3" json:"rules,omitempty"` Target *L7RuleTarget `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -11535,7 +11536,7 @@ func (*AddAllowRules) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{145} } -func (x *AddAllowRules) GetRules() []*sandboxv1.L7Rule { +func (x *AddAllowRules) GetRules() []*policyv1.L7Rule { if x != nil { return x.Rules } @@ -12269,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 *sandboxv1.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 @@ -12348,7 +12349,7 @@ func (x *SandboxPolicyRevision) GetLoadedTime() *timestamppb.Timestamp { return nil } -func (x *SandboxPolicyRevision) GetPolicy() *sandboxv1.SandboxPolicy { +func (x *SandboxPolicyRevision) GetPolicy() *policyv1.PolicyDocument { if x != nil { return x.Policy } @@ -14119,7 +14120,7 @@ type PolicyChunk struct { // Proposed network_policies map key. RuleName string `protobuf:"bytes,3,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` // The proposed network policy rule. - ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,4,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + ProposedRule *policyv1.NetworkPolicyRule `protobuf:"bytes,4,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` // Human-readable explanation of why this rule is proposed. Rationale string `protobuf:"bytes,5,opt,name=rationale,proto3" json:"rationale,omitempty"` // Security concerns flagged by analysis (empty if none). @@ -14165,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 *sandboxv1.SandboxPolicy `protobuf:"bytes,23,opt,name=current_effective_policy,json=currentEffectivePolicy,proto3" json:"current_effective_policy,omitempty"` - CandidateEffectivePolicy *sandboxv1.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 } @@ -14222,7 +14223,7 @@ func (x *PolicyChunk) GetRuleName() string { return "" } -func (x *PolicyChunk) GetProposedRule() *sandboxv1.NetworkPolicyRule { +func (x *PolicyChunk) GetProposedRule() *policyv1.NetworkPolicyRule { if x != nil { return x.ProposedRule } @@ -14355,14 +14356,14 @@ func (x *PolicyChunk) GetCandidateEffectivePolicyHash() string { return "" } -func (x *PolicyChunk) GetCurrentEffectivePolicy() *sandboxv1.SandboxPolicy { +func (x *PolicyChunk) GetCurrentEffectivePolicy() *policyv1.PolicyDocument { if x != nil { return x.CurrentEffectivePolicy } return nil } -func (x *PolicyChunk) GetCandidateEffectivePolicy() *sandboxv1.SandboxPolicy { +func (x *PolicyChunk) GetCandidateEffectivePolicy() *policyv1.PolicyDocument { if x != nil { return x.CandidateEffectivePolicy } @@ -15220,8 +15221,8 @@ type EditDraftChunkRequest struct { // Chunk ID to edit. ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` // The modified rule (replaces existing proposed_rule). - ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,3,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` - Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + ProposedRule *policyv1.NetworkPolicyRule `protobuf:"bytes,3,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` // Optional nonzero UUID for durable at-most-once admission. Successful results // can be replayed for 24 hours; see the API errors and retries reference. RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -15273,7 +15274,7 @@ func (x *EditDraftChunkRequest) GetChunkId() string { return "" } -func (x *EditDraftChunkRequest) GetProposedRule() *sandboxv1.NetworkPolicyRule { +func (x *EditDraftChunkRequest) GetProposedRule() *policyv1.NetworkPolicyRule { if x != nil { return x.ProposedRule } @@ -17076,7 +17077,7 @@ var File_openshell_proto protoreflect.FileDescriptor const file_openshell_proto_rawDesc = "" + "\n" + - "\x0fopenshell.proto\x12\fopenshell.v1\x1a\x0fdatamodel.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\roptions.proto\x1a\rsandbox.proto\"\x1a\n" + + "\x0fopenshell.proto\x12\fopenshell.v1\x1a\x0fdatamodel.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\roptions.proto\x1a\fpolicy.proto\x1a\rsandbox.proto\"\x1a\n" + "\x18IssueSandboxTokenRequest\"\x91\x01\n" + "\x19IssueSandboxTokenResponse\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12C\n" + @@ -17136,7 +17137,7 @@ const file_openshell_proto_rawDesc = "" + "\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.sandbox.v1.SandboxPolicyR\x06policy\x12\x1c\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" + @@ -17764,15 +17765,15 @@ const file_openshell_proto_rawDesc = "" + "\n" + "request_id\x18\x06 \x01(\tR\trequestId\"g\n" + "\x1dDeleteProviderRefreshResponse\x127\n" + - "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\xd8\x05\n" + + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\xd6\x05\n" + "\x0fProviderProfile\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + "\vdescription\x18\x03 \x01(\tR\vdescription\x12A\n" + "\bcategory\x18\x04 \x01(\x0e2%.openshell.v1.ProviderProfileCategoryR\bcategory\x12I\n" + - "\vcredentials\x18\x05 \x03(\v2'.openshell.v1.ProviderProfileCredentialR\vcredentials\x12C\n" + - "\tendpoints\x18\x06 \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\tendpoints\x12?\n" + - "\bbinaries\x18\a \x03(\v2#.openshell.sandbox.v1.NetworkBinaryR\bbinaries\x12+\n" + + "\vcredentials\x18\x05 \x03(\v2'.openshell.v1.ProviderProfileCredentialR\vcredentials\x12B\n" + + "\tendpoints\x18\x06 \x03(\v2$.openshell.policy.v1.NetworkEndpointR\tendpoints\x12>\n" + + "\bbinaries\x18\a \x03(\v2\".openshell.policy.v1.NetworkBinaryR\bbinaries\x12+\n" + "\x11inference_capable\x18\b \x01(\bR\x10inferenceCapable\x12D\n" + "\tdiscovery\x18\t \x01(\v2&.openshell.v1.ProviderProfileDiscoveryR\tdiscovery\x12)\n" + "\x10resource_version\x18\n" + @@ -17875,7 +17876,7 @@ const file_openshell_proto_rawDesc = "" + "\x13UpdateConfigRequest\x12R\n" + "\x0fworkspace_scope\x18\n" + " \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12;\n" + - "\x06policy\x18\x02 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x1f\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" + @@ -17898,31 +17899,31 @@ const file_openshell_proto_rawDesc = "" + "\x0eadd_deny_rules\x18\x04 \x01(\v2\x1a.openshell.v1.AddDenyRulesH\x00R\faddDenyRules\x12E\n" + "\x0fadd_allow_rules\x18\x05 \x01(\v2\x1b.openshell.v1.AddAllowRulesH\x00R\raddAllowRules\x12H\n" + "\rremove_binary\x18\x06 \x01(\v2!.openshell.v1.RemoveNetworkBinaryH\x00R\fremoveBinaryB\v\n" + - "\toperation\"j\n" + + "\toperation\"i\n" + "\x0eAddNetworkRule\x12\x1b\n" + - "\trule_name\x18\x01 \x01(\tR\bruleName\x12;\n" + - "\x04rule\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x04rule\"\\\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\x12:\n" + + "\x04rule\x18\x02 \x01(\v2&.openshell.policy.v1.NetworkPolicyRuleR\x04rule\"\\\n" + "\x15RemoveNetworkEndpoint\x12\x1b\n" + "\trule_name\x18\x01 \x01(\tR\bruleName\x12\x12\n" + "\x04host\x18\x02 \x01(\tR\x04host\x12\x12\n" + "\x04port\x18\x03 \x01(\rR\x04port\"0\n" + "\x11RemoveNetworkRule\x12\x1b\n" + - "\trule_name\x18\x01 \x01(\tR\bruleName\"\xd7\x01\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\"\xd6\x01\n" + "\fL7RuleTarget\x12\x1b\n" + "\trule_name\x18\x01 \x01(\tR\bruleName\x12\x12\n" + "\x04host\x18\x02 \x01(\tR\x04host\x12\x14\n" + "\x05ports\x18\x03 \x03(\rR\x05ports\x12\x17\n" + - "\x04path\x18\x04 \x01(\tH\x00R\x04path\x88\x01\x01\x12?\n" + - "\bbinaries\x18\x05 \x03(\v2#.openshell.sandbox.v1.NetworkBinaryR\bbinaries\x12\x1d\n" + + "\x04path\x18\x04 \x01(\tH\x00R\x04path\x88\x01\x01\x12>\n" + + "\bbinaries\x18\x05 \x03(\v2\".openshell.policy.v1.NetworkBinaryR\bbinaries\x12\x1d\n" + "\n" + "any_binary\x18\x06 \x01(\bR\tanyBinaryB\a\n" + - "\x05_path\"\x9b\x01\n" + - "\fAddDenyRules\x12?\n" + + "\x05_path\"\x9a\x01\n" + + "\fAddDenyRules\x12>\n" + "\n" + - "deny_rules\x18\x03 \x03(\v2 .openshell.sandbox.v1.L7DenyRuleR\tdenyRules\x122\n" + - "\x06target\x18\x04 \x01(\v2\x1a.openshell.v1.L7RuleTargetR\x06targetJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x04hostR\x04port\"\x8f\x01\n" + - "\rAddAllowRules\x122\n" + - "\x05rules\x18\x03 \x03(\v2\x1c.openshell.sandbox.v1.L7RuleR\x05rules\x122\n" + + "deny_rules\x18\x03 \x03(\v2\x1f.openshell.policy.v1.L7DenyRuleR\tdenyRules\x122\n" + + "\x06target\x18\x04 \x01(\v2\x1a.openshell.v1.L7RuleTargetR\x06targetJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x04hostR\x04port\"\x8e\x01\n" + + "\rAddAllowRules\x121\n" + + "\x05rules\x18\x03 \x03(\v2\x1b.openshell.policy.v1.L7RuleR\x05rules\x122\n" + "\x06target\x18\x04 \x01(\v2\x1a.openshell.v1.L7RuleTargetR\x06targetJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x04hostR\x04port\"S\n" + "\x13RemoveNetworkBinary\x12\x1b\n" + "\trule_name\x18\x01 \x01(\tR\bruleName\x12\x1f\n" + @@ -17990,7 +17991,7 @@ const file_openshell_proto_rawDesc = "" + "\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.sandbox.v1.SandboxPolicyR\x06policy\x12S\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" + @@ -18122,12 +18123,12 @@ 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\"\xf9\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" + - "\trule_name\x18\x03 \x01(\tR\bruleName\x12L\n" + - "\rproposed_rule\x18\x04 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12\x1c\n" + + "\trule_name\x18\x03 \x01(\tR\bruleName\x12K\n" + + "\rproposed_rule\x18\x04 \x01(\v2&.openshell.policy.v1.NetworkPolicyRuleR\fproposedRule\x12\x1c\n" + "\trationale\x18\x05 \x01(\tR\trationale\x12%\n" + "\x0esecurity_notes\x18\x06 \x01(\tR\rsecurityNotes\x12\x1e\n" + "\n" + @@ -18148,8 +18149,8 @@ const file_openshell_proto_rawDesc = "" + "\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.sandbox.v1.SandboxPolicyR\x16currentEffectivePolicy\x12a\n" + - "\x1acandidate_effective_policy\x18\x18 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x18candidateEffectivePolicyJ\x04\b\t\x10\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" + @@ -18213,11 +18214,11 @@ const file_openshell_proto_rawDesc = "" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + "policyHash\x12'\n" + "\x0fchunks_approved\x18\x03 \x01(\rR\x0echunksApproved\x12%\n" + - "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\x8d\x02\n" + + "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\x8c\x02\n" + "\x15EditDraftChunkRequest\x12R\n" + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x19\n" + - "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12L\n" + - "\rproposed_rule\x18\x03 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12\x18\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12K\n" + + "\rproposed_rule\x18\x03 \x01(\v2&.openshell.policy.v1.NetworkPolicyRuleR\fproposedRule\x12\x18\n" + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x1d\n" + "\n" + "request_id\x18\x05 \x01(\tR\trequestId\"\x18\n" + @@ -18922,18 +18923,18 @@ 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 - (*sandboxv1.SandboxPolicy)(nil), // 267: openshell.sandbox.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 (*datamodelv1.Provider)(nil), // 271: openshell.datamodel.v1.Provider (sandboxv1.PolicySource)(0), // 272: openshell.sandbox.v1.PolicySource - (*sandboxv1.NetworkEndpoint)(nil), // 273: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 274: openshell.sandbox.v1.NetworkBinary + (*policyv1.NetworkEndpoint)(nil), // 273: openshell.policy.v1.NetworkEndpoint + (*policyv1.NetworkBinary)(nil), // 274: openshell.policy.v1.NetworkBinary (*sandboxv1.SettingValue)(nil), // 275: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 276: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 277: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 278: openshell.sandbox.v1.L7Rule + (*policyv1.NetworkPolicyRule)(nil), // 276: openshell.policy.v1.NetworkPolicyRule + (*policyv1.L7DenyRule)(nil), // 277: openshell.policy.v1.L7DenyRule + (*policyv1.L7Rule)(nil), // 278: openshell.policy.v1.L7Rule (*datamodelv1.Workspace)(nil), // 279: openshell.datamodel.v1.Workspace (*sandboxv1.GetSandboxConfigRequest)(nil), // 280: openshell.sandbox.v1.GetSandboxConfigRequest (*sandboxv1.GetGatewayConfigRequest)(nil), // 281: openshell.sandbox.v1.GetGatewayConfigRequest @@ -18959,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.sandbox.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 @@ -19118,8 +19119,8 @@ var file_openshell_proto_depIdxs = []int32{ 15, // 174: openshell.v1.DeleteProviderRefreshResponse.outcome:type_name -> openshell.v1.DeletionOutcome 9, // 175: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory 123, // 176: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 273, // 177: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 274, // 178: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 273, // 177: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.policy.v1.NetworkEndpoint + 274, // 178: openshell.v1.ProviderProfile.binaries:type_name -> openshell.policy.v1.NetworkBinary 128, // 179: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery 256, // 180: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry 137, // 181: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile @@ -19146,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.sandbox.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 @@ -19156,11 +19157,11 @@ var file_openshell_proto_depIdxs = []int32{ 161, // 212: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules 162, // 213: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules 163, // 214: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 276, // 215: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 274, // 216: openshell.v1.L7RuleTarget.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 277, // 217: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 276, // 215: openshell.v1.AddNetworkRule.rule:type_name -> openshell.policy.v1.NetworkPolicyRule + 274, // 216: openshell.v1.L7RuleTarget.binaries:type_name -> openshell.policy.v1.NetworkBinary + 277, // 217: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.policy.v1.L7DenyRule 160, // 218: openshell.v1.AddDenyRules.target:type_name -> openshell.v1.L7RuleTarget - 278, // 219: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 278, // 219: openshell.v1.AddAllowRules.rules:type_name -> openshell.policy.v1.L7Rule 160, // 220: openshell.v1.AddAllowRules.target:type_name -> openshell.v1.L7RuleTarget 262, // 221: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry 270, // 222: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector @@ -19173,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.sandbox.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 @@ -19196,13 +19197,13 @@ var file_openshell_proto_depIdxs = []int32{ 265, // 252: openshell.v1.DenialSummary.last_seen_time:type_name -> google.protobuf.Timestamp 197, // 253: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample 199, // 254: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 276, // 255: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 276, // 255: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.policy.v1.NetworkPolicyRule 265, // 256: openshell.v1.PolicyChunk.created_time:type_name -> google.protobuf.Timestamp 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.sandbox.v1.SandboxPolicy - 267, // 261: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.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 @@ -19215,7 +19216,7 @@ var file_openshell_proto_depIdxs = []int32{ 270, // 271: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 211, // 272: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval 270, // 273: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 276, // 274: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 276, // 274: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.policy.v1.NetworkPolicyRule 270, // 275: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 270, // 276: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 270, // 277: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector diff --git a/sdk/go/proto/policyv1/policy.pb.go b/sdk/go/proto/policyv1/policy.pb.go new file mode 100644 index 0000000000..db3765bdd5 --- /dev/null +++ b/sdk/go/proto/policyv1/policy.pb.go @@ -0,0 +1,1745 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: policy.proto + +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" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// 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. +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"` + // Filesystem access policy. Absence enables the runtime workdir default; + // a present empty message explicitly disables that default. + FilesystemPolicy *FilesystemPolicy `protobuf:"bytes,2,opt,name=filesystem_policy,json=filesystemPolicy,proto3" json:"filesystem_policy,omitempty"` + // Landlock compatibility policy. + Landlock *LandlockPolicy `protobuf:"bytes,3,opt,name=landlock,proto3" json:"landlock,omitempty"` + // Process identity policy. + Process *ProcessPolicy `protobuf:"bytes,4,opt,name=process,proto3" json:"process,omitempty"` + // Network rules keyed by stable policy-local name. + NetworkPolicies map[string]*NetworkPolicyRule `protobuf:"bytes,5,rep,name=network_policies,json=networkPolicies,proto3" json:"network_policies,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Reusable middleware configs keyed by stable policy-local name. + NetworkMiddlewares map[string]*NetworkMiddleware `protobuf:"bytes,6,rep,name=network_middlewares,json=networkMiddlewares,proto3" json:"network_middlewares,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyDocument) Reset() { + *x = PolicyDocument{} + mi := &file_policy_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyDocument) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyDocument) ProtoMessage() {} + +func (x *PolicyDocument) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyDocument.ProtoReflect.Descriptor instead. +func (*PolicyDocument) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{0} +} + +func (x *PolicyDocument) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *PolicyDocument) GetFilesystemPolicy() *FilesystemPolicy { + if x != nil { + return x.FilesystemPolicy + } + return nil +} + +func (x *PolicyDocument) GetLandlock() *LandlockPolicy { + if x != nil { + return x.Landlock + } + return nil +} + +func (x *PolicyDocument) GetProcess() *ProcessPolicy { + if x != nil { + return x.Process + } + return nil +} + +func (x *PolicyDocument) GetNetworkPolicies() map[string]*NetworkPolicyRule { + if x != nil { + return x.NetworkPolicies + } + return nil +} + +func (x *PolicyDocument) GetNetworkMiddlewares() map[string]*NetworkMiddleware { + if x != nil { + return x.NetworkMiddlewares + } + return nil +} + +type FilesystemPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + IncludeWorkdir bool `protobuf:"varint,1,opt,name=include_workdir,json=includeWorkdir,proto3" json:"include_workdir,omitempty"` + ReadOnly []string `protobuf:"bytes,2,rep,name=read_only,json=readOnly,proto3" json:"read_only,omitempty"` + ReadWrite []string `protobuf:"bytes,3,rep,name=read_write,json=readWrite,proto3" json:"read_write,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesystemPolicy) Reset() { + *x = FilesystemPolicy{} + mi := &file_policy_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesystemPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesystemPolicy) ProtoMessage() {} + +func (x *FilesystemPolicy) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesystemPolicy.ProtoReflect.Descriptor instead. +func (*FilesystemPolicy) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{1} +} + +func (x *FilesystemPolicy) GetIncludeWorkdir() bool { + if x != nil { + return x.IncludeWorkdir + } + return false +} + +func (x *FilesystemPolicy) GetReadOnly() []string { + if x != nil { + return x.ReadOnly + } + return nil +} + +func (x *FilesystemPolicy) GetReadWrite() []string { + if x != nil { + return x.ReadWrite + } + return nil +} + +type LandlockPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Documented values are "best_effort" and "hard_requirement". + Compatibility string `protobuf:"bytes,1,opt,name=compatibility,proto3" json:"compatibility,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LandlockPolicy) Reset() { + *x = LandlockPolicy{} + mi := &file_policy_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LandlockPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LandlockPolicy) ProtoMessage() {} + +func (x *LandlockPolicy) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LandlockPolicy.ProtoReflect.Descriptor instead. +func (*LandlockPolicy) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{2} +} + +func (x *LandlockPolicy) GetCompatibility() string { + if x != nil { + return x.Compatibility + } + return "" +} + +type ProcessPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunAsUser string `protobuf:"bytes,1,opt,name=run_as_user,json=runAsUser,proto3" json:"run_as_user,omitempty"` + RunAsGroup string `protobuf:"bytes,2,opt,name=run_as_group,json=runAsGroup,proto3" json:"run_as_group,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessPolicy) Reset() { + *x = ProcessPolicy{} + mi := &file_policy_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessPolicy) ProtoMessage() {} + +func (x *ProcessPolicy) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessPolicy.ProtoReflect.Descriptor instead. +func (*ProcessPolicy) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{3} +} + +func (x *ProcessPolicy) GetRunAsUser() string { + if x != nil { + return x.RunAsUser + } + return "" +} + +func (x *ProcessPolicy) GetRunAsGroup() string { + if x != nil { + return x.RunAsGroup + } + return "" +} + +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"` + // 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 +} + +func (x *NetworkPolicyRule) Reset() { + *x = NetworkPolicyRule{} + mi := &file_policy_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkPolicyRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkPolicyRule) ProtoMessage() {} + +func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkPolicyRule.ProtoReflect.Descriptor instead. +func (*NetworkPolicyRule) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{4} +} + +func (x *NetworkPolicyRule) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *NetworkPolicyRule) GetEndpoints() []*NetworkEndpoint { + if x != nil { + return x.Endpoints + } + return nil +} + +func (x *NetworkPolicyRule) GetBinaries() []*NetworkBinary { + if x != nil { + return x.Binaries + } + return nil +} + +type NetworkEndpoint struct { + 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"` + PersistedQueries string `protobuf:"bytes,12,opt,name=persisted_queries,json=persistedQueries,proto3" json:"persisted_queries,omitempty"` + GraphqlPersistedQueries map[string]*GraphqlOperation `protobuf:"bytes,13,rep,name=graphql_persisted_queries,json=graphqlPersistedQueries,proto3" json:"graphql_persisted_queries,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + GraphqlMaxBodyBytes uint32 `protobuf:"varint,14,opt,name=graphql_max_body_bytes,json=graphqlMaxBodyBytes,proto3" json:"graphql_max_body_bytes,omitempty"` + Path string `protobuf:"bytes,15,opt,name=path,proto3" json:"path,omitempty"` + WebsocketCredentialRewrite bool `protobuf:"varint,16,opt,name=websocket_credential_rewrite,json=websocketCredentialRewrite,proto3" json:"websocket_credential_rewrite,omitempty"` + RequestBodyCredentialRewrite bool `protobuf:"varint,17,opt,name=request_body_credential_rewrite,json=requestBodyCredentialRewrite,proto3" json:"request_body_credential_rewrite,omitempty"` + CredentialSigning string `protobuf:"bytes,19,opt,name=credential_signing,json=credentialSigning,proto3" json:"credential_signing,omitempty"` + SigningService string `protobuf:"bytes,20,opt,name=signing_service,json=signingService,proto3" json:"signing_service,omitempty"` + SigningRegion string `protobuf:"bytes,21,opt,name=signing_region,json=signingRegion,proto3" json:"signing_region,omitempty"` + JsonRpc *JsonRpcConfig `protobuf:"bytes,22,opt,name=json_rpc,json=jsonRpc,proto3" json:"json_rpc,omitempty"` + Mcp *McpConfig `protobuf:"bytes,23,opt,name=mcp,proto3" json:"mcp,omitempty"` + CredentialBinding *NetworkCredentialBinding `protobuf:"bytes,24,opt,name=credential_binding,json=credentialBinding,proto3" json:"credential_binding,omitempty"` + AllowUninspectedCredentials bool `protobuf:"varint,25,opt,name=allow_uninspected_credentials,json=allowUninspectedCredentials,proto3" json:"allow_uninspected_credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkEndpoint) Reset() { + *x = NetworkEndpoint{} + mi := &file_policy_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkEndpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkEndpoint) ProtoMessage() {} + +func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkEndpoint.ProtoReflect.Descriptor instead. +func (*NetworkEndpoint) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{5} +} + +func (x *NetworkEndpoint) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *NetworkEndpoint) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *NetworkEndpoint) GetTls() string { + if x != nil { + return x.Tls + } + return "" +} + +func (x *NetworkEndpoint) GetEnforcement() string { + if x != nil { + return x.Enforcement + } + return "" +} + +func (x *NetworkEndpoint) GetAccess() string { + if x != nil { + return x.Access + } + return "" +} + +func (x *NetworkEndpoint) GetRules() []*L7Rule { + if x != nil { + return x.Rules + } + return nil +} + +func (x *NetworkEndpoint) GetAllowedIps() []string { + if x != nil { + return x.AllowedIps + } + return nil +} + +func (x *NetworkEndpoint) GetPorts() []uint32 { + if x != nil { + return x.Ports + } + return nil +} + +func (x *NetworkEndpoint) GetDenyRules() []*L7DenyRule { + if x != nil { + return x.DenyRules + } + return nil +} + +func (x *NetworkEndpoint) GetAllowEncodedSlash() bool { + if x != nil { + return x.AllowEncodedSlash + } + return false +} + +func (x *NetworkEndpoint) GetPersistedQueries() string { + if x != nil { + return x.PersistedQueries + } + return "" +} + +func (x *NetworkEndpoint) GetGraphqlPersistedQueries() map[string]*GraphqlOperation { + if x != nil { + return x.GraphqlPersistedQueries + } + return nil +} + +func (x *NetworkEndpoint) GetGraphqlMaxBodyBytes() uint32 { + if x != nil { + return x.GraphqlMaxBodyBytes + } + return 0 +} + +func (x *NetworkEndpoint) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *NetworkEndpoint) GetWebsocketCredentialRewrite() bool { + if x != nil { + return x.WebsocketCredentialRewrite + } + return false +} + +func (x *NetworkEndpoint) GetRequestBodyCredentialRewrite() bool { + if x != nil { + return x.RequestBodyCredentialRewrite + } + return false +} + +func (x *NetworkEndpoint) GetCredentialSigning() string { + if x != nil { + return x.CredentialSigning + } + return "" +} + +func (x *NetworkEndpoint) GetSigningService() string { + if x != nil { + return x.SigningService + } + return "" +} + +func (x *NetworkEndpoint) GetSigningRegion() string { + if x != nil { + return x.SigningRegion + } + return "" +} + +func (x *NetworkEndpoint) GetJsonRpc() *JsonRpcConfig { + if x != nil { + return x.JsonRpc + } + return nil +} + +func (x *NetworkEndpoint) GetMcp() *McpConfig { + if x != nil { + return x.Mcp + } + return nil +} + +func (x *NetworkEndpoint) GetCredentialBinding() *NetworkCredentialBinding { + if x != nil { + return x.CredentialBinding + } + return nil +} + +func (x *NetworkEndpoint) GetAllowUninspectedCredentials() bool { + if x != nil { + return x.AllowUninspectedCredentials + } + return false +} + +type NetworkCredentialBinding struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkCredentialBinding) Reset() { + *x = NetworkCredentialBinding{} + mi := &file_policy_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkCredentialBinding) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkCredentialBinding) ProtoMessage() {} + +func (x *NetworkCredentialBinding) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkCredentialBinding.ProtoReflect.Descriptor instead. +func (*NetworkCredentialBinding) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{6} +} + +func (x *NetworkCredentialBinding) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +type JsonRpcConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + MaxBodyBytes uint32 `protobuf:"varint,1,opt,name=max_body_bytes,json=maxBodyBytes,proto3" json:"max_body_bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *JsonRpcConfig) Reset() { + *x = JsonRpcConfig{} + mi := &file_policy_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *JsonRpcConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*JsonRpcConfig) ProtoMessage() {} + +func (x *JsonRpcConfig) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use JsonRpcConfig.ProtoReflect.Descriptor instead. +func (*JsonRpcConfig) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{7} +} + +func (x *JsonRpcConfig) GetMaxBodyBytes() uint32 { + if x != nil { + return x.MaxBodyBytes + } + return 0 +} + +type McpConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Empty and omitted are equivalent in protobuf and select the pinned + // default revision. Nonempty values form an exact allowlist. + Versions []string `protobuf:"bytes,1,rep,name=versions,proto3" json:"versions,omitempty"` + MaxBodyBytes uint32 `protobuf:"varint,2,opt,name=max_body_bytes,json=maxBodyBytes,proto3" json:"max_body_bytes,omitempty"` + StrictToolNames *bool `protobuf:"varint,3,opt,name=strict_tool_names,json=strictToolNames,proto3,oneof" json:"strict_tool_names,omitempty"` + AllowAllKnownMcpMethods *bool `protobuf:"varint,4,opt,name=allow_all_known_mcp_methods,json=allowAllKnownMcpMethods,proto3,oneof" json:"allow_all_known_mcp_methods,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *McpConfig) Reset() { + *x = McpConfig{} + mi := &file_policy_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *McpConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*McpConfig) ProtoMessage() {} + +func (x *McpConfig) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use McpConfig.ProtoReflect.Descriptor instead. +func (*McpConfig) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{8} +} + +func (x *McpConfig) GetVersions() []string { + if x != nil { + return x.Versions + } + return nil +} + +func (x *McpConfig) GetMaxBodyBytes() uint32 { + if x != nil { + return x.MaxBodyBytes + } + return 0 +} + +func (x *McpConfig) GetStrictToolNames() bool { + if x != nil && x.StrictToolNames != nil { + return *x.StrictToolNames + } + return false +} + +func (x *McpConfig) GetAllowAllKnownMcpMethods() bool { + if x != nil && x.AllowAllKnownMcpMethods != nil { + return *x.AllowAllKnownMcpMethods + } + return false +} + +type GraphqlOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + OperationType string `protobuf:"bytes,1,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + OperationName string `protobuf:"bytes,2,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + Fields []string `protobuf:"bytes,3,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GraphqlOperation) Reset() { + *x = GraphqlOperation{} + mi := &file_policy_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GraphqlOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GraphqlOperation) ProtoMessage() {} + +func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GraphqlOperation.ProtoReflect.Descriptor instead. +func (*GraphqlOperation) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{9} +} + +func (x *GraphqlOperation) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *GraphqlOperation) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *GraphqlOperation) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +type L7Rule struct { + state protoimpl.MessageState `protogen:"open.v1"` + Allow *L7Allow `protobuf:"bytes,1,opt,name=allow,proto3" json:"allow,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7Rule) Reset() { + *x = L7Rule{} + mi := &file_policy_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7Rule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7Rule) ProtoMessage() {} + +func (x *L7Rule) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L7Rule.ProtoReflect.Descriptor instead. +func (*L7Rule) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{10} +} + +func (x *L7Rule) GetAllow() *L7Allow { + if x != nil { + return x.Allow + } + return nil +} + +type L7Allow struct { + state protoimpl.MessageState `protogen:"open.v1"` + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Command string `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + Query map[string]*Matcher `protobuf:"bytes,4,rep,name=query,proto3" json:"query,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + OperationType string `protobuf:"bytes,5,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + OperationName string `protobuf:"bytes,6,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + Fields []string `protobuf:"bytes,7,rep,name=fields,proto3" json:"fields,omitempty"` + Tool *Matcher `protobuf:"bytes,8,opt,name=tool,proto3" json:"tool,omitempty"` + Params map[string]*ParameterMatcher `protobuf:"bytes,9,rep,name=params,proto3" json:"params,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7Allow) Reset() { + *x = L7Allow{} + mi := &file_policy_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7Allow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7Allow) ProtoMessage() {} + +func (x *L7Allow) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L7Allow.ProtoReflect.Descriptor instead. +func (*L7Allow) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{11} +} + +func (x *L7Allow) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *L7Allow) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *L7Allow) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *L7Allow) GetQuery() map[string]*Matcher { + if x != nil { + return x.Query + } + return nil +} + +func (x *L7Allow) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *L7Allow) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *L7Allow) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +func (x *L7Allow) GetTool() *Matcher { + if x != nil { + return x.Tool + } + return nil +} + +func (x *L7Allow) GetParams() map[string]*ParameterMatcher { + if x != nil { + return x.Params + } + return nil +} + +type L7DenyRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Command string `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + Query map[string]*Matcher `protobuf:"bytes,4,rep,name=query,proto3" json:"query,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + OperationType string `protobuf:"bytes,5,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + OperationName string `protobuf:"bytes,6,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + Fields []string `protobuf:"bytes,7,rep,name=fields,proto3" json:"fields,omitempty"` + Tool *Matcher `protobuf:"bytes,8,opt,name=tool,proto3" json:"tool,omitempty"` + Params map[string]*ParameterMatcher `protobuf:"bytes,9,rep,name=params,proto3" json:"params,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7DenyRule) Reset() { + *x = L7DenyRule{} + mi := &file_policy_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7DenyRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7DenyRule) ProtoMessage() {} + +func (x *L7DenyRule) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L7DenyRule.ProtoReflect.Descriptor instead. +func (*L7DenyRule) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{12} +} + +func (x *L7DenyRule) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *L7DenyRule) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *L7DenyRule) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *L7DenyRule) GetQuery() map[string]*Matcher { + if x != nil { + return x.Query + } + return nil +} + +func (x *L7DenyRule) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *L7DenyRule) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *L7DenyRule) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +func (x *L7DenyRule) GetTool() *Matcher { + if x != nil { + return x.Tool + } + return nil +} + +func (x *L7DenyRule) GetParams() map[string]*ParameterMatcher { + if x != nil { + return x.Params + } + return nil +} + +type Matcher struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Kind: + // + // *Matcher_Glob + // *Matcher_Any + Kind isMatcher_Kind `protobuf_oneof:"kind"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Matcher) Reset() { + *x = Matcher{} + mi := &file_policy_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Matcher) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Matcher) ProtoMessage() {} + +func (x *Matcher) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Matcher.ProtoReflect.Descriptor instead. +func (*Matcher) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{13} +} + +func (x *Matcher) GetKind() isMatcher_Kind { + if x != nil { + return x.Kind + } + return nil +} + +func (x *Matcher) GetGlob() string { + if x != nil { + if x, ok := x.Kind.(*Matcher_Glob); ok { + return x.Glob + } + } + return "" +} + +func (x *Matcher) GetAny() *AnyMatcher { + if x != nil { + if x, ok := x.Kind.(*Matcher_Any); ok { + return x.Any + } + } + return nil +} + +type isMatcher_Kind interface { + isMatcher_Kind() +} + +type Matcher_Glob struct { + Glob string `protobuf:"bytes,1,opt,name=glob,proto3,oneof"` +} + +type Matcher_Any struct { + Any *AnyMatcher `protobuf:"bytes,2,opt,name=any,proto3,oneof"` +} + +func (*Matcher_Glob) isMatcher_Kind() {} + +func (*Matcher_Any) isMatcher_Kind() {} + +type AnyMatcher struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AnyMatcher) Reset() { + *x = AnyMatcher{} + mi := &file_policy_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AnyMatcher) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AnyMatcher) ProtoMessage() {} + +func (x *AnyMatcher) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AnyMatcher.ProtoReflect.Descriptor instead. +func (*AnyMatcher) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{14} +} + +func (x *AnyMatcher) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + +type ParameterMatcher struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Kind: + // + // *ParameterMatcher_Matcher + // *ParameterMatcher_Object + Kind isParameterMatcher_Kind `protobuf_oneof:"kind"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ParameterMatcher) Reset() { + *x = ParameterMatcher{} + mi := &file_policy_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ParameterMatcher) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ParameterMatcher) ProtoMessage() {} + +func (x *ParameterMatcher) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ParameterMatcher.ProtoReflect.Descriptor instead. +func (*ParameterMatcher) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{15} +} + +func (x *ParameterMatcher) GetKind() isParameterMatcher_Kind { + if x != nil { + return x.Kind + } + return nil +} + +func (x *ParameterMatcher) GetMatcher() *Matcher { + if x != nil { + if x, ok := x.Kind.(*ParameterMatcher_Matcher); ok { + return x.Matcher + } + } + return nil +} + +func (x *ParameterMatcher) GetObject() *ParameterObject { + if x != nil { + if x, ok := x.Kind.(*ParameterMatcher_Object); ok { + return x.Object + } + } + return nil +} + +type isParameterMatcher_Kind interface { + isParameterMatcher_Kind() +} + +type ParameterMatcher_Matcher struct { + Matcher *Matcher `protobuf:"bytes,1,opt,name=matcher,proto3,oneof"` +} + +type ParameterMatcher_Object struct { + Object *ParameterObject `protobuf:"bytes,2,opt,name=object,proto3,oneof"` +} + +func (*ParameterMatcher_Matcher) isParameterMatcher_Kind() {} + +func (*ParameterMatcher_Object) isParameterMatcher_Kind() {} + +type ParameterObject struct { + state protoimpl.MessageState `protogen:"open.v1"` + Fields map[string]*ParameterMatcher `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ParameterObject) Reset() { + *x = ParameterObject{} + mi := &file_policy_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ParameterObject) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ParameterObject) ProtoMessage() {} + +func (x *ParameterObject) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ParameterObject.ProtoReflect.Descriptor instead. +func (*ParameterObject) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{16} +} + +func (x *ParameterObject) GetFields() map[string]*ParameterMatcher { + if x != nil { + return x.Fields + } + return nil +} + +type NetworkBinary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkBinary) Reset() { + *x = NetworkBinary{} + mi := &file_policy_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkBinary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkBinary) ProtoMessage() {} + +func (x *NetworkBinary) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkBinary.ProtoReflect.Descriptor instead. +func (*NetworkBinary) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{17} +} + +func (x *NetworkBinary) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type NetworkMiddleware 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"` + Middleware string `protobuf:"bytes,2,opt,name=middleware,proto3" json:"middleware,omitempty"` + Config *structpb.Struct `protobuf:"bytes,3,opt,name=config,proto3" json:"config,omitempty"` + OnError string `protobuf:"bytes,4,opt,name=on_error,json=onError,proto3" json:"on_error,omitempty"` + Endpoints *MiddlewareEndpointSelector `protobuf:"bytes,5,opt,name=endpoints,proto3" json:"endpoints,omitempty"` + Order int32 `protobuf:"varint,6,opt,name=order,proto3" json:"order,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkMiddleware) Reset() { + *x = NetworkMiddleware{} + mi := &file_policy_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkMiddleware) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkMiddleware) ProtoMessage() {} + +func (x *NetworkMiddleware) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkMiddleware.ProtoReflect.Descriptor instead. +func (*NetworkMiddleware) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{18} +} + +func (x *NetworkMiddleware) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *NetworkMiddleware) GetMiddleware() string { + if x != nil { + return x.Middleware + } + return "" +} + +func (x *NetworkMiddleware) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +func (x *NetworkMiddleware) GetOnError() string { + if x != nil { + return x.OnError + } + return "" +} + +func (x *NetworkMiddleware) GetEndpoints() *MiddlewareEndpointSelector { + if x != nil { + return x.Endpoints + } + return nil +} + +func (x *NetworkMiddleware) GetOrder() int32 { + if x != nil { + return x.Order + } + return 0 +} + +type MiddlewareEndpointSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + Include []string `protobuf:"bytes,1,rep,name=include,proto3" json:"include,omitempty"` + Exclude []string `protobuf:"bytes,2,rep,name=exclude,proto3" json:"exclude,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MiddlewareEndpointSelector) Reset() { + *x = MiddlewareEndpointSelector{} + mi := &file_policy_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MiddlewareEndpointSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MiddlewareEndpointSelector) ProtoMessage() {} + +func (x *MiddlewareEndpointSelector) ProtoReflect() protoreflect.Message { + mi := &file_policy_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MiddlewareEndpointSelector.ProtoReflect.Descriptor instead. +func (*MiddlewareEndpointSelector) Descriptor() ([]byte, []int) { + return file_policy_proto_rawDescGZIP(), []int{19} +} + +func (x *MiddlewareEndpointSelector) GetInclude() []string { + if x != nil { + return x.Include + } + return nil +} + +func (x *MiddlewareEndpointSelector) GetExclude() []string { + if x != nil { + return x.Exclude + } + return nil +} + +var File_policy_proto protoreflect.FileDescriptor + +const file_policy_proto_rawDesc = "" + + "\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\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" + + "\x17NetworkMiddlewaresEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + + "\x05value\x18\x02 \x01(\v2&.openshell.policy.v1.NetworkMiddlewareR\x05value:\x028\x01\"w\n" + + "\x10FilesystemPolicy\x12'\n" + + "\x0finclude_workdir\x18\x01 \x01(\bR\x0eincludeWorkdir\x12\x1b\n" + + "\tread_only\x18\x02 \x03(\tR\breadOnly\x12\x1d\n" + + "\n" + + "read_write\x18\x03 \x03(\tR\treadWrite\"6\n" + + "\x0eLandlockPolicy\x12$\n" + + "\rcompatibility\x18\x01 \x01(\tR\rcompatibility\"Q\n" + + "\rProcessPolicy\x12\x1e\n" + + "\vrun_as_user\x18\x01 \x01(\tR\trunAsUser\x12 \n" + + "\frun_as_group\x18\x02 \x01(\tR\n" + + "runAsGroup\"\xc1\x01\n" + + "\x11NetworkPolicyRule\x12\x12\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\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\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.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" + + "\x16graphql_max_body_bytes\x18\x0e \x01(\rR\x13graphqlMaxBodyBytes\x12\x12\n" + + "\x04path\x18\x0f \x01(\tR\x04path\x12@\n" + + "\x1cwebsocket_credential_rewrite\x18\x10 \x01(\bR\x1awebsocketCredentialRewrite\x12E\n" + + "\x1frequest_body_credential_rewrite\x18\x11 \x01(\bR\x1crequestBodyCredentialRewrite\x12-\n" + + "\x12credential_signing\x18\x13 \x01(\tR\x11credentialSigning\x12'\n" + + "\x0fsigning_service\x18\x14 \x01(\tR\x0esigningService\x12%\n" + + "\x0esigning_region\x18\x15 \x01(\tR\rsigningRegion\x12=\n" + + "\bjson_rpc\x18\x16 \x01(\v2\".openshell.policy.v1.JsonRpcConfigR\ajsonRpc\x120\n" + + "\x03mcp\x18\x17 \x01(\v2\x1e.openshell.policy.v1.McpConfigR\x03mcp\x12\\\n" + + "\x12credential_binding\x18\x18 \x01(\v2-.openshell.policy.v1.NetworkCredentialBindingR\x11credentialBinding\x12B\n" + + "\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\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" + + "\x0emax_body_bytes\x18\x01 \x01(\rR\fmaxBodyBytes\"\xf7\x01\n" + + "\tMcpConfig\x12\x1a\n" + + "\bversions\x18\x01 \x03(\tR\bversions\x12$\n" + + "\x0emax_body_bytes\x18\x02 \x01(\rR\fmaxBodyBytes\x12/\n" + + "\x11strict_tool_names\x18\x03 \x01(\bH\x00R\x0fstrictToolNames\x88\x01\x01\x12A\n" + + "\x1ballow_all_known_mcp_methods\x18\x04 \x01(\bH\x01R\x17allowAllKnownMcpMethods\x88\x01\x01B\x14\n" + + "\x12_strict_tool_namesB\x1e\n" + + "\x1c_allow_all_known_mcp_methods\"x\n" + + "\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\"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" + + "\acommand\x18\x03 \x01(\tR\acommand\x12=\n" + + "\x05query\x18\x04 \x03(\v2'.openshell.policy.v1.L7Allow.QueryEntryR\x05query\x12%\n" + + "\x0eoperation_type\x18\x05 \x01(\tR\roperationType\x12%\n" + + "\x0eoperation_name\x18\x06 \x01(\tR\roperationName\x12\x16\n" + + "\x06fields\x18\a \x03(\tR\x06fields\x120\n" + + "\x04tool\x18\b \x01(\v2\x1c.openshell.policy.v1.MatcherR\x04tool\x12@\n" + + "\x06params\x18\t \x03(\v2(.openshell.policy.v1.L7Allow.ParamsEntryR\x06params\x1aV\n" + + "\n" + + "QueryEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + + "\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\"\xab\x04\n" + + "\n" + + "L7DenyRule\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x18\n" + + "\acommand\x18\x03 \x01(\tR\acommand\x12@\n" + + "\x05query\x18\x04 \x03(\v2*.openshell.policy.v1.L7DenyRule.QueryEntryR\x05query\x12%\n" + + "\x0eoperation_type\x18\x05 \x01(\tR\roperationType\x12%\n" + + "\x0eoperation_name\x18\x06 \x01(\tR\roperationName\x12\x16\n" + + "\x06fields\x18\a \x03(\tR\x06fields\x120\n" + + "\x04tool\x18\b \x01(\v2\x1c.openshell.policy.v1.MatcherR\x04tool\x12C\n" + + "\x06params\x18\t \x03(\v2+.openshell.policy.v1.L7DenyRule.ParamsEntryR\x06params\x1aV\n" + + "\n" + + "QueryEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + + "\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\"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.\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\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\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" + + "middleware\x18\x02 \x01(\tR\n" + + "middleware\x12/\n" + + "\x06config\x18\x03 \x01(\v2\x17.google.protobuf.StructR\x06config\x12\x19\n" + + "\bon_error\x18\x04 \x01(\tR\aonError\x12M\n" + + "\tendpoints\x18\x05 \x01(\v2/.openshell.policy.v1.MiddlewareEndpointSelectorR\tendpoints\x12\x14\n" + + "\x05order\x18\x06 \x01(\x05R\x05order\"P\n" + + "\x1aMiddlewareEndpointSelector\x12\x18\n" + + "\ainclude\x18\x01 \x03(\tR\ainclude\x12\x18\n" + + "\aexclude\x18\x02 \x03(\tR\aexcludeb\x06proto3" + +var ( + file_policy_proto_rawDescOnce sync.Once + file_policy_proto_rawDescData []byte +) + +func file_policy_proto_rawDescGZIP() []byte { + file_policy_proto_rawDescOnce.Do(func() { + file_policy_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_policy_proto_rawDesc), len(file_policy_proto_rawDesc))) + }) + return file_policy_proto_rawDescData +} + +var file_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 28) +var file_policy_proto_goTypes = []any{ + (*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 + (*NetworkPolicyRule)(nil), // 4: openshell.policy.v1.NetworkPolicyRule + (*NetworkEndpoint)(nil), // 5: openshell.policy.v1.NetworkEndpoint + (*NetworkCredentialBinding)(nil), // 6: openshell.policy.v1.NetworkCredentialBinding + (*JsonRpcConfig)(nil), // 7: openshell.policy.v1.JsonRpcConfig + (*McpConfig)(nil), // 8: openshell.policy.v1.McpConfig + (*GraphqlOperation)(nil), // 9: openshell.policy.v1.GraphqlOperation + (*L7Rule)(nil), // 10: openshell.policy.v1.L7Rule + (*L7Allow)(nil), // 11: openshell.policy.v1.L7Allow + (*L7DenyRule)(nil), // 12: openshell.policy.v1.L7DenyRule + (*Matcher)(nil), // 13: openshell.policy.v1.Matcher + (*AnyMatcher)(nil), // 14: openshell.policy.v1.AnyMatcher + (*ParameterMatcher)(nil), // 15: openshell.policy.v1.ParameterMatcher + (*ParameterObject)(nil), // 16: openshell.policy.v1.ParameterObject + (*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.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 + nil, // 25: openshell.policy.v1.L7DenyRule.QueryEntry + nil, // 26: openshell.policy.v1.L7DenyRule.ParamsEntry + nil, // 27: openshell.policy.v1.ParameterObject.FieldsEntry + (*structpb.Struct)(nil), // 28: google.protobuf.Struct +} +var file_policy_proto_depIdxs = []int32{ + 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 + 12, // 8: openshell.policy.v1.NetworkEndpoint.deny_rules:type_name -> openshell.policy.v1.L7DenyRule + 22, // 9: openshell.policy.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.policy.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 7, // 10: openshell.policy.v1.NetworkEndpoint.json_rpc:type_name -> openshell.policy.v1.JsonRpcConfig + 8, // 11: openshell.policy.v1.NetworkEndpoint.mcp:type_name -> openshell.policy.v1.McpConfig + 6, // 12: openshell.policy.v1.NetworkEndpoint.credential_binding:type_name -> openshell.policy.v1.NetworkCredentialBinding + 11, // 13: openshell.policy.v1.L7Rule.allow:type_name -> openshell.policy.v1.L7Allow + 23, // 14: openshell.policy.v1.L7Allow.query:type_name -> openshell.policy.v1.L7Allow.QueryEntry + 13, // 15: openshell.policy.v1.L7Allow.tool:type_name -> openshell.policy.v1.Matcher + 24, // 16: openshell.policy.v1.L7Allow.params:type_name -> openshell.policy.v1.L7Allow.ParamsEntry + 25, // 17: openshell.policy.v1.L7DenyRule.query:type_name -> openshell.policy.v1.L7DenyRule.QueryEntry + 13, // 18: openshell.policy.v1.L7DenyRule.tool:type_name -> openshell.policy.v1.Matcher + 26, // 19: openshell.policy.v1.L7DenyRule.params:type_name -> openshell.policy.v1.L7DenyRule.ParamsEntry + 14, // 20: openshell.policy.v1.Matcher.any:type_name -> openshell.policy.v1.AnyMatcher + 13, // 21: openshell.policy.v1.ParameterMatcher.matcher:type_name -> openshell.policy.v1.Matcher + 16, // 22: openshell.policy.v1.ParameterMatcher.object:type_name -> openshell.policy.v1.ParameterObject + 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.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 + 13, // 31: openshell.policy.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.policy.v1.Matcher + 15, // 32: openshell.policy.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.policy.v1.ParameterMatcher + 15, // 33: openshell.policy.v1.ParameterObject.FieldsEntry.value:type_name -> openshell.policy.v1.ParameterMatcher + 34, // [34:34] is the sub-list for method output_type + 34, // [34:34] is the sub-list for method input_type + 34, // [34:34] is the sub-list for extension type_name + 34, // [34:34] is the sub-list for extension extendee + 0, // [0:34] is the sub-list for field type_name +} + +func init() { file_policy_proto_init() } +func file_policy_proto_init() { + if File_policy_proto != nil { + return + } + file_policy_proto_msgTypes[8].OneofWrappers = []any{} + file_policy_proto_msgTypes[13].OneofWrappers = []any{ + (*Matcher_Glob)(nil), + (*Matcher_Any)(nil), + } + file_policy_proto_msgTypes[15].OneofWrappers = []any{ + (*ParameterMatcher_Matcher)(nil), + (*ParameterMatcher_Object)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_policy_proto_rawDesc), len(file_policy_proto_rawDesc)), + NumEnums: 0, + NumMessages: 28, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_policy_proto_goTypes, + DependencyIndexes: file_policy_proto_depIdxs, + MessageInfos: file_policy_proto_msgTypes, + }.Build() + File_policy_proto = out.File + file_policy_proto_goTypes = nil + file_policy_proto_depIdxs = nil +} diff --git a/sdk/typescript/buf.gen.yaml b/sdk/typescript/buf.gen.yaml index a0fa6a31e4..f6cd3e580d 100644 --- a/sdk/typescript/buf.gen.yaml +++ b/sdk/typescript/buf.gen.yaml @@ -17,6 +17,8 @@ inputs: - ../../proto/sandbox.proto - ../../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 6c3664cedd..ad25b5a887 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 20e1da18bc..733f4b01f4 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 356ce0d591..af4e1fb796 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 546f151e4e..43f6fc5e59 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,8 +29,27 @@ import { ServiceStatus, type TcpForwardFrameSchema, } from './gen/openshell_pb.js'; -import type { EffectiveSetting, GetSandboxConfigResponse, SandboxPolicy, SettingValue } from './gen/sandbox_pb.js'; -import { PolicySource, type SandboxPolicySchema, SettingScope, type SettingValueSchema } from './gen/sandbox_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'; +import { PolicySource, SettingScope, type SettingValueSchema } from './gen/sandbox_pb.js'; import { validateSshResponse } from './ssh-validate.js'; import { buildTransport, type ConnectOptions } from './transport.js'; @@ -40,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)); @@ -56,7 +87,8 @@ export type { SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, } from './gen/openshell_pb.js'; -export type { SandboxPolicy, SettingValue } from './gen/sandbox_pb.js'; +export type { PolicyDocument } from './gen/policy_pb.js'; +export type { SettingValue } from './gen/sandbox_pb.js'; export type { ConnectOptions }; export { errorCode }; @@ -144,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 @@ -170,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 { @@ -373,7 +405,7 @@ export interface EffectiveSettingView { } export interface SandboxConfig { - policy?: SandboxPolicy; + policy?: PolicyDocument; version: number; policyHash: string; settings: Record; @@ -495,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, @@ -506,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 } : {}), @@ -871,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), @@ -895,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, }); @@ -1490,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 d858cd75b6..d8c41a2624 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 89e6223ac2..49a29eeafa 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 2cbc21b6b6..9aaa976bba 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 cefd2c375b..03ef7380ba 100644 --- a/tasks/scripts/generate_python_proto.py +++ b/tasks/scripts/generate_python_proto.py @@ -12,6 +12,7 @@ "proto/openshell.proto", "proto/datamodel.proto", "proto/options.proto", + "proto/policy.proto", "proto/sandbox.proto", ] @@ -35,6 +36,10 @@ r"^import options_pb2 as options__pb2$", "from . import options_pb2 as options__pb2", ), + ( + r"^import policy_pb2 as policy__pb2$", + "from . import policy_pb2 as policy__pb2", + ), ( r"^import sandbox_pb2 as sandbox__pb2$", "from . import sandbox_pb2 as sandbox__pb2", @@ -72,6 +77,18 @@ 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 0539bd88a0..41e3e10c88 100644 --- a/tasks/scripts/verify-python-wheel.py +++ b/tasks/scripts/verify-python-wheel.py @@ -13,7 +13,7 @@ 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/ansible/playbooks/conformance/cli.yaml b/tests/ansible/playbooks/conformance/cli.yaml index 4adf9aa954..07c78b3342 100644 --- a/tests/ansible/playbooks/conformance/cli.yaml +++ b/tests/ansible/playbooks/conformance/cli.yaml @@ -39,6 +39,29 @@ - conformance_archive.stat.isreg | default(false) fail_msg: OpenShell conformance test bundle did not contain tests.tar.zst + # The published community image still contains the legacy singular `port` + # policy field. This suite validates CLI lifecycle behavior, so provide a + # minimal current PolicyDocument explicitly and keep production parsing + # strict. Container-backed e2e suites separately cover image policy loading. + - name: Install PolicyDocument conformance fixture + become: true + ansible.builtin.copy: + content: | + version: 1 + filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + landlock: + compatibility: best_effort + process: + run_as_user: sandbox + run_as_group: sandbox + dest: /var/lib/openshell-conformance/policy-document-default.yaml + owner: tmachine + group: tmachine + mode: "0600" + - name: Run OpenShell conformance archive ansible.builtin.command: argv: @@ -52,6 +75,7 @@ - --no-capture environment: OPENSHELL_BIN: /usr/local/bin/openshell + OPENSHELL_SANDBOX_POLICY: /var/lib/openshell-conformance/policy-document-default.yaml register: conformance_result changed_when: false failed_when: false 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 cddc5b7bb3..29a99694a3 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 063ef739e6..6ac22bcb98 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"