From 15921fcd724c18e7ecbc71facd26d221e17600ac Mon Sep 17 00:00:00 2001 From: Vishwanath Raman Date: Thu, 15 Oct 2026 12:55:43 -0700 Subject: [PATCH 01/19] feat(policy): support Cedar as an alternate sandbox policy language A sandbox policy can be written in Cedar instead of YAML. The format is chosen once per sandbox: YAML policies keep using the Rego engine unchanged, and a .cedar policy makes Cedar the sole authority for network connections, per-request L7 checks, and Landlock filesystem grants. Policy model and validation: - New crates openshell-policy-cedar-schema (canonical Cedar schema) and openshell-policy-cedar (CedarEngine). Policies are validated in strict mode, and shapes that cannot be enforced exactly as written are rejected at submission in the CLI, the gateway, and the supervisor. - Load-time analysis derives Landlock grants, the endpoints and paths inspected per request (@protocol rest, json-rpc, mcp, graphql, websocket, or websocket-graphql, scoped by @path), native TCP endpoints (@transport("tcp")), and policy DNS eligibility, including delimited host globs. - Delimited like gives YAML-style segment globs for hosts, paths, and binaries. It comes from ai-openshell/cedar, pinned by commit, with a matching deny.toml allow-git exception; its Lean specification is ai-openshell/cedar-spec. - The schema declares only what the supervisor populates: NetworkConnect carries no request fields, Process has no workdir, and a has test on an undeclared attribute is rejected. A filesystem permit must list ReadFile with WriteFile, and resource.host_port names an endpoint for policy DNS. - @enforcement("audit") provides audit endpoints and staged audit policies; decisions name policies by their @id annotation. - Request policies match query parameters through context.query tags; repeated keys are evaluated per combination of values, as YAML matches every value. - context.destination_ip restricts the resolved addresses a connection may reach, the Cedar form of allowed_ips. Permits read it only as fixed isInRange lists; connections are evaluated before DNS and again per resolved address, private addresses need an address-constrained permit or an exact host, and always-blocked destinations stay blocked. - context.binary_aliases matches symlinked binaries, resolved inside the container as YAML does, and context.binary_globs holds the glob patterns that match the calling binary or an ancestor, so YAML binary globs translate exactly. - @enforcement("staged") marks a policy that is only compared against the enforced decision; @enforcement("audit") has YAML's audit-endpoint semantics. An HttpRequest policy may name its host with a delimited glob, inspecting every matching host as a YAML wildcard endpoint does. A GraphQL path is authoritative for the requests it parses. A delimited like whose ** touches other characters, and a GraphQL-over-WebSocket message permit without a GraphQL condition, are rejected at load. Integration: - SandboxPolicy gains cedar_policy_source, the gateway-managed provider_credential_rules, and endpoint_settings. Attached providers supply credentials and token grants on requests Cedar allows but never grant access; their allowed_ips only narrow addresses. Provider rules are compiled into Cedar policies tagged with their token-grant owner, so owner selection is decided by Cedar. - The CLI accepts .cedar policies and a separate --middleware file that also carries endpoint settings (TLS skip, body limits, MCP versions, persisted queries) and the process identity; policy get renders the Cedar text, middleware, endpoint settings, process identity, and provider rules. - The gateway rejects format switches, Cedar global policies, and removal of Cedar-derived Landlock grants on live sandboxes. - Cedar sandboxes run without an OPA engine or Rego. The supervisor builds the Cedar engine at startup admission, which owns middleware state and its own generation, commits Cedar, credentials, and middleware together on reload, and applies the policy validation failure mode to Cedar. Every network decision point, including the Windows host proxy, routes through the PolicyEngine enum; Cedar tunnels pin Cedar's generation. Middleware selection is shared Rust, the data both engines hand to the proxy is typed Rust with the Rego output converted at one boundary, and engine-neutral types move out of opa.rs. - The supervisor maps JSON-RPC, MCP, GraphQL, and WebSocket requests into Cedar requests the way the YAML engine sees them: uppercase methods, calls and response frames only in a POST, and one evaluation per call, GraphQL operation, or WebSocket message. - Cedar decisions report the policy DNS records that cover a connection, so transparent TCP correlation works as for YAML. - Cedar support is the default cedar feature of openshell-policy; crates that never handle Cedar policies disable it. The Go SDK carries the new fields. - The RPM vendor archive captures the git source replacement tables cargo vendor prints, so the offline RPM build resolves the Cedar dependency. Testing and documentation: - Unit tests for the Cedar crates and supervisor integration, and an e2e:cedar suite run by mise run e2e and the Docker E2E matrix. - A parity harness runs paired YAML and Cedar policies through both engines. It ports 258 of the 260 existing YAML decision tests; the remaining one that is valid YAML needs a Cedar language change. On Linux, 2,904 of 2,909 cases agree; the 5 differences are deliberate and recorded with their reasons, an evaluation error on either side fails a case, and load rejections are asserted. Token-grant owners match YAML for every shipped provider profile. - Every example and e2e directory with a YAML policy has a Cedar version beside it, and its scripts accept POLICY_FORMAT=cedar; a parity test runs each pair through both engines. - User documentation for Cedar policies, and a crate README. Signed-off-by: Vishwanath Raman --- .github/workflows/e2e-docker-test.yml | 2 +- .packit.yaml | 4 +- AGENTS.md | 2 + CONTRIBUTING.md | 2 + Cargo.lock | 717 ++- Cargo.toml | 7 + crates/openshell-cli/src/main.rs | 34 +- crates/openshell-cli/src/run.rs | 280 +- .../sandbox_create_lifecycle_integration.rs | 4 +- crates/openshell-core/src/policy_identity.rs | 312 +- crates/openshell-driver-kubernetes/Cargo.toml | 2 +- .../examples/e2e-policies/fs-empty.cedar | 13 + .../tests/policy_mapper_matrix.rs | 6 + crates/openshell-driver-vm/Cargo.toml | 2 +- crates/openshell-gateway/Cargo.toml | 2 +- .../openshell-policy-cedar-schema/Cargo.toml | 20 + .../schema/sandbox.cedarschema | 145 + .../src/error.rs | 24 + .../openshell-policy-cedar-schema/src/lib.rs | 229 + .../tests/schema_consistency.rs | 163 + crates/openshell-policy-cedar/Cargo.toml | 24 + crates/openshell-policy-cedar/README.md | 300 ++ crates/openshell-policy-cedar/src/analysis.rs | 2701 ++++++++++ crates/openshell-policy-cedar/src/error.rs | 342 ++ crates/openshell-policy-cedar/src/lib.rs | 1071 ++++ .../src/token_grants.rs | 1770 +++++++ crates/openshell-policy-cedar/tests/audit.rs | 319 ++ .../tests/binary_aliases.rs | 158 + .../tests/binary_globs.rs | 161 + .../tests/delimited_like.rs | 294 ++ .../tests/destination_ip.rs | 390 ++ .../tests/dns_eligibility.rs | 219 + .../tests/filesystem.rs | 213 + .../tests/fixtures/policies.cedar | 101 + crates/openshell-policy-cedar/tests/l7.rs | 80 + .../tests/l7_endpoints.rs | 204 + .../tests/l7_host_globs.rs | 371 ++ .../openshell-policy-cedar/tests/l7_paths.rs | 784 +++ .../openshell-policy-cedar/tests/network.rs | 117 + crates/openshell-policy-cedar/tests/query.rs | 356 ++ .../openshell-policy-cedar/tests/transport.rs | 308 ++ .../tests/validation.rs | 131 + crates/openshell-policy-schema/src/lib.rs | 23 + crates/openshell-policy/Cargo.toml | 8 + crates/openshell-policy/src/compose.rs | 31 +- crates/openshell-policy/src/lib.rs | 2184 +++++++- crates/openshell-policy/src/middleware.rs | 16 + crates/openshell-prover/src/containment.rs | 8 + crates/openshell-providers/Cargo.toml | 2 +- crates/openshell-sandbox/Cargo.toml | 2 +- crates/openshell-server/src/compute/mod.rs | 32 +- crates/openshell-server/src/grpc/policy.rs | 182 +- .../openshell-server/src/grpc/validation.rs | 239 + crates/openshell-server/src/storage_proto.rs | 18 +- .../openshell-supervisor-network/Cargo.toml | 3 + .../src/binary_resolution.rs | 279 + .../src/cedar_only.rs | 4658 +++++++++++++++++ .../src/cedar_parity_tests/examples.rs | 1138 ++++ .../src/cedar_parity_tests/mod.rs | 1582 ++++++ .../src/cedar_parity_tests/ported_opa_a.rs | 1662 ++++++ .../src/cedar_parity_tests/ported_opa_b.rs | 1200 +++++ .../src/cedar_parity_tests/ported_phase1.rs | 1016 ++++ .../src/cedar_parity_tests/ported_phase2.rs | 877 ++++ .../src/cedar_parity_tests/ported_phase3.rs | 1805 +++++++ .../src/cedar_parity_tests/ported_phase4.rs | 609 +++ .../src/cedar_parity_tests/ported_phase5.rs | 392 ++ .../src/cedar_parity_tests/ported_phase6.rs | 1184 +++++ .../src/cedar_parity_tests/ported_phase7.rs | 376 ++ .../cedar_parity_tests/ported_proxy_dns.rs | 644 +++ .../src/cedar_parity_tests/ported_relay.rs | 1276 +++++ .../src/cedar_parity_tests/provider_rules.rs | 552 ++ .../src/cedar_parity_tests/scenarios.rs | 500 ++ .../src/cedar_provider_rules.rs | 257 + .../openshell-supervisor-network/src/host.rs | 25 +- .../src/l7/mod.rs | 311 +- .../src/l7/relay.rs | 118 +- .../l7/relay/token_grant_ownership_tests.rs | 2 +- .../src/l7/rest.rs | 4 +- .../openshell-supervisor-network/src/lib.rs | 14 +- .../src/network_middleware.rs | 475 ++ .../openshell-supervisor-network/src/opa.rs | 1638 ++---- .../src/opa/raw_schema.rs | 3 +- .../src/policy_data.rs | 460 ++ .../src/policy_dns/mod.rs | 76 +- .../src/policy_dns/runtime.rs | 6 +- .../src/policy_engine.rs | 536 ++ .../src/policy_types.rs | 449 ++ .../src/policy_validation.rs | 142 + .../openshell-supervisor-network/src/proxy.rs | 714 ++- .../src/proxy/destination.rs | 573 +- .../src/proxy/egress.rs | 11 +- .../src/proxy/relay.rs | 116 +- .../src/proxy/tests/compatibility.rs | 7 +- .../openshell-supervisor-network/src/run.rs | 120 +- crates/openshell-supervisor/src/lib.rs | 684 ++- .../src/provider_readiness.rs | 40 +- crates/openshell-tui/Cargo.toml | 2 +- deny.toml | 3 +- deploy/rpm/create-vendor-archive.sh | 40 + .../supervisor-middleware/configure.mdx | 4 +- docs/how-it-works/policies/cedar.mdx | 499 ++ docs/index.yml | 3 + e2e/cedar/README.md | 64 + e2e/cedar/echo-server.py | 47 + e2e/cedar/middleware.template.yaml | 17 + e2e/cedar/policy.template.cedar | 71 + e2e/cedar/test.sh | 385 ++ e2e/mcp-conformance.sh | 33 +- e2e/mcp-conformance/README.md | 2 +- .../client-through-openshell.sh | 26 +- e2e/mcp-conformance/host-bridge.py | 1 + e2e/mcp-conformance/middleware-template.yaml | 19 + e2e/mcp-conformance/policy-template.cedar | 115 + e2e/mcp-conformance/render-policy.py | 34 +- e2e/policy-advisor/README.md | 6 + e2e/policy-advisor/middleware.yaml | 10 + e2e/policy-advisor/policy.template.cedar | 73 + e2e/policy-advisor/test.sh | 32 +- .../agent-driven-policy-management/README.md | 4 + .../agent-driven-policy-management/demo.sh | 22 +- .../policy.template.cedar | 160 + examples/governance-interceptor/Cargo.toml | 2 +- examples/governance-interceptor/README.md | 5 + examples/governance-interceptor/policy.cedar | 72 + examples/governance-interceptor/smoke.sh | 113 +- examples/governance-interceptor/src/main.rs | 88 +- .../src/smoke_client.rs | 70 +- examples/governance-interceptor/src/tests.rs | 49 + examples/jupyter-sandbox/README.md | 3 + examples/jupyter-sandbox/policy.cedar | 42 + examples/local-inference/README.md | 5 + examples/local-inference/sandbox-policy.cedar | 102 + examples/multi-agent-notepad/README.md | 3 +- examples/multi-agent-notepad/demo.sh | 14 +- .../multi-agent-notepad/policy.template.cedar | 167 + examples/policy-advisor/README.md | 1 + examples/policy-advisor/sandbox-policy.cedar | 33 + examples/sandbox-policy-quickstart/README.md | 6 + examples/sandbox-policy-quickstart/demo.sh | 59 +- .../sandbox-policy-quickstart/policy.cedar | 75 + .../Cargo.toml | 2 +- .../README.md | 10 + .../middleware.yaml | 23 + .../policy.cedar | 109 + .../smoke.sh | 38 +- examples/transparent-tcp-redis/README.md | 3 + examples/transparent-tcp-redis/demo.sh | 13 +- examples/transparent-tcp-redis/policy.cedar | 57 + openshell.spec | 7 + proto/openshell.proto | 4 +- proto/sandbox.proto | 29 + scripts/agents/gator/README.md | 2 +- scripts/agents/gator/middleware.yaml | 11 + scripts/agents/gator/policy.cedar | 45 + .../v1/internal/converter/coverage_test.go | 15 +- .../openshell/v1/internal/converter/policy.go | 46 +- .../v1/internal/converter/policy_test.go | 22 + sdk/go/openshell/v1/types/policy.go | 14 + sdk/go/proto/openshellv1/openshell.pb.go | 4 +- sdk/go/proto/sandboxv1/sandbox.pb.go | 174 +- tasks/test.toml | 12 +- 161 files changed, 42349 insertions(+), 2535 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.cedar create mode 100644 crates/openshell-policy-cedar-schema/Cargo.toml create mode 100644 crates/openshell-policy-cedar-schema/schema/sandbox.cedarschema create mode 100644 crates/openshell-policy-cedar-schema/src/error.rs create mode 100644 crates/openshell-policy-cedar-schema/src/lib.rs create mode 100644 crates/openshell-policy-cedar-schema/tests/schema_consistency.rs create mode 100644 crates/openshell-policy-cedar/Cargo.toml create mode 100644 crates/openshell-policy-cedar/README.md create mode 100644 crates/openshell-policy-cedar/src/analysis.rs create mode 100644 crates/openshell-policy-cedar/src/error.rs create mode 100644 crates/openshell-policy-cedar/src/lib.rs create mode 100644 crates/openshell-policy-cedar/src/token_grants.rs create mode 100644 crates/openshell-policy-cedar/tests/audit.rs create mode 100644 crates/openshell-policy-cedar/tests/binary_aliases.rs create mode 100644 crates/openshell-policy-cedar/tests/binary_globs.rs create mode 100644 crates/openshell-policy-cedar/tests/delimited_like.rs create mode 100644 crates/openshell-policy-cedar/tests/destination_ip.rs create mode 100644 crates/openshell-policy-cedar/tests/dns_eligibility.rs create mode 100644 crates/openshell-policy-cedar/tests/filesystem.rs create mode 100644 crates/openshell-policy-cedar/tests/fixtures/policies.cedar create mode 100644 crates/openshell-policy-cedar/tests/l7.rs create mode 100644 crates/openshell-policy-cedar/tests/l7_endpoints.rs create mode 100644 crates/openshell-policy-cedar/tests/l7_host_globs.rs create mode 100644 crates/openshell-policy-cedar/tests/l7_paths.rs create mode 100644 crates/openshell-policy-cedar/tests/network.rs create mode 100644 crates/openshell-policy-cedar/tests/query.rs create mode 100644 crates/openshell-policy-cedar/tests/transport.rs create mode 100644 crates/openshell-policy-cedar/tests/validation.rs create mode 100644 crates/openshell-supervisor-network/src/binary_resolution.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_only.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/examples.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/mod.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_a.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_b.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase1.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase2.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase3.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase4.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase5.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase6.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase7.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_proxy_dns.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/ported_relay.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/provider_rules.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_parity_tests/scenarios.rs create mode 100644 crates/openshell-supervisor-network/src/cedar_provider_rules.rs create mode 100644 crates/openshell-supervisor-network/src/network_middleware.rs create mode 100644 crates/openshell-supervisor-network/src/policy_data.rs create mode 100644 crates/openshell-supervisor-network/src/policy_engine.rs create mode 100644 crates/openshell-supervisor-network/src/policy_types.rs create mode 100644 crates/openshell-supervisor-network/src/policy_validation.rs create mode 100755 deploy/rpm/create-vendor-archive.sh create mode 100644 docs/how-it-works/policies/cedar.mdx create mode 100644 e2e/cedar/README.md create mode 100644 e2e/cedar/echo-server.py create mode 100644 e2e/cedar/middleware.template.yaml create mode 100644 e2e/cedar/policy.template.cedar create mode 100755 e2e/cedar/test.sh create mode 100644 e2e/mcp-conformance/middleware-template.yaml create mode 100644 e2e/mcp-conformance/policy-template.cedar create mode 100644 e2e/policy-advisor/middleware.yaml create mode 100644 e2e/policy-advisor/policy.template.cedar create mode 100644 examples/agent-driven-policy-management/policy.template.cedar create mode 100644 examples/governance-interceptor/policy.cedar create mode 100644 examples/jupyter-sandbox/policy.cedar create mode 100644 examples/local-inference/sandbox-policy.cedar create mode 100644 examples/multi-agent-notepad/policy.template.cedar create mode 100644 examples/policy-advisor/sandbox-policy.cedar create mode 100644 examples/sandbox-policy-quickstart/policy.cedar create mode 100644 examples/supervisor-middleware-content-guard/middleware.yaml create mode 100644 examples/supervisor-middleware-content-guard/policy.cedar create mode 100644 examples/transparent-tcp-redis/policy.cedar create mode 100644 scripts/agents/gator/middleware.yaml create mode 100644 scripts/agents/gator/policy.cedar diff --git a/.github/workflows/e2e-docker-test.yml b/.github/workflows/e2e-docker-test.yml index c7f21684a1..fca5327e42 100644 --- a/.github/workflows/e2e-docker-test.yml +++ b/.github/workflows/e2e-docker-test.yml @@ -29,7 +29,7 @@ on: required: false type: string default: >- - [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}] + [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true},{"suite":"mcp-cedar","cmd":"POLICY_FORMAT=cedar mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true},{"suite":"cedar","cmd":"mise run --no-deps --skip-deps e2e:cedar","apt_packages":"","python_proto":false,"mcp":false}] permissions: actions: read diff --git a/.packit.yaml b/.packit.yaml index d3b92eafae..88348841a7 100644 --- a/.packit.yaml +++ b/.packit.yaml @@ -28,7 +28,9 @@ actions: # reflects any patching that Packit may have done (e.g. version bumps). - 'bash -c "VERSION=${PACKIT_PROJECT_VERSION} && TMPDIR=$(mktemp -d) && DIR=openshell-${VERSION} && mkdir -p ${TMPDIR}/${DIR} && git ls-files -z | xargs -0 tar cf - | tar xf - -C ${TMPDIR}/${DIR}/ && tar -czf openshell-${VERSION}.tar.gz -C ${TMPDIR} ${DIR} && rm -rf ${TMPDIR}"' # Step 2: Create vendored Cargo dependencies tarball for offline RPM build. - - 'bash -c "VERSION=${PACKIT_PROJECT_VERSION} && CARGO_HTTP_TIMEOUT=600 CARGO_NET_RETRY=5 cargo vendor --locked --quiet && tar -cJf openshell-${VERSION}-vendor.tar.xz vendor/ && rm -rf vendor/"' + # The script also captures the [source."git+..."] replacement tables that + # cargo vendor prints, so git dependencies resolve offline in %prep. + - 'bash deploy/rpm/create-vendor-archive.sh' # Step 3: Return the primary archive name. Packit expects create-archive # to print one path for Source0; Source1 is patched explicitly below. - 'bash -c "echo openshell-${PACKIT_PROJECT_VERSION}.tar.gz"' diff --git a/AGENTS.md b/AGENTS.md index 0a21595b85..b5907e7222 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,8 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `crates/openshell-sandbox-backend/` | OpenShell sandbox backend | `OpenShellRuntimeBackend` and the authenticated OpenShell Sandbox Protocol shared with `openshell-sandbox` | | `crates/openshell-policy/` | Policy engine | Filesystem, network, and process constraints | | `crates/openshell-policy-schema/` | Authored policy schema | Dependency-light YAML/JSON representation, bounded parsing, and pure authored-language semantics | +| `crates/openshell-policy-cedar/` | Cedar policy engine | Validation, Landlock/L7/DNS derivation, and evaluation for Cedar-authored sandbox policies | +| `crates/openshell-policy-cedar-schema/` | Cedar policy schema | Canonical Cedar schema and entity, action, and context names | | `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage | | `crates/openshell-gateway-interceptors/` | Gateway interceptors | Intercepts and transforms configured gRPC requests at the gateway routing boundary | | `crates/openshell-ocsf/` | OCSF logging | OCSF v1.8.0 event types, builders, shorthand/JSONL formatters, tracing layers | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd6d69cda2..4aef8d279c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -337,6 +337,8 @@ These are the primary `mise` tasks for day-to-day development: | --------------- | --------------------------------------------- | | `crates/` | Rust crates | | `crates/openshell-policy-schema/` | Canonical authored policy DTOs and bounded YAML/JSON parser | +| `crates/openshell-policy-cedar/` | Validation and evaluation for Cedar-authored sandbox policies | +| `crates/openshell-policy-cedar-schema/` | Canonical Cedar schema and entity, action, and context names | | `crates/openshell-prover-cli/` | Standalone local policy boundary checker | | `python/` | Python SDK and bindings | | `sdk/go/` | Go SDK (types, gRPC clients, converters) | diff --git a/Cargo.lock b/Cargo.lock index cd0cfdf0fe..844a7b2d87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,6 +167,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object 0.39.1", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -188,6 +197,21 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -723,7 +747,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link", ] @@ -794,7 +818,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -803,6 +836,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -909,6 +948,25 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -998,6 +1056,67 @@ dependencies = [ "shlex", ] +[[package]] +name = "cedar-policy" +version = "4.13.0" +source = "git+https://github.com/ai-openshell/cedar.git?rev=3cba25c85a1aca0cfb74f9a59426122c11c18280#3cba25c85a1aca0cfb74f9a59426122c11c18280" +dependencies = [ + "cedar-policy-core", + "cedar-policy-formatter", + "itertools 0.15.0", + "linked-hash-map", + "linked_hash_set", + "miette", + "ref-cast", + "semver", + "serde", + "serde_json", + "serde_with", + "smol_str", + "thiserror 2.0.20", +] + +[[package]] +name = "cedar-policy-core" +version = "4.13.0" +source = "git+https://github.com/ai-openshell/cedar.git?rev=3cba25c85a1aca0cfb74f9a59426122c11c18280#3cba25c85a1aca0cfb74f9a59426122c11c18280" +dependencies = [ + "chrono", + "educe 0.8.1", + "either", + "itertools 0.15.0", + "lalrpop", + "lalrpop-util", + "linked-hash-map", + "linked_hash_set", + "miette", + "nonempty", + "ref-cast", + "regex", + "rustc-literal-escaper", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 2.0.20", + "unicode-security", +] + +[[package]] +name = "cedar-policy-formatter" +version = "4.13.0" +source = "git+https://github.com/ai-openshell/cedar.git?rev=3cba25c85a1aca0cfb74f9a59426122c11c18280#3cba25c85a1aca0cfb74f9a59426122c11c18280" +dependencies = [ + "cedar-policy-core", + "itertools 0.15.0", + "logos", + "miette", + "pretty", + "regex", + "smol_str", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -1517,6 +1636,16 @@ dependencies = [ "darling_macro 0.20.11", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling" version = "0.24.1" @@ -1541,6 +1670,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_core" version = "0.24.1" @@ -1565,6 +1707,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.24.1" @@ -1620,6 +1773,37 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "delegate" version = "0.13.5" @@ -1680,6 +1864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -1911,11 +2096,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "educe" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebdb1f8f8d2815bbe7b369a7da10343c82026a4503b68bd1279ba557611e96d" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "either" -version = "1.15.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" dependencies = [ "serde", ] @@ -1963,6 +2160,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -2083,7 +2289,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] @@ -2500,13 +2706,19 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -3049,6 +3261,17 @@ dependencies = [ "quote", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -3172,12 +3395,75 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -3367,6 +3653,15 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "keccak" version = "0.2.0" @@ -3483,7 +3778,7 @@ dependencies = [ "http 1.4.0", "json-patch 4.2.0", "k8s-openapi", - "schemars", + "schemars 0.8.22", "serde", "serde-value", "serde_json", @@ -3515,7 +3810,7 @@ dependencies = [ "async-stream", "async-trait", "backon", - "educe", + "educe 0.6.0", "futures", "hashbrown 0.15.5", "hostname", @@ -3538,6 +3833,38 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set 0.8.0", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph 0.7.1", + "pico-args", + "regex", + "regex-syntax", + "sha3 0.10.9", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "landlock" version = "0.4.4" @@ -3624,6 +3951,24 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +dependencies = [ + "serde", +] + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3663,6 +4008,38 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + [[package]] name = "lru" version = "0.18.5" @@ -3769,7 +4146,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "indexmap", + "indexmap 2.14.0", "ipnet", "metrics", "metrics-util", @@ -3806,6 +4183,7 @@ dependencies = [ "cfg-if", "miette-derive", "owo-colors", + "serde", "supports-color", "supports-hyperlinks", "supports-unicode", @@ -3899,6 +4277,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.29.0" @@ -3947,6 +4331,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + [[package]] name = "notify" version = "8.2.0" @@ -3981,7 +4374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" dependencies = [ "hashbrown 0.17.0", - "indexmap", + "indexmap 2.14.0", "libm", "memchr", "rustc-hash 2.1.2", @@ -4137,6 +4530,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "oci-client" version = "0.16.1" @@ -4731,12 +5133,37 @@ dependencies = [ "hickory-proto", "miette", "openshell-core", + "openshell-policy-cedar", "openshell-policy-schema", "prost-types", "serde", "serde_json", ] +[[package]] +name = "openshell-policy-cedar" +version = "0.0.0" +dependencies = [ + "cedar-policy", + "globset", + "ipnet", + "miette", + "openshell-core", + "openshell-policy-cedar-schema", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "openshell-policy-cedar-schema" +version = "0.0.0" +dependencies = [ + "cedar-policy", + "miette", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "openshell-policy-schema" version = "0.0.0" @@ -5084,6 +5511,7 @@ dependencies = [ "flate2", "futures", "glob", + "globset", "hex", "hickory-proto", "http 1.4.0", @@ -5099,7 +5527,9 @@ dependencies = [ "openshell-ocsf", "openshell-otel", "openshell-policy", + "openshell-policy-cedar", "openshell-policy-schema", + "openshell-providers", "openshell-supervisor-middleware", "openshell-supervisor-middleware-builtins", "opentelemetry_sdk", @@ -5166,7 +5596,7 @@ dependencies = [ "base64 0.22.1", "crossterm 0.28.1", "futures", - "indexmap", + "indexmap 2.14.0", "miette", "openshell-bootstrap", "openshell-core", @@ -5548,6 +5978,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -5556,7 +5996,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -5567,7 +6007,7 @@ checksum = "9cd31dcfdbbd7431a807ef4df6edd6473228e94d5c805e8cf671227a21bad068" dependencies = [ "anyhow", "clap", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "rand 0.8.6", @@ -5635,6 +6075,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.11" @@ -5781,6 +6227,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -5811,6 +6266,23 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec", + "typed-arena", + "unicode-width 0.2.2", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -5905,10 +6377,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", "prost", "prost-types", @@ -5926,7 +6398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6018,6 +6490,16 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.13.3" @@ -6250,7 +6732,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.0", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -6305,7 +6787,7 @@ dependencies = [ "hashbrown 0.17.0", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -6356,11 +6838,31 @@ dependencies = [ "bitflags 2.13.2", ] +[[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" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -6387,9 +6889,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regorus" @@ -6611,7 +7113,7 @@ dependencies = [ "hex-literal", "hmac 0.13.0", "inout", - "keccak", + "keccak 0.2.0", "log", "md5", "ml-kem", @@ -6711,6 +7213,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc-literal-escaper" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe6f213fb658c8fb95baabd5420393438cf5a98d707f5dd701d9197c705f71e" + [[package]] name = "rustc_version" version = "0.4.1" @@ -6893,6 +7401,30 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -7065,7 +7597,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -7116,13 +7648,46 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -7183,6 +7748,16 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + [[package]] name = "sha3" version = "0.11.0" @@ -7190,7 +7765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.2", - "keccak", + "keccak 0.2.0", ] [[package]] @@ -7200,7 +7775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.2", - "keccak", + "keccak 0.2.0", "sponge-cursor", ] @@ -7343,6 +7918,16 @@ dependencies = [ "serde", ] +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.6.3" @@ -7449,7 +8034,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "percent-encoding", @@ -7655,12 +8240,37 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.60.2", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -7836,6 +8446,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "termina" version = "0.3.3" @@ -8192,7 +8811,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", @@ -8310,7 +8929,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -8356,7 +8975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bafe456e348e69895f4d0f1731a8f3094075f0e5f0a3f7d5f3c2443a0412b035" dependencies = [ "base64 0.23.1", - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "thiserror 2.0.20", @@ -8512,6 +9131,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typed-path" version = "0.12.3" @@ -8569,6 +9194,22 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -8581,7 +9222,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width 0.2.2", ] @@ -8829,7 +9470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -8855,7 +9496,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.13.2", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -9413,7 +10054,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -9444,7 +10085,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.13.2", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -9463,7 +10104,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -9703,7 +10344,7 @@ dependencies = [ "flate2", "getrandom 0.4.2", "hmac 0.13.0", - "indexmap", + "indexmap 2.14.0", "lzma-rust2", "memchr", "pbkdf2", diff --git a/Cargo.toml b/Cargo.toml index b37051dd89..d7c820694c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,13 @@ apollo-parser = "0.8.5" tower-mcp-types = "=0.22.2" regex = "1" +# Policy +# OpenShell's Cedar in the OpenShell CNCF workspace, pinned by commit. Policy +# language features (delimited `like` today, tracked upstream as +# cedar-policy/rfcs#115) are developed there with the Cedar team. The Lean +# specification is ai-openshell/cedar-spec. +cedar-policy = { git = "https://github.com/ai-openshell/cedar.git", rev = "3cba25c85a1aca0cfb74f9a59426122c11c18280" } + # HTTP client reqwest = { version = "0.12.28", default-features = false, features = ["json", "rustls-tls-native-roots-no-provider"] } diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 5f20fc7f3c..4940efff5a 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1528,11 +1528,18 @@ enum SandboxCommands { #[arg(long = "provider")] providers: Vec, - /// Path to a custom sandbox policy YAML file. + /// Path to a custom sandbox policy file: YAML, or Cedar with a `.cedar` extension. /// Overrides the built-in default and the `OPENSHELL_SANDBOX_POLICY` env var. #[arg(long, value_hint = ValueHint::FilePath)] policy: Option, + /// Path to a middleware file: YAML with `network_middlewares` and, for a Cedar + /// policy, `endpoint_settings` and `process` sections. Adds supervisor middleware, + /// endpoint settings, and the process identity to the policy; required to use + /// middleware with a Cedar policy. + #[arg(long, value_hint = ValueHint::FilePath)] + middleware: Option, + /// Forward a local port to the sandbox before the initial command or shell starts. /// Accepts [`bind_address`:]port (e.g. 8080, 0.0.0.0:8080). Keeps the sandbox alive. #[arg(long, conflicts_with = "no_keep")] @@ -1637,7 +1644,7 @@ enum SandboxCommands { #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, - /// Print only the active policy YAML (same policy as the default view; stdout only). + /// Print only the active policy, as YAML or Cedar source (stdout only). #[arg(long, conflicts_with = "output")] policy_only: bool, @@ -2135,10 +2142,18 @@ enum PolicyCommands { #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, - /// Path to the policy YAML file. + /// Path to the policy file: YAML, or Cedar with a `.cedar` extension. + /// Global policies must be YAML. #[arg(long, value_hint = ValueHint::FilePath)] policy: String, + /// Path to a middleware file: YAML with `network_middlewares` and, for a Cedar + /// policy, `endpoint_settings` and `process` sections. Adds supervisor middleware, + /// endpoint settings, and the process identity to the policy; required to use + /// middleware with a Cedar policy. + #[arg(long, value_hint = ValueHint::FilePath)] + middleware: Option, + /// Apply as a gateway-global policy for all sandboxes. #[arg(long)] global: bool, @@ -3045,6 +3060,7 @@ async fn run_async() -> Result<()> { PolicyCommands::Set { name, policy, + middleware, global, yes, wait, @@ -3059,7 +3075,10 @@ async fn run_async() -> Result<()> { } run::sandbox_policy_set_global( &ctx.endpoint, - &policy, + run::PolicyFiles { + policy: &policy, + middleware: middleware.as_deref(), + }, yes, wait, timeout, @@ -3072,7 +3091,10 @@ async fn run_async() -> Result<()> { run::sandbox_policy_set( &ctx.endpoint, &name, - &policy, + run::PolicyFiles { + policy: &policy, + middleware: middleware.as_deref(), + }, wait, timeout, &cli.workspace, @@ -3368,6 +3390,7 @@ async fn run_async() -> Result<()> { driver_config_json, providers, policy, + middleware, forward, expose, expose_authorization_mode, @@ -3464,6 +3487,7 @@ async fn run_async() -> Result<()> { editor, providers: &providers, policy: policy.as_deref(), + middleware: middleware.as_deref(), forward, expose, expose_authorization_mode: expose_authorization_mode.into(), diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index f87ee7b709..dac43b1bb6 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -443,6 +443,7 @@ pub struct SandboxCreateConfig<'a> { pub editor: Option, pub providers: &'a [String], pub policy: Option<&'a str>, + pub middleware: Option<&'a str>, pub forward: Option, pub expose: Option, pub expose_authorization_mode: ServiceAuthorizationMode, @@ -473,6 +474,7 @@ impl Default for SandboxCreateConfig<'_> { editor: None, providers: &[], policy: None, + middleware: None, forward: None, expose: None, expose_authorization_mode: ServiceAuthorizationMode::Strip, @@ -511,6 +513,7 @@ pub async fn sandbox_create( editor, providers, policy, + middleware, forward, expose, expose_authorization_mode, @@ -614,7 +617,7 @@ pub async fn sandbox_create( ensure_required_providers(&mut client, providers, auto_providers_override, workspace) .await?; - let policy = load_sandbox_policy(policy)?; + let policy = load_sandbox_policy_with_middleware(policy, middleware)?; let resource_limits = if template.is_none() { build_sandbox_resource_limits(cpu, memory)? } else { @@ -1519,13 +1522,103 @@ fn merge_rootfs_tar_driver_config( Ok(config) } -/// Load sandbox policy YAML. +/// Load a sandbox policy file, YAML or Cedar. /// /// 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. +/// to apply its own default. A Cedar policy's load-time warnings are printed +/// to stderr; they never fail the load. fn load_sandbox_policy(cli_path: Option<&str>) -> Result> { - openshell_policy::load_sandbox_policy(cli_path) + let policy = openshell_policy::load_sandbox_policy(cli_path)?; + if let Some(policy) = &policy { + for warning in cedar_policy_warning_lines(policy) { + eprintln!("{} {warning}", "warning:".yellow().bold()); + } + } + Ok(policy) +} + +/// The load-time warnings of a policy's Cedar source, if it has one. +fn cedar_policy_warning_lines(policy: &SandboxPolicy) -> Vec { + if policy.cedar_policy_source.is_empty() { + return Vec::new(); + } + openshell_policy::cedar_policy_warnings(&policy.cedar_policy_source) +} + +/// Renders a policy for `policy get`. +/// +/// A YAML policy renders as YAML. A Cedar policy renders as its Cedar text, +/// followed by its middleware, endpoint settings, and process identity as a +/// separate section, since they are configuration supplied with +/// `--middleware` rather than part of the policy. +/// When present (the `--full` view), the gateway-managed provider rules follow +/// in a section of their own; they supply credential settings and grant no +/// access. +fn render_policy_text(policy: &SandboxPolicy) -> Result { + let mut text = openshell_policy::serialize_sandbox_policy(policy) + .wrap_err("failed to serialize policy")?; + if policy.cedar_policy_source.is_empty() { + return Ok(text); + } + if let Some(middleware) = openshell_policy::serialize_network_middlewares(policy) + .wrap_err("failed to serialize middleware")? + { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str( + "---\n# Middleware, endpoint settings, and process identity (set with --middleware)\n", + ); + text.push_str(&middleware); + } + if let Some(rules) = openshell_policy::serialize_provider_credential_rules(policy) + .wrap_err("failed to serialize provider credential rules")? + { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str( + "---\n# Provider credential rules (managed by the gateway; they grant no access)\n", + ); + text.push_str(&rules); + if !text.ends_with('\n') { + text.push('\n'); + } + } + Ok(text) +} + +/// The files `policy set` reads: a policy file and an optional middleware file. +#[derive(Debug, Clone, Copy)] +pub struct PolicyFiles<'a> { + /// Path to a YAML or `.cedar` policy file. + pub policy: &'a str, + /// Path to a middleware file, if any. + pub middleware: Option<&'a str>, +} + +impl PolicyFiles<'_> { + /// Loads the policy and adds the middleware file, if any. + fn load(self) -> Result { + load_sandbox_policy_with_middleware(Some(self.policy), self.middleware)? + .ok_or_else(|| miette::miette!("No policy loaded from {}", self.policy)) + } +} + +/// Loads the policy and adds the middleware from `middleware_path`, if given. +fn load_sandbox_policy_with_middleware( + policy_path: Option<&str>, + middleware_path: Option<&str>, +) -> Result> { + let mut policy = load_sandbox_policy(policy_path)?; + if let Some(middleware_path) = middleware_path { + let policy = policy.as_mut().ok_or_else(|| { + miette::miette!("--middleware requires a policy (--policy or OPENSHELL_SANDBOX_POLICY)") + })?; + openshell_policy::apply_middleware_file(policy, Path::new(middleware_path))?; + } + Ok(policy) } /// Sync files to or from a sandbox. @@ -5300,7 +5393,7 @@ pub async fn sandbox_upload( pub async fn sandbox_policy_set_global( server: &str, - policy_path: &str, + files: PolicyFiles<'_>, yes: bool, wait: bool, _timeout_secs: u64, @@ -5315,8 +5408,7 @@ pub async fn sandbox_policy_set_global( confirm_global_setting_takeover("policy", yes)?; - let policy = load_sandbox_policy(Some(policy_path))? - .ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?; + let policy = files.load()?; let mut client = grpc_client(server, tls).await?; let response = client @@ -5645,14 +5737,13 @@ pub async fn sandbox_setting_delete( pub async fn sandbox_policy_set( server: &str, name: &str, - policy_path: &str, + files: PolicyFiles<'_>, wait: bool, timeout_secs: u64, workspace: &str, tls: &TlsOptions, ) -> Result<()> { - let policy = load_sandbox_policy(Some(policy_path))? - .ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?; + let policy = files.load()?; let mut client = grpc_client(server, tls).await?; @@ -6066,8 +6157,7 @@ where 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()) - .wrap_err("failed to serialize policy to YAML")?; + let yaml_str = render_policy_text(policy.as_ref())?; write!(stdout, "{yaml_str}").into_diagnostic()?; } else { writeln!(stderr, "Policy payload not available for this version") @@ -6174,8 +6264,7 @@ where if view.includes_policy() { writeln!(stdout, "---").into_diagnostic()?; let policy = policy_for_view(policy, view); - let yaml_str = openshell_policy::serialize_sandbox_policy(policy.as_ref()) - .wrap_err("failed to serialize policy to YAML")?; + let yaml_str = render_policy_text(policy.as_ref())?; write!(stdout, "{yaml_str}").into_diagnostic()?; } } @@ -6236,8 +6325,7 @@ pub async fn sandbox_policy_get_global( 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()) - .wrap_err("failed to serialize policy to YAML")?; + let yaml_str = render_policy_text(policy.as_ref())?; print!("{yaml_str}"); } else { eprintln!("Policy payload not available for this version"); @@ -6322,9 +6410,7 @@ fn policy_for_view(policy: &SandboxPolicy, view: PolicyGetView) -> Cow<'_, Sandb } let mut base_policy = policy.clone(); - base_policy - .network_policies - .retain(|name, _| !openshell_policy::is_provider_rule_name(name)); + openshell_policy::strip_provider_rule_names(&mut base_policy); Cow::Owned(base_policy) } @@ -7014,14 +7100,15 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String mod tests { use super::{ ForwardTcpConnectionError, PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, - format_endpoint, format_log_line, forward_requires_session_token, git_sync_files, - has_main_process_result, parse_cli_setting_value, parse_credential_expiry_cli_value, - parse_driver_config_json, parse_env_from_pairs, parse_secret_material_env_pairs, + cedar_policy_warning_lines, format_endpoint, format_log_line, + forward_requires_session_token, git_sync_files, has_main_process_result, + parse_cli_setting_value, parse_credential_expiry_cli_value, parse_driver_config_json, + parse_env_from_pairs, parse_secret_material_env_pairs, policy_for_view, policy_revision_list_json, policy_revision_to_json, proto_execution_timeout, provisioning_timeout_message, ready_false_condition_message, relay_local_socket, - resolve_from, rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, - sandbox_upload_plan, service_endpoint_to_json, service_status_error, - service_url_for_gateway, workspace_member_to_json, + render_policy_text, resolve_from, rootfs_tar_sources_supported_for_gateway, + sandbox_should_persist, sandbox_upload_plan, service_endpoint_to_json, + service_status_error, service_url_for_gateway, workspace_member_to_json, }; use openshell_core::proto::TcpForwardFrame; @@ -9353,4 +9440,147 @@ mod tests { "authorization_token is required for ForwardTcp" ))); } + + #[test] + fn cedar_policy_warning_lines_report_an_audit_only_path_beside_enforced_policies() { + let http = |annotations: &str, effect: &str, condition: &str| { + format!( + "{annotations}\n{effect} (principal, action == Sandbox::Action::\"HttpRequest\", \ + resource == Sandbox::NetworkEndpoint::\"api.example.com:443\")\nwhen {{ {condition} }};\n" + ) + }; + let trap = format!( + "{}{}", + http("", "permit", "context.method == \"GET\""), + http( + "@path(\"/admin/**\") @enforcement(\"audit\")", + "forbid", + "context.method == \"DELETE\"" + ), + ); + let policy = SandboxPolicy { + version: 1, + cedar_policy_source: trap, + ..Default::default() + }; + let warnings = cedar_policy_warning_lines(&policy); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0].starts_with("path `/admin/**` of api.example.com:443 is audit-only"), + "{}", + warnings[0] + ); + + let enforced = SandboxPolicy { + version: 1, + cedar_policy_source: http("", "permit", "context.method == \"GET\""), + ..Default::default() + }; + assert!(cedar_policy_warning_lines(&enforced).is_empty()); + assert!(cedar_policy_warning_lines(&SandboxPolicy::default()).is_empty()); + } + + #[test] + fn render_policy_text_shows_cedar_text_then_middleware() { + let source = "permit (principal, action == Sandbox::Action::\"NetworkConnect\", \ + resource == Sandbox::NetworkEndpoint::\"pypi.org:443\");"; + let mut policy = SandboxPolicy { + version: 1, + cedar_policy_source: source.to_string(), + ..Default::default() + }; + assert_eq!(render_policy_text(&policy).expect("render"), source); + + policy.network_middlewares.insert( + "guard".to_string(), + openshell_core::proto::NetworkMiddlewareConfig { + middleware: "openshell/regex".to_string(), + order: 10, + ..Default::default() + }, + ); + let rendered = render_policy_text(&policy).expect("render"); + let (cedar, middleware) = rendered + .split_once("---\n") + .expect("middleware is a separate section"); + assert_eq!(cedar.trim_end(), source); + assert!(middleware.contains("network_middlewares:"), "{middleware}"); + assert!(middleware.contains("guard"), "{middleware}"); + + policy + .endpoint_settings + .push(openshell_core::proto::NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + ports: vec![443], + allow_encoded_slash: true, + ..Default::default() + }); + let rendered = render_policy_text(&policy).expect("render"); + let (_, settings) = rendered + .split_once("---\n") + .expect("middleware is a separate section"); + assert!(settings.contains("endpoint_settings:"), "{settings}"); + assert!(settings.contains("allow_encoded_slash: true"), "{settings}"); + let reparsed = openshell_policy::parse_middleware_yaml( + settings + .split_once('\n') + .map(|(_, body)| body) + .expect("heading line"), + ) + .expect("the rendered section is a middleware file"); + assert_eq!(reparsed.endpoint_settings, policy.endpoint_settings); + + policy.process = Some(openshell_core::proto::ProcessPolicy { + run_as_user: "1000".to_string(), + run_as_group: "sandbox".to_string(), + }); + let rendered = render_policy_text(&policy).expect("render"); + let (cedar, section) = rendered + .split_once("---\n") + .expect("middleware is a separate section"); + assert_eq!( + cedar.trim_end(), + source, + "process stays out of the Cedar text" + ); + assert!(section.contains("process:"), "{section}"); + assert!(section.contains("run_as_user: \"1000\""), "{section}"); + let reparsed = openshell_policy::parse_middleware_yaml( + section + .split_once('\n') + .map(|(_, body)| body) + .expect("heading line"), + ) + .expect("the rendered section is a middleware file"); + assert_eq!(reparsed.process, policy.process); + + policy.provider_credential_rules.insert( + "_provider_work".to_string(), + openshell_core::proto::NetworkPolicyRule { + name: "_provider_work".to_string(), + endpoints: vec![openshell_core::proto::NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + provider_credentialed: true, + ..Default::default() + }], + ..Default::default() + }, + ); + let full = render_policy_text(&policy_for_view(&policy, PolicyGetView::Full)) + .expect("render full view"); + let sections: Vec<&str> = full.split("---\n").collect(); + assert_eq!(sections.len(), 3, "{full}"); + assert!( + sections[2].contains("# Credentialed endpoints: pypi.org:443"), + "{full}" + ); + assert!(sections[2].contains("_provider_work"), "{full}"); + + let base = render_policy_text(&policy_for_view(&policy, PolicyGetView::Base)) + .expect("render base view"); + assert!(!base.contains("provider_credential_rules"), "{base}"); + assert!(base.contains("network_middlewares:"), "{base}"); + } } diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 625d944fa4..e231f80bf5 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -2993,7 +2993,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { install_fake_forwarding_ssh(&fake_ssh_dir); let (endpoint, tls) = (server.endpoint.as_str(), &tls); let (forward_port, result) = with_free_forward_port(|spec| async move { - run::sandbox_create( + Box::pin(run::sandbox_create( endpoint, "openshell", run::SandboxCreateConfig { @@ -3005,7 +3005,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { }, "default", tls, - ) + )) .await }) .await; diff --git a/crates/openshell-core/src/policy_identity.rs b/crates/openshell-core/src/policy_identity.rs index c9dda96501..d738810c8b 100644 --- a/crates/openshell-core/src/policy_identity.rs +++ b/crates/openshell-core/src/policy_identity.rs @@ -4,7 +4,8 @@ //! Canonical policy encoding and deterministic policy hashes. use crate::proto::{ - NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy as ProtoSandboxPolicy, + NetworkEndpoint, NetworkMiddlewareConfig, NetworkPolicyRule, + SandboxPolicy as ProtoSandboxPolicy, }; use prost::Message; use sha2::{Digest, Sha256}; @@ -41,48 +42,73 @@ fn append_sorted_message_map( } } +/// Remove every protobuf map nested inside an endpoint so prost encodes it deterministically. +fn clear_endpoint_maps(endpoint: &mut NetworkEndpoint) { + endpoint.graphql_persisted_queries.clear(); + for rule in &mut endpoint.rules { + if let Some(allow) = &mut rule.allow { + allow.query.clear(); + allow.params.clear(); + } + } + for deny in &mut endpoint.deny_rules { + deny.query.clear(); + deny.params.clear(); + } +} + +/// Append the maps removed by [`clear_endpoint_maps`] in sorted key order. +fn append_sorted_endpoint_maps(out: &mut Vec, endpoint: &NetworkEndpoint) { + append_sorted_message_map( + out, + b"graphql_persisted_queries", + &endpoint.graphql_persisted_queries, + ); + for (rule_index, rule) in endpoint.rules.iter().enumerate() { + out.extend_from_slice(&canonical_size(rule_index)); + if let Some(allow) = &rule.allow { + append_sorted_message_map(out, b"allow_query", &allow.query); + append_sorted_message_map(out, b"allow_params", &allow.params); + } + } + for (rule_index, deny) in endpoint.deny_rules.iter().enumerate() { + out.extend_from_slice(&canonical_size(rule_index)); + append_sorted_message_map(out, b"deny_query", &deny.query); + append_sorted_message_map(out, b"deny_params", &deny.params); + } +} + /// Encode a policy rule without depending on randomized protobuf map order. pub fn canonical_rule_bytes(rule: &NetworkPolicyRule) -> Vec { let mut map_free = rule.clone(); for endpoint in &mut map_free.endpoints { - endpoint.graphql_persisted_queries.clear(); - for rule in &mut endpoint.rules { - if let Some(allow) = &mut rule.allow { - allow.query.clear(); - allow.params.clear(); - } - } - for deny in &mut endpoint.deny_rules { - deny.query.clear(); - deny.params.clear(); - } + clear_endpoint_maps(endpoint); } let mut out = Vec::new(); append_canonical_message(&mut out, &map_free); for (endpoint_index, endpoint) in rule.endpoints.iter().enumerate() { out.extend_from_slice(&canonical_size(endpoint_index)); - append_sorted_message_map( - &mut out, - b"graphql_persisted_queries", - &endpoint.graphql_persisted_queries, - ); - for (rule_index, rule) in endpoint.rules.iter().enumerate() { - out.extend_from_slice(&canonical_size(rule_index)); - if let Some(allow) = &rule.allow { - append_sorted_message_map(&mut out, b"allow_query", &allow.query); - append_sorted_message_map(&mut out, b"allow_params", &allow.params); - } - } - for (rule_index, deny) in endpoint.deny_rules.iter().enumerate() { - out.extend_from_slice(&canonical_size(rule_index)); - append_sorted_message_map(&mut out, b"deny_query", &deny.query); - append_sorted_message_map(&mut out, b"deny_params", &deny.params); - } + append_sorted_endpoint_maps(&mut out, endpoint); } out } +fn append_sorted_rule_map( + out: &mut Vec, + label: &[u8], + rules: &HashMap, +) { + let mut entries = rules.iter().collect::>(); + entries.sort_by_key(|(key, _)| key.as_str()); + append_canonical_bytes(out, label); + out.extend_from_slice(&canonical_size(entries.len())); + for (key, rule) in entries { + append_canonical_bytes(out, key.as_bytes()); + append_canonical_bytes(out, &canonical_rule_bytes(rule)); + } +} + fn canonical_struct_bytes(value: &prost_types::Struct) -> Vec { let mut out = Vec::new(); let mut fields = value.fields.iter().collect::>(); @@ -147,17 +173,14 @@ fn canonical_policy_bytes(policy: &ProtoSandboxPolicy) -> Vec { let mut map_free = policy.clone(); map_free.network_policies.clear(); map_free.network_middlewares.clear(); + map_free.provider_credential_rules.clear(); + for endpoint in &mut map_free.endpoint_settings { + clear_endpoint_maps(endpoint); + } let mut out = Vec::new(); append_canonical_message(&mut out, &map_free); - let mut policy_entries = policy.network_policies.iter().collect::>(); - policy_entries.sort_by_key(|(key, _)| key.as_str()); - append_canonical_bytes(&mut out, b"network_policies"); - out.extend_from_slice(&canonical_size(policy_entries.len())); - for (key, rule) in policy_entries { - append_canonical_bytes(&mut out, key.as_bytes()); - append_canonical_bytes(&mut out, &canonical_rule_bytes(rule)); - } + append_sorted_rule_map(&mut out, b"network_policies", &policy.network_policies); let mut middleware_entries = policy.network_middlewares.iter().collect::>(); middleware_entries.sort_by_key(|(key, _)| key.as_str()); @@ -167,6 +190,24 @@ fn canonical_policy_bytes(policy: &ProtoSandboxPolicy) -> Vec { append_canonical_bytes(&mut out, key.as_bytes()); append_canonical_bytes(&mut out, &canonical_middleware_bytes(middleware)); } + + // The Cedar-only sections below are appended only when populated so that + // every policy hash computed before these fields existed stays unchanged. + if !policy.provider_credential_rules.is_empty() { + append_sorted_rule_map( + &mut out, + b"provider_credential_rules", + &policy.provider_credential_rules, + ); + } + if !policy.endpoint_settings.is_empty() { + append_canonical_bytes(&mut out, b"endpoint_settings"); + out.extend_from_slice(&canonical_size(policy.endpoint_settings.len())); + for (endpoint_index, endpoint) in policy.endpoint_settings.iter().enumerate() { + out.extend_from_slice(&canonical_size(endpoint_index)); + append_sorted_endpoint_maps(&mut out, endpoint); + } + } out } @@ -246,10 +287,205 @@ fn token_grant_owner(domain: &[u8], identity: &[u8], policy: &[u8], index: usize format!("grant-owner:v1:{:x}", Sha256::digest(input)) } +#[cfg(test)] +mod policy_hash_tests { + use super::*; + use crate::proto::{GraphqlOperation, NetworkBinary}; + + const ROUND_TRIPS: usize = 50; + + fn provider_rule(host: &str) -> NetworkPolicyRule { + NetworkPolicyRule { + endpoints: vec![NetworkEndpoint { + host: host.into(), + path: "/v1/**".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/client".into(), + }], + ..Default::default() + } + } + + fn operation(name: &str) -> GraphqlOperation { + GraphqlOperation { + operation_type: "query".into(), + operation_name: name.into(), + fields: vec![name.to_ascii_lowercase()], + } + } + + fn cedar_policy_with_provider_rules() -> ProtoSandboxPolicy { + ProtoSandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".into(), + provider_credential_rules: HashMap::from([ + ("alpha".into(), provider_rule("alpha.example.test")), + ("beta".into(), provider_rule("beta.example.test")), + ]), + ..Default::default() + } + } + + fn cedar_policy_with_endpoint_settings() -> ProtoSandboxPolicy { + ProtoSandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".into(), + endpoint_settings: vec![NetworkEndpoint { + host: "graphql.example.test".into(), + graphql_persisted_queries: HashMap::from([ + ("GetUser".into(), operation("GetUser")), + ("ListRepos".into(), operation("ListRepos")), + ]), + ..Default::default() + }], + ..Default::default() + } + } + + /// Hash the policy after each of many protobuf round-trips, which reshuffle + /// `HashMap` iteration order, and return the distinct hashes observed. + fn round_trip_hashes(policy: &ProtoSandboxPolicy) -> std::collections::BTreeSet { + let mut hashes = std::collections::BTreeSet::new(); + let mut current = policy.clone(); + for _ in 0..ROUND_TRIPS { + current = ProtoSandboxPolicy::decode(current.encode_to_vec().as_slice()) + .expect("policy round-trips through protobuf"); + hashes.insert(deterministic_policy_hash(¤t)); + } + hashes + } + + #[test] + fn provider_credential_rules_hash_is_stable_across_round_trips() { + let hashes = round_trip_hashes(&cedar_policy_with_provider_rules()); + assert_eq!(hashes.len(), 1, "distinct hashes observed: {hashes:?}"); + } + + #[test] + fn endpoint_settings_persisted_queries_hash_is_stable_across_round_trips() { + let hashes = round_trip_hashes(&cedar_policy_with_endpoint_settings()); + assert_eq!(hashes.len(), 1, "distinct hashes observed: {hashes:?}"); + } + + #[test] + fn cedar_map_insertion_order_does_not_change_hash() { + let forward = cedar_policy_with_provider_rules(); + let mut reversed = ProtoSandboxPolicy { + provider_credential_rules: HashMap::new(), + ..forward.clone() + }; + let mut entries = forward.provider_credential_rules.iter().collect::>(); + entries.sort_by_key(|(key, _)| key.as_str()); + for (key, rule) in entries.into_iter().rev() { + reversed + .provider_credential_rules + .insert(key.clone(), rule.clone()); + } + assert_eq!( + deterministic_policy_hash(&forward), + deterministic_policy_hash(&reversed) + ); + + let forward = cedar_policy_with_endpoint_settings(); + let mut reversed = forward.clone(); + let queries = &mut reversed.endpoint_settings[0].graphql_persisted_queries; + let mut entries = std::mem::take(queries).into_iter().collect::>(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + queries.extend(entries.into_iter().rev()); + assert_eq!( + deterministic_policy_hash(&forward), + deterministic_policy_hash(&reversed) + ); + } + + #[test] + fn cedar_map_value_changes_change_hash() { + let original = cedar_policy_with_provider_rules(); + let mut changed = original.clone(); + changed + .provider_credential_rules + .get_mut("beta") + .expect("beta rule") + .endpoints[0] + .path = "/v2/**".into(); + assert_ne!( + deterministic_policy_hash(&original), + deterministic_policy_hash(&changed) + ); + + let original = cedar_policy_with_endpoint_settings(); + let mut changed = original.clone(); + changed.endpoint_settings[0] + .graphql_persisted_queries + .get_mut("GetUser") + .expect("GetUser operation") + .operation_type = "mutation".into(); + assert_ne!( + deterministic_policy_hash(&original), + deterministic_policy_hash(&changed) + ); + } + + #[test] + fn policies_without_cedar_fields_keep_legacy_canonical_bytes() { + // Mirrors the canonical encoding that shipped before the Cedar-only + // sections were added, so hashes persisted by older gateways still match. + fn legacy_canonical_policy_bytes(policy: &ProtoSandboxPolicy) -> Vec { + let mut map_free = policy.clone(); + map_free.network_policies.clear(); + map_free.network_middlewares.clear(); + let mut out = Vec::new(); + append_canonical_message(&mut out, &map_free); + + let mut policy_entries = policy.network_policies.iter().collect::>(); + policy_entries.sort_by_key(|(key, _)| key.as_str()); + append_canonical_bytes(&mut out, b"network_policies"); + out.extend_from_slice(&canonical_size(policy_entries.len())); + for (key, rule) in policy_entries { + append_canonical_bytes(&mut out, key.as_bytes()); + append_canonical_bytes(&mut out, &canonical_rule_bytes(rule)); + } + + let mut middleware_entries = policy.network_middlewares.iter().collect::>(); + middleware_entries.sort_by_key(|(key, _)| key.as_str()); + append_canonical_bytes(&mut out, b"network_middlewares"); + out.extend_from_slice(&canonical_size(middleware_entries.len())); + for (key, middleware) in middleware_entries { + append_canonical_bytes(&mut out, key.as_bytes()); + append_canonical_bytes(&mut out, &canonical_middleware_bytes(middleware)); + } + out + } + + let yaml_policy = ProtoSandboxPolicy { + network_policies: HashMap::from([ + ("alpha".into(), provider_rule("alpha.example.test")), + ("beta".into(), provider_rule("beta.example.test")), + ]), + network_middlewares: HashMap::from([( + "audit".into(), + NetworkMiddlewareConfig { + name: "audit".into(), + ..Default::default() + }, + )]), + ..Default::default() + }; + assert_eq!( + canonical_policy_bytes(&yaml_policy), + legacy_canonical_policy_bytes(&yaml_policy) + ); + assert_eq!( + canonical_policy_bytes(&ProtoSandboxPolicy::default()), + legacy_canonical_policy_bytes(&ProtoSandboxPolicy::default()) + ); + } +} + #[cfg(test)] mod token_grant_owner_tests { use super::*; - use crate::proto::{NetworkBinary, NetworkEndpoint}; + use crate::proto::NetworkBinary; fn profile_rule() -> NetworkPolicyRule { NetworkPolicyRule { diff --git a/crates/openshell-driver-kubernetes/Cargo.toml b/crates/openshell-driver-kubernetes/Cargo.toml index 9f28f4fcd5..4ecf0a47a5 100644 --- a/crates/openshell-driver-kubernetes/Cargo.toml +++ b/crates/openshell-driver-kubernetes/Cargo.toml @@ -19,7 +19,7 @@ openshell-core = { path = "../openshell-core", default-features = false } openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } openshell-otel = { path = "../openshell-otel" } -openshell-policy = { path = "../openshell-policy" } +openshell-policy = { path = "../openshell-policy", default-features = false } opentelemetry = { workspace = true } tokio = { workspace = true } diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.cedar b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.cedar new file mode 100644 index 0000000000..fe0b17b956 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.cedar @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of fs-empty.yaml: no filesystem paths and no network access. +// +// The MXC driver maps YAML filesystem policies only, so run-mxc-e2e.ps1 keeps +// using fs-empty.yaml; this file records the Cedar form. A Cedar policy has +// no include_workdir, and a policy without filesystem permits grants no +// paths. On Linux, the supervisor still adds its baseline runtime paths to a +// Cedar policy, while a YAML policy with no paths and no network policies +// gets no Landlock ruleset. +// +// Cedar denies by default, so the policy has no statements. diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 6dda646bd0..17dcaaded6 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -1143,6 +1143,12 @@ fn handled_fields_inventory() { m.insert("redactor".to_owned(), middleware_config()); m }, + // Cedar-only fields. A Cedar policy cannot carry `network_policies`, + // so this YAML inventory leaves them empty; a Cedar policy is routed + // to the Cedar engine by the host proxy, not through the mapper. + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::new(), + endpoint_settings: Vec::new(), }; // Validate so the test itself doesn't carry a bad policy. diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index f8ed6c70e9..d511a8e6d4 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -28,7 +28,7 @@ openshell-core = { path = "../openshell-core", default-features = false } openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } openshell-otel = { path = "../openshell-otel", optional = true } -openshell-policy = { path = "../openshell-policy", optional = true } +openshell-policy = { path = "../openshell-policy", default-features = false, optional = true } openshell-driver-podman = { path = "../openshell-driver-podman", optional = true } openshell-vfio = { path = "../openshell-vfio", optional = true } diff --git a/crates/openshell-gateway/Cargo.toml b/crates/openshell-gateway/Cargo.toml index fa977a2c2a..0de75d92d4 100644 --- a/crates/openshell-gateway/Cargo.toml +++ b/crates/openshell-gateway/Cargo.toml @@ -27,7 +27,7 @@ tokio = { workspace = true } openshell-driver-docker = { path = "../openshell-driver-docker", optional = true } openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes", optional = true } openshell-driver-podman = { path = "../openshell-driver-podman", optional = true } -openshell-policy = { path = "../openshell-policy", optional = true } +openshell-policy = { path = "../openshell-policy", default-features = false, optional = true } hyper-util = { workspace = true, optional = true } nix = { workspace = true, optional = true } serde = { workspace = true, optional = true } diff --git a/crates/openshell-policy-cedar-schema/Cargo.toml b/crates/openshell-policy-cedar-schema/Cargo.toml new file mode 100644 index 0000000000..b8ef91e47b --- /dev/null +++ b/crates/openshell-policy-cedar-schema/Cargo.toml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-policy-cedar-schema" +description = "Canonical Cedar schema for OpenShell's experimental Cedar policy engine" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +cedar-policy = { workspace = true } +miette = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-policy-cedar-schema/schema/sandbox.cedarschema b/crates/openshell-policy-cedar-schema/schema/sandbox.cedarschema new file mode 100644 index 0000000000..704057be5b --- /dev/null +++ b/crates/openshell-policy-cedar-schema/schema/sandbox.cedarschema @@ -0,0 +1,145 @@ +namespace Sandbox { + + // ── Principals ────────────────────────────────────────────────────────── + + entity User {}; + entity Group {}; + + // The principal of every action. It carries only the identity the + // supervisor knows at request time; it has no working directory. + entity Process { + user: User, + group: Group, + }; + + // ── Filesystem resources ───────────────────────────────────────────────── + // Parent-child hierarchy: FilesystemPath::"/usr/bin" in FilesystemPath::"/usr" + // enables `resource in FilesystemPath::"/usr"` to match any descendant. + + entity FilesystemPath in [FilesystemPath] {}; + + // ── Network resources ──────────────────────────────────────────────────── + + entity NetworkEndpoint { + host: String, + port: Long, + protocol: String, // "rest", "grpc", etc. + // Precomputed "{host}:{port}", with the lowercase host. A NetworkConnect + // permit whose `when` conditions require `resource.host_port == "h:p"`, + // or `["h:p"].contains(resource.host_port)` with one element, names that + // endpoint for policy DNS as a scope literal does, and the literal must + // pass the same checks. A set with several elements grants no + // eligibility. + host_port: String, + }; + + // The decoded query parameters of one HttpRequest: one tag per key, holding + // one of that key's values. A request whose keys repeat is evaluated once + // per combination of values (see the HttpRequest action). + entity HttpQuery tags String; + + // ── Actions ────────────────────────────────────────────────────────────── + + action "ReadFile" + appliesTo { + principal: [Process], + resource: [FilesystemPath], + context: {} + }; + + action "WriteFile" + appliesTo { + principal: [Process], + resource: [FilesystemPath], + context: {} + }; + + // binary_path: absolute path of the executable making the call (plain + // string, not a FilesystemPath entity - matching is + // Set.contains()/like, not hierarchy traversal) + // ancestors: absolute paths of the calling process's ancestors, from + // /proc//exe walks; excludes cmdline/argv0 (spoofable) + // binary_aliases: the exact binary paths policies name in + // `context.binary_aliases.contains("...")` that are symlinks + // in the sandbox resolving to binary_path or an ancestor, as + // YAML expands a symlinked binary path. Empty until the + // sandbox's entrypoint process is known. + // binary_globs: the binary glob patterns policies name in + // `context.binary_globs.contains("...")` that match binary_path + // or an ancestor with the YAML binary glob matcher (`*` within + // one path segment, `**` as a whole segment across segments), + // as a YAML binary glob matches the binary or any ancestor. + // There is no method, path, or command: CONNECT happens before any + // request is read, so a policy that conditions a connection + // on them fails strict validation. Gate per-request method + // and path on the HttpRequest action, and give a policy that + // reads them exactly that action in its scope. + // destination_ip: one resolved address of the destination, present only + // once the host has been resolved. A connection is decided + // first without it, then once per resolved address, so a + // permit can require `context has destination_ip && + // context.destination_ip.isInRange(ip("10.0.5.0/24"))`. A + // forbid that reads it is evaluated both times; before + // resolution the `context has destination_ip` guard is false. + action "NetworkConnect" + appliesTo { + principal: [Process], + resource: [NetworkEndpoint], + context: { + binary_path: String, + ancestors: Set, + binary_aliases: Set, + binary_globs: Set, + destination_ip?: ipaddr, + } + }; + + // Per-request L7 enforcement within an already-permitted NetworkConnect + // tunnel. binary_path/ancestors/binary_aliases/binary_globs mirror + // NetworkConnect's fields (binary identity is re-checked per request, + // matching Rego's stateless per-call evaluation). method, path, and + // command are "" when they do not apply to the request's protocol. + // + // JSON-RPC and MCP (one request per call; batches are split): + // jsonrpc_method the call's method, "" when there is no call + // jsonrpc_receive_stream an MCP GET that opens the server-to-client stream + // jsonrpc_response the body carries client-to-server response frames + // mcp_tool tools/call params.name, "" otherwise + // mcp_method_class "available" (core method of the selected MCP + // revision), "extension", or "" for non-MCP + // Query parameters (every protocol): + // query the decoded query parameters, as HttpQuery tags; + // read with context.query.hasTag("k") and + // context.query.getTag("k"). When a key repeats, + // the request is evaluated once per combination + // of values, and allowed only if no evaluation is + // denied and one permit allows every evaluation. + // GraphQL (one request per operation; a batch is denied if any is): + // graphql_operation_type "query", "mutation", "subscription", or "" for a + // hash-only persisted query + // graphql_operation_name the operation name, "" when anonymous + // graphql_fields the operation's top-level fields + action "HttpRequest" + appliesTo { + principal: [Process], + resource: [NetworkEndpoint], + context: { + binary_path: String, + ancestors: Set, + binary_aliases: Set, + binary_globs: Set, + method: String, + path: String, + command: String, + jsonrpc_method: String, + jsonrpc_receive_stream: Bool, + jsonrpc_response: Bool, + mcp_tool: String, + mcp_method_class: String, + graphql_operation_type: String, + graphql_operation_name: String, + graphql_fields: Set, + query: HttpQuery, + } + }; +} diff --git a/crates/openshell-policy-cedar-schema/src/error.rs b/crates/openshell-policy-cedar-schema/src/error.rs new file mode 100644 index 0000000000..d94f9a9663 --- /dev/null +++ b/crates/openshell-policy-cedar-schema/src/error.rs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Typed error for [`crate::load_schema`] and [`crate::declared_attributes`]. + +use std::error::Error as StdError; + +use miette::Diagnostic; +use thiserror::Error; + +/// The embedded [`crate::SANDBOX_SCHEMA_SRC`] failed to parse or to convert. +/// +/// This should only happen if this crate ships a broken schema; it is not a +/// caller-input error. +#[derive(Debug, Error, Diagnostic)] +#[error("Cedar schema parse error: {0}")] +#[diagnostic(code(openshell::policy_cedar_schema::parse))] +pub struct CedarSchemaLoadError(#[source] Box); + +impl CedarSchemaLoadError { + pub(crate) fn new(source: impl StdError + Send + Sync + 'static) -> Self { + Self(Box::new(source)) + } +} diff --git a/crates/openshell-policy-cedar-schema/src/lib.rs b/crates/openshell-policy-cedar-schema/src/lib.rs new file mode 100644 index 0000000000..c4d3424051 --- /dev/null +++ b/crates/openshell-policy-cedar-schema/src/lib.rs @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical Cedar schema for `OpenShell`'s experimental Cedar policy engine. +//! +//! This crate owns the single `.cedarschema` source that every Cedar-aware +//! consumer (currently `openshell-policy-cedar`) parses against, plus the +//! entity-type and action name constants that source declares. Consumers +//! reference [`entity_types`] and [`actions`] instead of writing schema +//! string literals of their own, so a renamed entity or action fails to +//! compile at every call site instead of silently drifting. +//! `tests/schema_consistency.rs` asserts the constants stay in sync with the +//! schema. + +mod error; + +pub use error::CedarSchemaLoadError; + +use std::collections::{BTreeMap, BTreeSet}; + +use cedar_policy::{Schema, SchemaFragment}; +use serde_json::Value; + +/// Canonical `.cedarschema` source for the `OpenShell` `Sandbox` namespace. +pub const SANDBOX_SCHEMA_SRC: &str = include_str!("../schema/sandbox.cedarschema"); + +/// Entity type names declared in [`SANDBOX_SCHEMA_SRC`]. +pub mod entity_types { + /// A sandboxed process; the principal for every sandbox action. + pub const PROCESS: &str = "Sandbox::Process"; + /// The user identity a [`PROCESS`] runs as. + pub const USER: &str = "Sandbox::User"; + /// The group identity a [`PROCESS`] runs as. + pub const GROUP: &str = "Sandbox::Group"; + /// A filesystem path; resource for the `ReadFile`/`WriteFile` actions. + pub const FILESYSTEM_PATH: &str = "Sandbox::FilesystemPath"; + /// A network destination; resource for the `NetworkConnect` action. + pub const NETWORK_ENDPOINT: &str = "Sandbox::NetworkEndpoint"; + /// The decoded query parameters of one `HttpRequest`, one `String` tag + /// per key. + pub const HTTP_QUERY: &str = "Sandbox::HttpQuery"; +} + +/// Action names declared in [`SANDBOX_SCHEMA_SRC`], all in the +/// [`actions::ACTION_TYPE`] entity type. +pub mod actions { + /// Cedar entity type name shared by every action. + pub const ACTION_TYPE: &str = "Sandbox::Action"; + /// Read a file at a [`super::entity_types::FILESYSTEM_PATH`]. + pub const READ_FILE: &str = "ReadFile"; + /// Write a file at a [`super::entity_types::FILESYSTEM_PATH`]. + pub const WRITE_FILE: &str = "WriteFile"; + /// Open a connection to a [`super::entity_types::NETWORK_ENDPOINT`]. + pub const NETWORK_CONNECT: &str = "NetworkConnect"; + /// A single L7 request within an already-permitted `NetworkConnect` tunnel. + pub const HTTP_REQUEST: &str = "HttpRequest"; +} + +/// `NetworkConnect`'s and `HttpRequest`'s `context` record attribute names. +/// +/// Declared in [`SANDBOX_SCHEMA_SRC`]. `String`-typed attributes take `""` +/// for requests where they don't apply (e.g. `COMMAND` for a REST request). +/// `NetworkConnect` declares only the binary identity fields and +/// [`DESTINATION_IP`]; every request field is `HttpRequest`-only. +pub mod context_fields { + /// Absolute path of the calling binary (plain string, not an entity). + pub const BINARY_PATH: &str = "binary_path"; + /// `Set` of the calling process's ancestor binary paths. + pub const ANCESTORS: &str = "ancestors"; + /// `Set` of the exact binary paths policies name that resolve, + /// as symlinks in the sandbox, to the calling binary or an ancestor. + pub const BINARY_ALIASES: &str = "binary_aliases"; + /// `Set` of the binary glob patterns policies name that match + /// the calling binary or an ancestor with the YAML binary glob matcher. + pub const BINARY_GLOBS: &str = "binary_globs"; + /// HTTP method for REST requests, uppercase. `HttpRequest`-only: no + /// request has been read at CONNECT time. + pub const METHOD: &str = "method"; + /// REST request path. `HttpRequest`-only. + pub const PATH: &str = "path"; + /// SQL command verb for SQL requests. `HttpRequest`-only. + pub const COMMAND: &str = "command"; + /// JSON-RPC method name for JSON-RPC requests. `HttpRequest`-only. + pub const JSONRPC_METHOD: &str = "jsonrpc_method"; + /// `Bool`: an MCP GET that opens the server-to-client stream. + /// `HttpRequest`-only. + pub const JSONRPC_RECEIVE_STREAM: &str = "jsonrpc_receive_stream"; + /// `Bool`: the body carries client-to-server JSON-RPC response frames. + /// `HttpRequest`-only. + pub const JSONRPC_RESPONSE: &str = "jsonrpc_response"; + /// MCP `tools/call` tool name. `HttpRequest`-only. + pub const MCP_TOOL: &str = "mcp_tool"; + /// MCP method classification: `"available"`, `"extension"`, or `""`. + /// `HttpRequest`-only. + pub const MCP_METHOD_CLASS: &str = "mcp_method_class"; + /// GraphQL operation type, or `""` for a hash-only persisted query. + /// `HttpRequest`-only. + pub const GRAPHQL_OPERATION_TYPE: &str = "graphql_operation_type"; + /// GraphQL operation name, or `""` when anonymous. `HttpRequest`-only. + pub const GRAPHQL_OPERATION_NAME: &str = "graphql_operation_name"; + /// `Set` of the GraphQL operation's top-level fields. + /// `HttpRequest`-only. + pub const GRAPHQL_FIELDS: &str = "graphql_fields"; + /// `ipaddr`: one resolved address of the destination. `NetworkConnect` + /// only, and optional: absent while the host is unresolved. + pub const DESTINATION_IP: &str = "destination_ip"; + /// The request's [`super::entity_types::HTTP_QUERY`] entity, whose tags + /// hold one value per query key. `HttpRequest`-only. + pub const QUERY: &str = "query"; +} + +/// `NetworkEndpoint`'s entity attribute names, declared in +/// [`SANDBOX_SCHEMA_SRC`]. +pub mod endpoint_fields { + /// Destination host. + pub const HOST: &str = "host"; + /// Destination port. + pub const PORT: &str = "port"; + /// L7 protocol label (e.g. `"rest"`). + pub const PROTOCOL: &str = "protocol"; + /// Precomputed `"{host}:{port}"`, with the lowercase host. A + /// `NetworkConnect` permit that requires it to equal one literal names + /// that endpoint for policy DNS. + pub const HOST_PORT: &str = "host_port"; +} + +/// Parses [`SANDBOX_SCHEMA_SRC`] into a [`cedar_policy::Schema`]. +/// +/// # Errors +/// +/// Returns [`CedarSchemaLoadError`] if the embedded schema source fails to +/// parse. +pub fn load_schema() -> Result { + let (schema, _warnings) = + Schema::from_cedarschema_str(SANDBOX_SCHEMA_SRC).map_err(CedarSchemaLoadError::new)?; + Ok(schema) +} + +/// The attributes [`SANDBOX_SCHEMA_SRC`] declares, by action context and by +/// entity type. +/// +/// Cedar's validator accepts a `has` test of an attribute the schema does +/// not declare, since it simply typechecks to `false`, so consumers use this +/// to reject such a test, which could never hold. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DeclaredAttributes { + /// Context attributes by action id, such as `"NetworkConnect"`. + context: BTreeMap>, + /// Entity attributes by entity type name, such as `"Sandbox::Process"`. + entity: BTreeMap>, +} + +impl DeclaredAttributes { + /// Returns the context attributes of `action`, an action id, empty for + /// an unknown action. + #[must_use] + pub fn context(&self, action: &str) -> &BTreeSet { + self.context.get(action).unwrap_or(&EMPTY_ATTRIBUTES) + } + + /// Returns the attributes of `entity_type`, a full entity type name, + /// empty for an unknown type. + #[must_use] + pub fn entity(&self, entity_type: &str) -> &BTreeSet { + self.entity.get(entity_type).unwrap_or(&EMPTY_ATTRIBUTES) + } +} + +/// The attribute set of an action or entity type the schema does not declare. +static EMPTY_ATTRIBUTES: BTreeSet = BTreeSet::new(); + +/// Reads the attributes [`SANDBOX_SCHEMA_SRC`] declares from its JSON form. +/// +/// Context and entity shapes are read as inline records, which is how the +/// embedded schema writes them; a shape named through a common type would +/// read as declaring no attributes. +/// +/// # Errors +/// +/// Returns [`CedarSchemaLoadError`] if the embedded schema source fails to +/// parse or to serialize as JSON. +pub fn declared_attributes() -> Result { + let (fragment, _warnings) = SchemaFragment::from_cedarschema_str(SANDBOX_SCHEMA_SRC) + .map_err(CedarSchemaLoadError::new)?; + let json = fragment + .to_json_value() + .map_err(CedarSchemaLoadError::new)?; + let mut declared = DeclaredAttributes::default(); + for (namespace, body) in json.as_object().into_iter().flatten() { + let qualify = |name: &str| { + if namespace.is_empty() { + name.to_string() + } else { + format!("{namespace}::{name}") + } + }; + for (name, entity_type) in object_entries(body.get("entityTypes")) { + declared + .entity + .insert(qualify(name), record_attributes(entity_type.get("shape"))); + } + for (name, action) in object_entries(body.get("actions")) { + declared.context.insert( + name.clone(), + record_attributes( + action + .get("appliesTo") + .and_then(|applies| applies.get("context")), + ), + ); + } + } + Ok(declared) +} + +/// The entries of a JSON object, or none for anything else. +fn object_entries(value: Option<&Value>) -> impl Iterator { + value + .and_then(Value::as_object) + .into_iter() + .flat_map(|object| object.iter()) +} + +/// The attribute names of an inline JSON record type. +fn record_attributes(record: Option<&Value>) -> BTreeSet { + object_entries(record.and_then(|record| record.get("attributes"))) + .map(|(name, _)| name.clone()) + .collect() +} diff --git a/crates/openshell-policy-cedar-schema/tests/schema_consistency.rs b/crates/openshell-policy-cedar-schema/tests/schema_consistency.rs new file mode 100644 index 0000000000..5c6124e1bf --- /dev/null +++ b/crates/openshell-policy-cedar-schema/tests/schema_consistency.rs @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Asserts that every constant in [`entity_types`]/[`actions`] names an +//! entity type or action actually declared in [`SANDBOX_SCHEMA_SRC`], so the +//! hand-written constants cannot silently drift from the schema they +//! describe. + +use std::collections::{BTreeSet, HashSet}; + +use openshell_policy_cedar_schema::{ + SANDBOX_SCHEMA_SRC, actions, context_fields, declared_attributes, endpoint_fields, + entity_types, load_schema, +}; + +#[test] +fn entity_type_constants_are_declared_in_the_schema() { + let schema = load_schema().expect("embedded schema must parse"); + let declared: HashSet = schema.entity_types().map(ToString::to_string).collect(); + + for name in [ + entity_types::PROCESS, + entity_types::USER, + entity_types::GROUP, + entity_types::FILESYSTEM_PATH, + entity_types::NETWORK_ENDPOINT, + entity_types::HTTP_QUERY, + ] { + assert!( + declared.contains(name), + "entity_types constant {name:?} is not declared in the schema; declared types: {declared:?}" + ); + } +} + +#[test] +fn action_constants_are_declared_in_the_schema() { + let schema = load_schema().expect("embedded schema must parse"); + let declared: HashSet = schema.actions().map(ToString::to_string).collect(); + + for action in [ + actions::READ_FILE, + actions::WRITE_FILE, + actions::NETWORK_CONNECT, + actions::HTTP_REQUEST, + ] { + let uid = format!("{}::\"{action}\"", actions::ACTION_TYPE); + assert!( + declared.contains(&uid), + "action constant {action:?} (as {uid:?}) is not declared in the schema; declared actions: {declared:?}" + ); + } +} + +/// Counts the `context` attribute declarations of `field` in the schema +/// source. +fn context_declarations(field: &str) -> usize { + SANDBOX_SCHEMA_SRC + .lines() + .filter(|line| line.trim_start().starts_with(&format!("{field}:"))) + .count() +} + +#[test] +fn binary_identity_context_fields_are_declared_on_both_network_actions() { + // Both network actions carry the same binary identity fields, so a + // policy can guard connections and requests with one condition. + for field in [ + context_fields::BINARY_PATH, + context_fields::ANCESTORS, + context_fields::BINARY_ALIASES, + context_fields::BINARY_GLOBS, + ] { + assert_eq!( + context_declarations(field), + 2, + "{field} must be declared on NetworkConnect and HttpRequest" + ); + } +} + +#[test] +fn request_context_fields_are_declared_only_on_http_request() { + // No request has been read at CONNECT time, so NetworkConnect declares + // none of these; a connection policy that reads one fails validation + // instead of never matching. + for field in [ + context_fields::METHOD, + context_fields::PATH, + context_fields::COMMAND, + context_fields::JSONRPC_METHOD, + context_fields::JSONRPC_RECEIVE_STREAM, + context_fields::JSONRPC_RESPONSE, + context_fields::MCP_TOOL, + context_fields::MCP_METHOD_CLASS, + context_fields::GRAPHQL_OPERATION_TYPE, + context_fields::GRAPHQL_OPERATION_NAME, + context_fields::GRAPHQL_FIELDS, + context_fields::QUERY, + ] { + assert_eq!( + context_declarations(field), + 1, + "{field} must be declared on HttpRequest only" + ); + } +} + +#[test] +fn process_carries_only_its_identity() { + // `Process` once declared an optional `workdir` that nothing populated, + // so `principal has workdir` was always false. The entity has exactly + // the attributes the supervisor sets on every request. + let declared = declared_attributes().expect("embedded schema must convert"); + assert_eq!( + declared.entity(entity_types::PROCESS), + &BTreeSet::from(["user".to_string(), "group".to_string()]) + ); +} + +#[test] +fn declared_attributes_follow_the_schema() { + let declared = declared_attributes().expect("embedded schema must convert"); + let names = |fields: &[&str]| { + fields + .iter() + .map(ToString::to_string) + .collect::>() + }; + assert_eq!( + declared.context(actions::NETWORK_CONNECT), + &names(&[ + context_fields::BINARY_PATH, + context_fields::ANCESTORS, + context_fields::BINARY_ALIASES, + context_fields::BINARY_GLOBS, + context_fields::DESTINATION_IP, + ]) + ); + let http_request = declared.context(actions::HTTP_REQUEST); + for field in [ + context_fields::METHOD, + context_fields::PATH, + context_fields::COMMAND, + context_fields::QUERY, + ] { + assert!(http_request.contains(field), "{field}"); + } + assert!(!http_request.contains(context_fields::DESTINATION_IP)); + assert!(declared.context(actions::READ_FILE).is_empty()); + assert_eq!( + declared.entity(entity_types::NETWORK_ENDPOINT), + &names(&[ + endpoint_fields::HOST, + endpoint_fields::PORT, + endpoint_fields::PROTOCOL, + endpoint_fields::HOST_PORT, + ]) + ); + assert!(declared.entity(entity_types::FILESYSTEM_PATH).is_empty()); + assert!(declared.entity("Sandbox::Missing").is_empty()); + assert!(declared.context("Missing").is_empty()); +} diff --git a/crates/openshell-policy-cedar/Cargo.toml b/crates/openshell-policy-cedar/Cargo.toml new file mode 100644 index 0000000000..0ce7128511 --- /dev/null +++ b/crates/openshell-policy-cedar/Cargo.toml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-policy-cedar" +description = "Cedar policy validation and evaluation for Cedar-authored OpenShell sandbox policies" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +cedar-policy = { workspace = true } +globset = { version = "0.4", default-features = false } +ipnet = "2" +miette = { workspace = true } +openshell-core = { path = "../openshell-core", default-features = false } +openshell-policy-cedar-schema = { path = "../openshell-policy-cedar-schema" } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-policy-cedar/README.md b/crates/openshell-policy-cedar/README.md new file mode 100644 index 0000000000..7ac3750aa1 --- /dev/null +++ b/crates/openshell-policy-cedar/README.md @@ -0,0 +1,300 @@ +# Cedar policy engine + +`openshell-policy-cedar` loads and evaluates sandbox policies authored in +[Cedar](https://www.cedarpolicy.com/). A policy is either YAML or Cedar for the +life of a sandbox. A non-empty `SandboxPolicy.cedar_policy_source`, authored as +a `.cedar` file, selects Cedar. It is mutually exclusive with +`network_policies` and filesystem paths. Middleware and endpoint settings are +configuration rather than policy, so a Cedar sandbox gets them from a separate +middleware file (`--middleware`), carried in `network_middlewares` and +`endpoint_settings`. + +`CedarEngine` validates the policy text in strict mode against the canonical +schema in `openshell-policy-cedar-schema`. It then derives three artifacts that +Cedar does not decide at request time: Landlock grants, which endpoints the +proxy inspects per request, and policy DNS eligibility. It accepts only policy +shapes whose meaning those artifacts can enforce exactly. The CLI, the gateway, +and the supervisor reject everything else. Cedar evaluation errors fail the +request instead of skipping the erroring policy. + +Policy DNS eligibility comes from `NetworkConnect` permit scopes, and from +`when` conditions that require both `resource.host` and `resource.port`. A host +condition can be an exact string, or a delimited glob such as +`resource.host like("*.example.com", ".")` that follows the YAML wildcard host +rules. The glob is converted from Cedar's pattern elements, not its source +text, so policy DNS matches exactly the hosts Cedar's delimited `like` admits. +A glob never counts as an exactly named host, so it cannot resolve to a +private address. `resource.host_port == "host:port"`, or +`["host:port"].contains(resource.host_port)` with one element, names the host +and port together, and the literal passes the same checks as a scope literal +(`parse_endpoint`); a `host_port` set with several elements grants no +eligibility, since the exactly-one-host-and-port rule applies to it too. + +The schema declares only what the supervisor populates. `NetworkConnect`'s +context has the binary identity fields and the optional `destination_ip`; it +has no `method`, `path`, or `command`, so a connection policy that reads one +fails strict validation rather than never matching, and a policy whose scope +lists both network actions must be split to read them. `Process` has `user` +and `group` only. Cedar's validator accepts a `has` test of an undeclared +attribute as always false, so analysis rejects one (`principal has workdir`, +`context has method` on `NetworkConnect`) with a validation error, using the +attributes `openshell-policy-cedar-schema::declared_attributes` reads from +the schema. A filesystem permit that lists `WriteFile` must list `ReadFile` +too, since a Landlock read-write grant includes read. + +`@transport("tcp")` on a `NetworkConnect` permit marks its endpoint as native +transparent TCP, the Cedar form of YAML `protocol: tcp`. `dns_endpoints` keeps +one record per host and `NetworkTransport`, so a host with native TCP and +other ports has two. Analysis applies the YAML rules for such endpoints: the +permit must name a DNS host (not an IP literal) and a port, in its scope or +conditions; and no inspected endpoint or non-TCP `NetworkConnect` endpoint may +overlap it on a host and port, which YAML's endpoint ambiguity check rejects. +Host globs are compared for overlap by searching for a host both match. + +`NetworkConnect` has an optional `destination_ip` (`ipaddr`) context field, +the Cedar form of YAML `allowed_ips`. Analysis accepts it in a permit only as +top-level `when` conjuncts that are `context has destination_ip` or an `||` +of `context.destination_ip.isInRange(ip("..."))` tests, so each permit's +ranges are known exactly (conjuncts intersect). Ranges overlapping +loopback, link-local, or unspecified addresses are rejected, as is any +policy reading `destination_ip` whose action scope is not exactly +`NetworkConnect`. `CedarEngine::authorize_network` decides a request +without `destination_ip` against `unresolved_policies` (permits stripped of +their address conjuncts; forbids as written, whose required `context has +destination_ip` guards are then false, so a forbid's other conditions still +deny at CONNECT time, which `proxy_connect_by_hostname` relies on), and with +it against every enforced policy. For an allow it reports each determining permit's +ranges, whether one has none, and whether one names the host and port +exactly. `dns_endpoints` keeps one record per host, transport, and range +set, carrying the ranges as `allowed_ips`. + +The crate depends on `ai-openshell/cedar`, OpenShell's Cedar in the OpenShell +CNCF workspace, pinned by commit with a matching `allow-git` exception in +`deny.toml`. Policy language features such as delimited `like` (tracked +upstream as cedar-policy/rfcs#115) are developed there with the Cedar team. +Its Lean specification and proofs are in `ai-openshell/cedar-spec`. + +## Integration + +- `openshell-policy` validates Cedar sources behind its default `cedar` + feature. Crates that never see Cedar policies disable the feature. +- The gateway rejects format switches on update, Cedar global policies, and + removal of a Cedar-derived Landlock grant on a live sandbox. +- Providers attached to a Cedar sandbox supply credentials but grant no access. + Provider composition puts their rules in `provider_credential_rules` instead + of `network_policies`. For a connection Cedar allows, each matching provider + endpoint contributes its credential settings. The same fail-closed credential + guard as YAML refuses an uninspected connection to a credentialed endpoint. + Token-grant owners, which the gateway stamps on provider endpoints, are + admitted only for a request Cedar allows and the owner's own provider + endpoint admits under its own rules. +- `TokenGrantRules` compiles provider rules to Cedar for that second check, + without Rego. The supervisor validates and normalizes them as YAML loads + them (access presets, MCP aliases, symlinked binaries) into the neutral + `ProviderPolicy` input. Each allow rule of an owned endpoint becomes a + `permit` annotated with `@token_grant_owner`, conditioned on the policy's + binaries and the endpoint's host, ports, and path; each deny rule, and the + protocol denials of JSON-RPC and GraphQL endpoints, becomes a `forbid`, + since a YAML deny admits no owner. The set is evaluated with the request's + own `HttpRequest` evaluations: an owner is admitted when one of its + endpoints admits every evaluation (one per GraphQL operation), and within + one by a single `permit` for every query-value combination. Globs become + delimited `like` patterns with Rego `glob.match` semantics, with a `/**/` + component also tried as `/`. A binary glob becomes + `context.binary_globs.contains(pattern)`, so it matches ancestors as the + YAML glob does; `binary_glob_patterns()` lists the patterns the supervisor + must match. What Cedar cannot express never admits more: a `permit` loses + it (`?` globs, GraphQL field globs, endpoint path globs other than `*` and + a `/**` suffix), a `forbid` ignores it, and a pattern Rego could fail to + compile (`[`, `{`, `\`) admits no owner. `untranslated()` lists each case, which the supervisor + logs. A JSON-RPC request with a non-uppercase method, a hash-only GraphQL + persisted query, a host with a trailing `.`, and a NUL in a matched string + admit no owner, since Cedar sees them differently from Rego. +- In `openshell-supervisor-network`, `PolicyEngine` is either the OPA engine or + `CedarOnlyEngine`, and it is the only engine that network decision points + receive. A Cedar sandbox builds no OPA engine and evaluates no Rego. + `CedarOnlyEngine` owns the policy's `network_middlewares`, the connected + middleware services, and a middleware generation; middleware is selected in + Rust with the same code YAML sandboxes use on their Rego data. Inspected + tunnels are pinned to Cedar's policy generation, delegate L7 decisions to + Cedar, and keep the middleware in effect when they started. +- The supervisor chooses the engine once at startup. On reload it rejects a + format switch, stages the Cedar policy, and validates the candidate with + the checks YAML policies get (including middleware and endpoint settings). + `CedarOnlyEngine::commit_configuration` then advances the middleware + generation, commits the Cedar policy, installs provider credentials, and + publishes the new middleware, so a rejected policy leaves the policy, + middleware, and credentials on the previous revision. A middleware-only + change keeps Cedar's generation, so it does not close inspected tunnels. A + rejected revision quarantines or retains the Cedar engine according to + `policy_validation_failure_mode`, as for YAML. +- An `HttpRequest` policy names its endpoint exactly in its scope, or as a + host glob: exactly one `.`-delimited `resource.host like` and one + `resource.port ==` top-level `when` conjunct, in the shapes policy DNS + accepts. A glob entry inspects every host it matches, as a YAML wildcard + endpoint with request rules does. `CedarEngine::l7_endpoints` selects a + host's entries with `openshell_core::host_pattern`, the matcher YAML + endpoints, endpoint settings, and policy DNS use: the exact entry and every + glob entry the host matches, whose paths are inspected together (the exact + entry's declaration of a path takes precedence). Entries that can select + one host must inspect equally specific overlapping paths the same way, or + the policy is rejected (`AmbiguousL7Hosts`), as YAML rejects ambiguous + overlapping endpoints. The native TCP overlap check compares glob entries + with `host_patterns_overlap`, like exact ones. Any other host-less + `HttpRequest` form is rejected. +- `@path` attaches `HttpRequest` policies to one path pattern of their + endpoint, with the YAML endpoint `path` grammar. Analysis keys inspection + by host (exact or glob), port, and path; policies without `@path` declare + the path-less inspection. Protocols must agree per path, and two equally + specific paths that can match one request must inspect it the same way, + since the relay could not order them. The supervisor emits one endpoint config per path, + and evaluates each request with the inspection of the config the relay + selects for it (most specific matching path), denying a request no path + matches as the relay does. `@path` selects inspection only: every policy + applies to every request on its endpoint, so policies spell out path + conditions. `resource.protocol` is the routed path's protocol. Provider + endpoints and path-scoped endpoint settings take the inspection of the + Cedar path declared as their own path, or else the one the relay would + select for that path's text. +- `@protocol` selects `rest` (default), `json-rpc`, `mcp`, `graphql`, + `websocket`, or `websocket-graphql`. Both WebSocket protocols produce a + `websocket` endpoint config; `websocket-graphql` also sets + `websocket_graphql_policy`, which a YAML endpoint derives from its GraphQL + rules, so the relay classifies client messages as GraphQL over WebSocket. + The relay evaluates the upgrade as a `GET` and each client message as a + `WEBSOCKET_TEXT` request on the upgrade path, with the GraphQL fields for + `websocket-graphql`. The + supervisor fills the protocol's `HttpRequest` context fields from the + relay's parsed request: one evaluation per JSON-RPC or MCP call (the relay + splits batches), and one per GraphQL operation, denying the request if any + is denied. Cedar MCP endpoints get the same default MCP revision as YAML + unless an endpoint setting lists revisions. + `sql` is rejected because the relay does not inspect SQL. The supervisor + uppercases the request method, and denies JSON-RPC calls and response + frames not sent with `POST` before evaluation, matching YAML. +- `HttpRequest`'s `context.query` is a per-request `Sandbox::HttpQuery` + entity whose `String` tags hold the query parameters, decoded by the + relay's request parser as for the Rego input. `CedarEngine::evaluate_l7` + evaluates a request once per combination of its repeated values, and + allows it only if no evaluation is denied and one `permit` (by policy id) + is among the determining policies of every evaluation. That reproduces + YAML query matchers: an allow rule matches every value of each key, and a + deny rule fires on one matching value per key. Analysis collects the tag + keys policies name literally; other keys are left out of the entity, so + their values do not multiply evaluations, unless a policy computes a key. + More than `MAX_QUERY_COMBINATIONS` (4096) combinations is an error, which + the supervisor turns into a denial with that reason. Audit paths and + staged policies use the same combined decision. +- `CedarOnlyEngine` attaches a `CedarDestination` to each allowed + connection's `EgressAuthorization`. It replaces the YAML destination modes + below the host gateway aliases: every resolved address must avoid the + always-blocked ranges, lie in the ranges of every allowing permit that has + them, and pass a Cedar evaluation with `destination_ip` set; a private + address also needs a permit that names the host exactly or only ranged + permits. Control-plane ports are blocked unless only public-only permits + allow the connection. These rules admit no address that YAML would reject + whichever overlapping endpoint it consulted first. CONNECT and forward + HTTP check every address; transparent TCP keeps the pinned policy DNS + addresses that pass. For a host gateway alias, Cedar must also allow the + pinned gateway address. Provider endpoints' `allowed_ips` only narrow: + each matching provider endpoint's ranges must also admit every address, + and block control-plane ports, but never count toward admitting a private + address. They do not apply to a host gateway alias, as in YAML. Host-less + provider endpoints match nothing. +- `CedarOnlyEngine` publishes the DNS records under the policy name `cedar`, + indexed by position, with `protocol: tcp` on native TCP records. For an + allowed connection, `EgressAuthorization::matched_endpoints` lists the + records covering its host and port with the same indices, so the + transparent TCP listener can correlate the decision with the policy DNS + mapping the workload dialed, as it does for YAML. +- `openshell-supervisor-network`'s `cedar_parity_tests` run paired YAML and + Cedar policies through both engines and fail when outcomes differ outside a + recorded list of known differences. They compare per-request decisions, + connection decisions, exactly declared hosts, the selected inspection, + policy DNS eligibility, admitted token-grant owners, endpoint config + settings, TLS mode, deny reasons, policy DNS records and the records a + connection matched, staged transparent TCP opens, and whole relay exchanges + and WebSocket sessions, and include ports of every existing YAML decision test that + Cedar can express. +- `SandboxPolicy.endpoint_settings` holds endpoint configuration that is not + access control: `tls: skip`, `allow_encoded_slash`, body limits, MCP + revisions and `strict_tool_names`, and the GraphQL persisted-query + registry. Each entry selects endpoints by host or host glob, ports, and an + optional path, with the YAML endpoint field names; `openshell-policy` + validates entries and rejects access and credential fields, and rejects the + section on YAML policies. For a connection Cedar allows, the supervisor + merges matching entries into the endpoint configs: a path-less entry into + every config, and a path-scoped entry into a config for that path, added + with Cedar's inspection so the relay's most-specific-path selection picks + it. Protocol options apply only where Cedar selects that protocol, and + `tls: skip` also applies to connections Cedar does not inspect. Before Cedar + evaluates a hash-only GraphQL persisted query, the supervisor replaces it + with its registry entry (hash first, then saved-query id) from the selected + config, and denies it when it is unregistered or `persisted_queries` is not + `allow_registered`, as the Rego rules do. The settings are part of the + reload inputs, so changing them advances the policy generation. +- Both network actions have `binary_aliases: Set`, the Cedar form of + YAML's symlink expansion of binary paths. Analysis collects the literal + paths policies test with `context.binary_aliases.contains`, `containsAny`, + or `containsAll` (`CedarEngine::binary_alias_paths`), and rejects any other + use of the set or of the whole `context` record, and paths that are not + absolute or contain `*`. `CedarOnlyEngine` resolves each path with the + YAML resolver through `/proc//root` when the entrypoint process is + known (`resolve_binary_symlinks`), and again on every reload (`stage` with + the pid), as a YAML reload resolves its binary paths again. A + request's aliases are the paths whose resolved target equals its binary or + an ancestor, or glob-matches one when the target contains `*`, exactly as + the Rego rules match the entry YAML adds for the target. So a binary + condition `binary_path == B || ancestors.contains(B) || + binary_aliases.contains(B)` matches where the YAML binary `B` matches, in a + `permit` or a `forbid`. Provider rule binaries used to select token-grant + owners are resolved the same way. +- Both network actions also have `binary_globs: Set`, the Cedar form + of a YAML binary glob, which matches the binary or any ancestor with + `glob.match(pattern, ["/"], path)`; Cedar cannot apply `like` across the + `context.ancestors` set. Analysis collects the literal patterns policies + test with `.contains`, `.containsAny`, or `.containsAll` + (`CedarEngine::binary_glob_patterns`), requiring absolute patterns with + `*` that compile as globs, and rejects any other use. `CedarOnlyEngine` + compiles those patterns and the token-grant compiler's with the Rego + matcher (`binary_resolution::compile_binary_glob`: `globset` with a literal + `/` separator) and lists in each request the patterns that match its + binary or an ancestor, so `context.binary_globs.contains(P)` holds exactly + where the YAML glob `P` matches. +- A delimited `like` pattern with `**` next to a character other than the + delimiter is rejected at load: Rego reads such a `**` as `*`, within one + segment, while Cedar's crosses segments. +- `@enforcement("audit")` marks an `HttpRequest`-only policy as audit, with + YAML audit-endpoint semantics. Enforcement is computed per inspected path: + a path whose deciding policies are all audit gets `enforcement: audit` in + its L7 config, so the relay logs and forwards denials as it does for YAML. + Audit policies decide requests like enforced ones, on every path of their + endpoint, as YAML applies the rules of every matching endpoint while the + selected endpoint's enforcement decides whether a denial blocks; an audit + path beside enforced policies is reported as a warning. + `@enforcement("staged")` marks a policy that never changes a path's mode + and never decides: `CedarEngine` decides with the deciding policies (all + but staged), and reports a staged decision only when adding the staged + policies would change it. Comparing decisions, not evaluating the staged + policies alone, matters because Cedar denies by default. The supervisor + logs staged decisions as `cedar_audit` events. +- A `permit` attached to a `websocket-graphql` path whose conditions can + hold for a `WEBSOCKET_TEXT` request through no `graphql_*` condition is + rejected, as YAML rejects a generic `WEBSOCKET_TEXT` rule on a WebSocket + endpoint with GraphQL operation policy. Analysis evaluates the conditions + in a four-valued logic over the method and the GraphQL fields. +- For a request the relay parsed as GraphQL (`HttpRequestInput::graphql_request`, + the Rego `is_object(request.graphql)`) on a `graphql` or + `websocket-graphql` path, permits attached to other paths of the endpoint + that neither read a `graphql_*` field nor test `resource.protocol` are left + out of the decision, as a YAML GraphQL endpoint is authoritative for the + requests it parses. The policy sets without them are built once per such + path at load. +- Cedar-derived Landlock grants pass the same path checks as YAML paths and get + the same baseline enrichment, under the same condition: YAML adds the + baseline only to a policy with network rules, and the supervisor adds it to + a Cedar policy only when `CedarEngine::has_network_permits` is true. + +`cedar-policy` enables `serde_json`'s `preserve_order` feature in every binary +that links it. Code that relies on sorted JSON object keys must sort them +explicitly. diff --git a/crates/openshell-policy-cedar/src/analysis.rs b/crates/openshell-policy-cedar/src/analysis.rs new file mode 100644 index 0000000000..2a3dab9055 --- /dev/null +++ b/crates/openshell-policy-cedar/src/analysis.rs @@ -0,0 +1,2701 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Load-time checks that an authored policy set is enforced exactly as written. +//! +//! Cedar evaluates `NetworkConnect` and `HttpRequest` at request time, so any +//! condition an author writes on those actions is enforced by Cedar itself. +//! Three things are *not* decided by Cedar at request time, and are instead +//! derived from the policy text once, here: +//! +//! - **Landlock grants.** Landlock is a flat allow-list of path subtrees +//! applied at sandbox start. Only filesystem policies whose meaning is +//! exactly "this process may read/write these subtrees" are accepted: +//! `permit`, an action scope of `ReadFile`/`WriteFile` only, an +//! unconstrained principal, and paths named either as +//! `resource in Sandbox::FilesystemPath::"/p"` in the scope, or as a `when` +//! clause that is only an `||` of such `resource in` tests. Anything else +//! (`forbid`, other conditions, `resource ==`, a principal constraint) is +//! rejected, because Landlock would grant more than the policy says. A +//! Landlock read-write grant includes read, so a permit that lists +//! `WriteFile` without `ReadFile` is rejected too: it would grant reads +//! the policy does not say. +//! - **L7 inspection routing.** The proxy must decide at CONNECT time whether +//! to inspect a connection per request. Every policy that can apply to +//! `HttpRequest` must therefore name its endpoint: literally in the scope, +//! or as a host glob written with a `.` delimiter and a port, as exactly +//! one `resource.host like("*.example.com", ".")` and one +//! `resource.port == ` top-level `when` conjunct, in the shapes +//! policy DNS accepts (see DNS eligibility below). Every endpoint so named +//! is inspected, and a glob inspects every host it matches, as a YAML +//! wildcard endpoint with request rules does. An `HttpRequest` policy the +//! proxy could not route would otherwise be silently skipped. Entries that +//! can select one host (an exact host and the globs matching it, or +//! overlapping globs) must inspect equally specific overlapping paths the +//! same way, as YAML rejects such ambiguous endpoints. A `@path` +//! annotation attaches a policy to one request path pattern of its +//! endpoint, so one `host:port` can be inspected with a different protocol +//! and enforcement per path, as YAML endpoints sharing a `host:port` are. +//! `@path` selects inspection only: every policy still applies to every +//! request on its endpoint, so conditions on `context.path` decide which +//! requests a policy allows. An `@enforcement("audit")` annotation marks an +//! `HttpRequest` policy as audit: an inspected path whose policies are all +//! audit logs denials instead of enforcing them, like a YAML +//! `enforcement: audit` endpoint. Audit policies still decide requests +//! routed to every path of their `host:port`, as YAML applies the rules of +//! every matching endpoint while the selected endpoint's enforcement +//! decides whether a denial blocks; so, as in YAML, the routed path's mode +//! decides whether a denial blocks, even a denial by an enforced policy +//! attached to another path of the same `host:port`. Such an audit path +//! beside enforced policies is reported as an [`AnalysisWarning`]. An +//! `@enforcement("staged")` annotation marks a policy as staged: it never +//! changes a path's mode and never takes part in a decision; requests whose +//! decision it would change are logged. A permit attached to a +//! `websocket-graphql` path that could allow a client message without a +//! GraphQL condition is rejected, as YAML rejects a generic +//! `WEBSOCKET_TEXT` rule there. For a request parsed as GraphQL, on a +//! `graphql` or `websocket-graphql` path, permits attached to another path +//! of the endpoint that neither read a `graphql_*` field nor test +//! `resource.protocol` are left out of the decision, as a YAML GraphQL +//! endpoint is authoritative for the requests it parses. +//! - **DNS eligibility.** A `NetworkConnect` `permit` makes a host eligible +//! for policy DNS when it names the endpoint in its scope, or when its +//! `when` conditions require both `resource.host` and `resource.port`: the +//! host as an exact string or as a host glob written with a `.` delimiter +//! (`resource.host like("*.example.com", ".")`), and the port as a number. +//! `resource.host_port == "host:port"`, or +//! `["host:port"].contains(resource.host_port)` with one element, names +//! both at once, and its literal passes the checks a scope literal does. +//! Host globs follow the same shape rules as YAML wildcard hosts. Hosts +//! matched any other way, such as an undelimited `like` or a `host_port` +//! set with several elements, are not eligible, which fails closed. +//! - **Native TCP.** A `@transport("tcp")` annotation on a `NetworkConnect` +//! `permit` marks its endpoint for native transparent TCP, like a YAML +//! `protocol: tcp` endpoint, and its policy DNS record carries +//! `protocol: tcp`. As in YAML, the endpoint must be a DNS host or host +//! glob with a port, never an IP literal or a hostless form, it is never +//! inspected per request, and no other `NetworkConnect` permit may name an +//! overlapping host and port without the annotation. A permit without the +//! annotation whose host cannot be derived (no `resource.host` conjunct, a +//! wildcard top-level domain, an undelimited `like`, or hosts joined with +//! `||`) may allow any host, so it is rejected whenever a native TCP permit +//! shares a port it may allow, or exists at all when it names no port. +//! +//! - **Destination addresses.** A `NetworkConnect` permit may require +//! `context.destination_ip`, the resolved address, to lie in fixed ranges, +//! like a YAML endpoint's `allowed_ips`. Such a condition must be a +//! top-level `when` conjunct that is `context has destination_ip` or an +//! `||` of `context.destination_ip.isInRange(ip("..."))` tests, so the +//! ranges are known exactly: the proxy enforces them on resolved addresses +//! and policy DNS filters answers with them. Ranges overlapping loopback, +//! link-local, or unspecified addresses are rejected, as YAML rejects such +//! `allowed_ips`. A connection is first decided without +//! `destination_ip`, with each such permit's address conjuncts removed, +//! then again for each resolved address with every policy. A `forbid` may +//! read `destination_ip` in any form and takes part in both decisions as +//! written: strict validation requires a `context has destination_ip` +//! guard, which is false before resolution, so only its other conditions +//! can deny then, and its address conditions apply per address after. +//! - **Binary aliases.** `context.binary_aliases` holds the binary paths +//! policies name that are symlinks in the sandbox resolving to the calling +//! binary or an ancestor, the way YAML expands a symlinked binary path. The +//! supervisor resolves only the paths collected here, so a policy may test +//! the set only with `.contains`, `.containsAny`, or `.containsAll` and +//! literal absolute paths without `*` (YAML never resolves a glob). +//! - **Binary globs.** `context.binary_globs` holds the binary glob patterns +//! policies name that match the calling binary or an ancestor with the +//! YAML binary glob matcher, since Cedar cannot apply `like` across the +//! `context.ancestors` set. The supervisor matches only the patterns +//! collected here, in the same forms as binary aliases, with literal +//! absolute patterns that contain `*` and compile as globs. +//! - **Delimited patterns.** A delimited `like` pattern with `**` next to a +//! character other than the delimiter is rejected: YAML reads such a `**` +//! as `*`, within one segment, while Cedar's crosses segments. +//! +//! Before any of that, the policy set is validated against the schema in +//! strict mode, so a typo such as `context.binray_path` is a load error +//! rather than a policy Cedar silently skips at request time. The validator +//! accepts a `has` test of an attribute the schema does not declare, which +//! typechecks to `false`, so such a test is a load error here too: +//! `principal has workdir` or `context has method` on `NetworkConnect` could +//! never hold, and the policy would never match. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt; +use std::net::IpAddr; + +use cedar_policy::{ + ActionConstraint, Effect, EntityUid, Policy, PolicyId, PolicySet, PrincipalConstraint, + ResourceConstraint, Schema, ValidationMode, Validator, +}; +use ipnet::IpNet; +use openshell_core::host_pattern::HostPattern; +use openshell_policy_cedar_schema::{ + DeclaredAttributes, actions, context_fields, endpoint_fields, entity_types, +}; +use serde_json::Value; + +use crate::{AuthorizedNetworkEndpoint, CedarEngineError, FilesystemGrants}; + +/// Annotation that declares the wire protocol of an `HttpRequest` endpoint. +/// +/// Read only from policies that can apply to `HttpRequest`; see +/// [`L7Protocol`] for accepted values. +const PROTOCOL_ANNOTATION: &str = "protocol"; + +/// Annotation that attaches an `HttpRequest` policy to one request path +/// pattern of its endpoint, with the YAML endpoint `path` grammar. +const PATH_ANNOTATION: &str = "path"; + +/// Annotation that marks an `HttpRequest` policy as audit or staged. +/// +/// See [`EnforcementAnnotation`] for accepted values. +const ENFORCEMENT_ANNOTATION: &str = "enforcement"; + +/// The method a WebSocket client text message is evaluated with. +const WEBSOCKET_TEXT_METHOD: &str = "WEBSOCKET_TEXT"; + +/// Prefix of the `HttpRequest` context fields that carry a GraphQL operation. +const GRAPHQL_FIELD_PREFIX: &str = "graphql_"; + +/// Annotation that marks a `NetworkConnect` permit's endpoint for native TCP. +/// +/// See [`NetworkTransport`] for accepted values. +const TRANSPORT_ANNOTATION: &str = "transport"; + +/// Annotation whose value, when present, names a policy in error messages. +/// +/// Cedar assigns positional ids (`policy0`, `policy1`, ...) to policies +/// parsed from text, which are hard to map back to the source. +const ID_ANNOTATION: &str = "id"; + +/// Wire protocol the proxy parses on an L7-inspected endpoint. +/// +/// Only protocols whose per-request fields the `HttpRequest` context carries +/// are accepted. SQL has no request inspection in the proxy, so it is +/// rejected at load instead of being relayed without enforcement. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum L7Protocol { + /// HTTP/1.1 REST requests, matched on `context.method`/`context.path`. + #[default] + Rest, + /// JSON-RPC requests, matched on `context.jsonrpc_method`. + JsonRpc, + /// MCP requests, matched on `context.jsonrpc_method`, `context.mcp_tool`, + /// and `context.mcp_method_class`. + Mcp, + /// GraphQL operations, matched on `context.graphql_operation_type`, + /// `context.graphql_operation_name`, and `context.graphql_fields`. + Graphql, + /// WebSocket: the upgrade is a `GET` request, and each client text + /// message is evaluated with method `WEBSOCKET_TEXT` and the upgrade path. + Websocket, + /// WebSocket whose client messages carry GraphQL operations: the upgrade + /// is a `GET` request, and each operation message is evaluated with + /// method `WEBSOCKET_TEXT`, the upgrade path, and the `graphql_*` fields. + /// Control messages pass without evaluation, and messages that are not + /// valid GraphQL-over-WebSocket client messages are denied. + WebsocketGraphql, +} + +impl L7Protocol { + /// Returns the label `@protocol` accepts, also the endpoint's + /// `resource.protocol` for requests inspected with this protocol. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Rest => "rest", + Self::JsonRpc => "json-rpc", + Self::Mcp => "mcp", + Self::Graphql => "graphql", + Self::Websocket => "websocket", + Self::WebsocketGraphql => "websocket-graphql", + } + } + + /// Returns the protocol label the proxy's L7 config parser expects. + /// + /// Both WebSocket variants are `websocket`; + /// [`Self::websocket_graphql_messages`] tells them apart. + #[must_use] + pub fn config_label(self) -> &'static str { + match self { + Self::WebsocketGraphql => Self::Websocket.as_str(), + other => other.as_str(), + } + } + + /// Returns whether the relay applies GraphQL message policy to client + /// WebSocket messages. + #[must_use] + pub fn websocket_graphql_messages(self) -> bool { + self == Self::WebsocketGraphql + } + + fn from_annotation(value: &str) -> Option { + match value { + "rest" => Some(Self::Rest), + "json-rpc" => Some(Self::JsonRpc), + "mcp" => Some(Self::Mcp), + "graphql" => Some(Self::Graphql), + "websocket" => Some(Self::Websocket), + "websocket-graphql" => Some(Self::WebsocketGraphql), + _ => None, + } + } +} + +impl fmt::Display for L7Protocol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// How connections to a `NetworkConnect` endpoint are carried. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NetworkTransport { + /// No `@transport` annotation: the default connection handling. + #[default] + Default, + /// `@transport("tcp")`: native transparent TCP, like a YAML endpoint with + /// `protocol: tcp`. + Tcp, +} + +impl NetworkTransport { + /// Returns the YAML endpoint `protocol` this transport corresponds to, if + /// any; `"tcp"` for [`Self::Tcp`]. + #[must_use] + pub fn protocol(self) -> Option<&'static str> { + match self { + Self::Default => None, + Self::Tcp => Some("tcp"), + } + } + + fn from_annotation(value: &str) -> Option { + (value == "tcp").then_some(Self::Tcp) + } +} + +/// Whether the proxy blocks requests an endpoint's policies deny. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum L7Enforcement { + /// Denied requests are blocked. + #[default] + Enforce, + /// Denied requests are logged and forwarded. + Audit, +} + +impl L7Enforcement { + /// Returns the enforcement label the proxy's L7 config parser expects. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Enforce => "enforce", + Self::Audit => "audit", + } + } +} + +/// The value of an `@enforcement` annotation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum EnforcementAnnotation { + /// The default: the policy decides requests and enforces its path. + #[default] + Enforce, + /// The policy decides requests; a path whose policies are all audit + /// logs denials instead of blocking them. + Audit, + /// The policy never decides a request and never changes a path's mode; + /// requests whose decision it would change are logged. + Staged, +} + +impl EnforcementAnnotation { + /// The label of [`Self::Staged`]. + const STAGED: &'static str = "staged"; + + fn from_annotation(value: &str) -> Option { + match value { + "enforce" => Some(Self::Enforce), + "audit" => Some(Self::Audit), + Self::STAGED => Some(Self::Staged), + _ => None, + } + } +} + +/// How the proxy inspects requests routed to one path of an endpoint named +/// by `HttpRequest` policies. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct L7Endpoint { + /// Wire protocol parsed per request. + pub protocol: L7Protocol, + /// [`L7Enforcement::Audit`] when every deciding `HttpRequest` policy + /// attached to the path is audit; staged policies do not count. + pub enforcement: L7Enforcement, + /// True when some policy of the path's `host:port` is staged, so the + /// decision staged policies would make is compared for logging. + pub(crate) staged: bool, +} + +impl L7Endpoint { + /// Returns whether two paths inspect requests the same way. + fn same_inspection(self, other: Self) -> bool { + self.protocol == other.protocol && self.enforcement == other.enforcement + } +} + +/// Returns whether a policy is annotated `@enforcement("staged")`. +pub fn is_staged(policy: &Policy) -> bool { + policy.annotation(ENFORCEMENT_ANNOTATION) == Some(EnforcementAnnotation::STAGED) +} + +/// `HttpRequest` policies seen for one inspected `host:port` while analyzing, +/// by `@path`. +#[derive(Default)] +struct DeclaredEndpoint { + paths: BTreeMap, +} + +impl DeclaredEndpoint { + /// Returns the inspection of each path, each decided by its own + /// policies, and warns of an audit path beside enforced policies. + /// + /// # Errors + /// + /// Returns [`CedarEngineError::GenericWebsocketGraphqlPermit`] for a + /// permit on a `websocket-graphql` path that could allow a client + /// message without a GraphQL condition. + fn finish( + self, + host: &str, + port: u16, + warnings: &mut Vec, + ) -> Result, CedarEngineError> { + let endpoint_enforced = self.paths.values().any(|path| path.enforced_policies > 0); + let staged = self.paths.values().any(|path| path.staged_policies > 0); + self.paths + .into_iter() + .map(|(path, declared)| { + if let Some(policy_id) = declared.generic_websocket_permit() { + return Err(CedarEngineError::GenericWebsocketGraphqlPermit { + policy_id, + path, + }); + } + let endpoint = declared.finish(staged); + if endpoint.enforcement == L7Enforcement::Audit && endpoint_enforced { + warnings.push(AnalysisWarning::AuditPathOnEnforcedEndpoint { + endpoint: format!("{host}:{port}"), + path: path.clone(), + }); + } + Ok((path, endpoint)) + }) + .collect() + } +} + +/// `HttpRequest` policies seen for one inspected endpoint path. +#[derive(Default)] +struct DeclaredPath { + protocol: Option, + enforced_policies: usize, + audit_policies: usize, + staged_policies: usize, + /// Permits whose conditions can hold for a `WEBSOCKET_TEXT` request + /// through no condition on a `graphql_*` field, in policy order. + generic_permits: Vec, +} + +impl DeclaredPath { + fn finish(self, staged: bool) -> L7Endpoint { + let audit_only = self.audit_policies > 0 && self.enforced_policies == 0; + L7Endpoint { + protocol: self.protocol.unwrap_or_default(), + enforcement: if audit_only { + L7Enforcement::Audit + } else { + L7Enforcement::Enforce + }, + staged, + } + } + + /// Returns the first permit that could allow a GraphQL-over-WebSocket + /// client message without a GraphQL condition, if the path carries + /// such messages. + fn generic_websocket_permit(&self) -> Option { + (self.protocol == Some(L7Protocol::WebsocketGraphql)) + .then(|| self.generic_permits.first().cloned()) + .flatten() + } +} + +/// A policy set that loads but is likely not what its author meant. +/// +/// Warnings never change how a policy set is enforced; they are reported to +/// the author when the policy is validated or loaded. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum AnalysisWarning { + /// Every deciding `HttpRequest` policy attached to one `@path` is audit + /// while the same `host:port` has enforced `HttpRequest` policies on + /// other paths or without `@path`. Requests routed to the path are logged + /// and forwarded even when an enforced policy denies them, as YAML audits + /// a request routed to an `enforcement: audit` endpoint. + AuditPathOnEnforcedEndpoint { + /// The endpoint, as `"host:port"`. + endpoint: String, + /// The audit-only `@path`; empty for the path-less endpoint. + path: String, + }, +} + +impl fmt::Display for AnalysisWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AuditPathOnEnforcedEndpoint { endpoint, path } => { + let path = if path.is_empty() { + "the path-less endpoint".to_string() + } else { + format!("path `{path}`") + }; + write!( + f, + "{path} of {endpoint} is audit-only: requests routed to it are logged and \ + forwarded even when an enforced policy denies them; to try a policy \ + without changing the path's mode or any decision, annotate it \ + @enforcement(\"staged\") instead" + ) + } + } + } +} + +/// A `NetworkConnect` permit without `@transport("tcp")` whose host cannot +/// be derived, so it may allow a connection to any host. +struct HostlessPermit { + policy_id: String, + /// The port its conditions require, if exactly one is named. + port: Option, +} + +/// Everything derived from an authored policy set at load time. +#[derive(Debug, Clone, Default)] +pub struct PolicyAnalysis { + /// Landlock path grants. + pub(crate) filesystem: FilesystemGrants, + /// Exact `NetworkConnect` endpoints eligible for policy DNS, by host. + pub(crate) dns_endpoints: Vec, + /// Endpoints routed into L7 inspection, keyed by `(host, port)`, with + /// the inspection of each declared path. The host is an exact host or a + /// host glob in policy DNS syntax. The empty path is the endpoint + /// declared by policies without `@path`. + pub(crate) l7_endpoints: BTreeMap<(String, u16), BTreeMap>, + /// The keys of [`Self::l7_endpoints`] whose host is a glob, compiled + /// with the matcher policy DNS and YAML endpoints use, in key order. + pub(crate) l7_host_patterns: Vec, + /// The query keys a policy can read. + pub(crate) query_keys: QueryKeys, + /// What each `NetworkConnect` permit requires of a connection, by id. + pub(crate) network_permits: HashMap, + /// Each permit that reads `destination_ip`, without its address + /// conjuncts, as it takes part in the decision made before resolution. + /// Every other policy, forbids included, takes part as written. + pub(crate) unresolved: HashMap, + /// The binary paths policies test in `context.binary_aliases`. + pub(crate) binary_alias_paths: BTreeSet, + /// The binary glob patterns policies test in `context.binary_globs`. + pub(crate) binary_glob_patterns: BTreeSet, + /// Where each `HttpRequest` permit is attached and whether it is aware + /// of GraphQL, by id. + pub(crate) http_permits: HashMap, + /// Shapes that load but are likely not what the author meant. + pub(crate) warnings: Vec, +} + +/// One inspected endpoint whose host is a glob. +#[derive(Debug, Clone)] +pub struct L7HostPattern { + /// The glob, as keyed in [`PolicyAnalysis::l7_endpoints`]. + pub(crate) host: String, + pub(crate) port: u16, + /// The glob compiled with the YAML endpoint host matcher. + pub(crate) pattern: HostPattern, +} + +impl PolicyAnalysis { + /// Returns the inspected endpoint entries that select `host:port`, the + /// exact entry first and then each glob entry the host matches, each as + /// its key host with the inspection of its paths. + /// + /// `host` must be normalized (lowercase, no trailing dot). When several + /// entries select a host, their paths are inspected together: a request + /// is routed to the most specific matching path of any entry, which the + /// load-time ambiguity check makes well defined. + pub(crate) fn l7_selecting( + &self, + host: &str, + port: u16, + ) -> impl Iterator)> { + let exact = self + .l7_endpoints + .get_key_value(&(host.to_string(), port)) + .map(|((key, _), paths)| (key.as_str(), paths)); + let globs = self + .l7_host_patterns + .iter() + .filter(move |entry| entry.port == port && entry.pattern.matches(host)) + .filter_map(move |entry| { + self.l7_endpoints + .get_key_value(&(entry.host.clone(), port)) + .map(|((key, _), paths)| (key.as_str(), paths)) + }); + exact.into_iter().chain(globs) + } +} + +/// Where one `HttpRequest` permit is attached and what it is aware of. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpPermit { + /// The endpoint it names, as `(host, port)`; the host may be a glob. + pub(crate) endpoint: (String, u16), + /// Its `@path`, or empty without one. + pub(crate) path: String, + /// It reads a `graphql_*` context field or tests `resource.protocol`, + /// so it means to apply to requests parsed as GraphQL. + pub(crate) graphql_aware: bool, +} + +/// What one `NetworkConnect` permit requires of a connection. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkPermit { + /// The host and port the permit names exactly, if any; never a glob. + pub(crate) endpoint: Option<(String, u16)>, + /// The ranges its `destination_ip` condition admits; empty when the + /// permit has no such condition. + pub(crate) allowed_ips: Vec, +} + +/// Which `HttpRequest` query keys policies can read through +/// `context.query`. +/// +/// A key no policy reads cannot change a decision, so leaving it out of the +/// `HttpQuery` entity keeps its repeated values from multiplying the +/// evaluations of a request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QueryKeys { + /// Every `hasTag` and `getTag` names one of these keys literally. + Named(BTreeSet), + /// Some policy computes a tag key, so every key may be read. + Any, +} + +impl Default for QueryKeys { + fn default() -> Self { + Self::Named(BTreeSet::new()) + } +} + +impl QueryKeys { + /// Returns whether a policy can read `key`. + pub fn includes(&self, key: &str) -> bool { + match self { + Self::Named(keys) => keys.contains(key), + Self::Any => true, + } + } + + /// Adds the tag keys `policy`'s conditions read. + fn add_policy(&mut self, policy: &Policy) { + let Ok(json) = policy.to_json() else { + // Without the policy's JSON form its keys are unknown. + *self = Self::Any; + return; + }; + if let Some(conditions) = json.get("conditions") { + self.add_expr(conditions); + } + } + + /// Adds the tag keys of every `hasTag` and `getTag` call within `expr`. + fn add_expr(&mut self, expr: &Value) { + match expr { + Value::Object(fields) => { + for (name, value) in fields { + if name == "hasTag" || name == "getTag" { + let key = value + .get("right") + .and_then(|right| right.get("Value")) + .and_then(Value::as_str); + match (key, &mut *self) { + (Some(key), Self::Named(keys)) => { + keys.insert(key.to_string()); + } + (Some(_), Self::Any) => {} + (None, _) => *self = Self::Any, + } + } + self.add_expr(value); + } + } + Value::Array(values) => { + for value in values { + self.add_expr(value); + } + } + _ => {} + } + } +} + +/// Validates `policies` against `schema` and derives a [`PolicyAnalysis`]. +/// +/// # Errors +/// +/// Returns [`CedarEngineError`] if the policy set fails strict schema +/// validation, contains templates, or contains a policy whose meaning this +/// crate cannot enforce exactly (see the module docs). +pub fn analyze(schema: &Schema, policies: &PolicySet) -> Result { + let validation = Validator::new(schema.clone()).validate(policies, ValidationMode::Strict); + if !validation.validation_passed() { + let reason = validation + .validation_errors() + .map(ToString::to_string) + .collect::>() + .join("; "); + return Err(CedarEngineError::PolicyValidation { reason }); + } + if let Some(template) = policies.templates().next() { + return Err(CedarEngineError::UnsupportedPolicy { + policy_id: template.id().to_string(), + reason: "policy templates are not supported".to_string(), + }); + } + + let declared = openshell_policy_cedar_schema::declared_attributes()?; + let mut read_only = BTreeSet::new(); + let mut read_write = BTreeSet::new(); + let mut dns_ports: BTreeMap<(String, NetworkTransport, Vec), BTreeSet> = + BTreeMap::new(); + let mut network_permits = HashMap::new(); + let mut unresolved = HashMap::new(); + let mut l7_declared: BTreeMap<(String, u16), DeclaredEndpoint> = BTreeMap::new(); + let mut hostless_permits = Vec::new(); + let mut query_keys = QueryKeys::default(); + let mut binary_alias_paths = BTreeSet::new(); + let mut binary_glob_patterns = BTreeSet::new(); + let mut http_permits = HashMap::new(); + + for policy in policies.policies() { + let policy_id = display_id(policy); + query_keys.add_policy(policy); + let json = policy + .to_json() + .map_err(|_| CedarEngineError::UnsupportedPolicy { + policy_id: policy_id.clone(), + reason: "the policy has no JSON form to inspect".to_string(), + })?; + let conditions = json.get("conditions").unwrap_or(&Value::Null); + collect_binary_set_literals( + conditions, + BinarySet::Aliases, + &policy_id, + &mut binary_alias_paths, + )?; + collect_binary_set_literals( + conditions, + BinarySet::Globs, + &policy_id, + &mut binary_glob_patterns, + )?; + check_delimited_patterns(conditions, &policy_id)?; + let scope = ActionScope::of(policy); + check_has_tests(conditions, policy, &policy_id, &scope, schema, &declared)?; + let touches_filesystem = (scope.includes(actions::READ_FILE) + || scope.includes(actions::WRITE_FILE)) + && resource_may_be(policy, entity_types::FILESYSTEM_PATH); + let touches_http_request = scope.includes(actions::HTTP_REQUEST) + && resource_may_be(policy, entity_types::NETWORK_ENDPOINT); + let touches_network_connect = scope.includes(actions::NETWORK_CONNECT) + && resource_may_be(policy, entity_types::NETWORK_ENDPOINT); + + let protocol_annotation = policy.annotation(PROTOCOL_ANNOTATION); + if protocol_annotation.is_some() && !touches_http_request { + return Err(CedarEngineError::UnsupportedPolicy { + policy_id, + reason: "@protocol only applies to policies on the HttpRequest action".to_string(), + }); + } + let path_annotation = policy.annotation(PATH_ANNOTATION); + if path_annotation.is_some() && !touches_http_request { + return Err(CedarEngineError::UnsupportedPolicy { + policy_id, + reason: "@path only applies to policies on the HttpRequest action".to_string(), + }); + } + let enforcement = policy + .annotation(ENFORCEMENT_ANNOTATION) + .map(|value| { + EnforcementAnnotation::from_annotation(value).ok_or_else(|| { + CedarEngineError::UnsupportedEnforcement { + policy_id: policy_id.clone(), + enforcement: value.to_string(), + } + }) + }) + .transpose()? + .unwrap_or_default(); + if enforcement != EnforcementAnnotation::Enforce + && !(touches_http_request && scope.is_only(actions::HTTP_REQUEST)) + { + // Audit and staged policies describe per-request enforcement, and + // staged policies are left out of every decision, so they may + // only name the HttpRequest action. `@enforcement("enforce")` is + // the default and allowed anywhere. + return Err(CedarEngineError::UnsupportedPolicy { + policy_id, + reason: "@enforcement(\"audit\") and @enforcement(\"staged\") only apply to \ + policies whose action scope is exactly the HttpRequest action" + .to_string(), + }); + } + + let transport = policy + .annotation(TRANSPORT_ANNOTATION) + .map(|value| { + NetworkTransport::from_annotation(value).ok_or_else(|| { + CedarEngineError::UnsupportedTransport { + policy_id: policy_id.clone(), + transport: value.to_string(), + } + }) + }) + .transpose()?; + if transport.is_some() + && !(touches_network_connect + && policy.effect() == Effect::Permit + && scope.is_only(actions::NETWORK_CONNECT)) + { + return Err(CedarEngineError::UnsupportedPolicy { + policy_id, + reason: "@transport only applies to permit policies whose action scope is \ + exactly the NetworkConnect action" + .to_string(), + }); + } + + let destination = if touches_network_connect { + destination_ip_use(policy, &policy_id, &scope)? + } else { + DestinationIpUse::None + }; + let allowed_ips = match destination { + DestinationIpUse::None | DestinationIpUse::Forbid => Vec::new(), + DestinationIpUse::Permit { + allowed_ips, + unresolved: stripped, + } => { + unresolved.insert(policy.id().clone(), *stripped); + allowed_ips + } + }; + + if touches_filesystem { + let grant = filesystem_grant(policy, &policy_id, &scope)?; + let target = if grant.writes { + &mut read_write + } else { + &mut read_only + }; + target.extend(grant.paths); + } + + if touches_http_request { + let key = match scope_endpoint(policy) { + Some(endpoint) => parse_endpoint(&policy_id, &endpoint)?, + None => condition_host_glob(policy, &policy_id)?.ok_or_else(|| { + CedarEngineError::UnsupportedPolicy { + policy_id: policy_id.clone(), + reason: "a policy on the HttpRequest action must name its endpoint, in \ + the scope (resource == Sandbox::NetworkEndpoint::\"host:port\") \ + or as exactly one `.`-delimited host glob \ + (`resource.host like(\"*.example.com\", \".\")`) and one \ + `resource.port == ` top-level `when` conjunct, so the \ + proxy knows which connections to inspect" + .to_string(), + } + })?, + }; + let endpoint = format!("{}:{}", key.0, key.1); + let path = path_annotation + .map(|path| validate_path(&policy_id, path)) + .transpose()? + .unwrap_or_default(); + let protocol = protocol_annotation + .map(|value| { + L7Protocol::from_annotation(value).ok_or_else(|| { + CedarEngineError::UnsupportedL7Protocol { + policy_id: policy_id.clone(), + protocol: value.to_string(), + } + }) + }) + .transpose()?; + let declared = l7_declared + .entry(key.clone()) + .or_default() + .paths + .entry(path.clone()) + .or_default(); + match (declared.protocol, protocol) { + (Some(first), Some(second)) if first != second => { + return Err(CedarEngineError::ConflictingL7Protocol { + endpoint: format!("{endpoint}{path}"), + first: first.to_string(), + second: second.to_string(), + }); + } + (None, Some(protocol)) => declared.protocol = Some(protocol), + _ => {} + } + match enforcement { + EnforcementAnnotation::Enforce => declared.enforced_policies += 1, + EnforcementAnnotation::Audit => declared.audit_policies += 1, + EnforcementAnnotation::Staged => declared.staged_policies += 1, + } + if policy.effect() == Effect::Permit { + let reads_graphql = + reads_context_attr(conditions, |attr| attr.starts_with(GRAPHQL_FIELD_PREFIX)); + if permits_method_without_graphql(&json, WEBSOCKET_TEXT_METHOD) { + declared.generic_permits.push(policy_id.clone()); + } + http_permits.insert( + policy.id().clone(), + HttpPermit { + endpoint: key, + path, + graphql_aware: reads_graphql + || mentions_resource_attr(conditions, endpoint_fields::PROTOCOL), + }, + ); + } + } + + if touches_network_connect && policy.effect() == Effect::Permit { + let endpoint = match scope_endpoint(policy) { + Some(endpoint) => Some(parse_endpoint(&policy_id, &endpoint)?), + None => condition_endpoint(policy, &policy_id)?, + }; + if transport == Some(NetworkTransport::Tcp) + && let Some((host, _)) = &endpoint + && !host.contains('*') + { + check_tcp_host(&policy_id, host)?; + } + network_permits.insert( + policy.id().clone(), + NetworkPermit { + endpoint: endpoint.clone().filter(|(host, _)| !host.contains('*')), + allowed_ips: allowed_ips.clone(), + }, + ); + match endpoint { + Some((host, port)) => { + dns_ports + .entry((host, transport.unwrap_or_default(), allowed_ips)) + .or_default() + .insert(port); + } + None if transport.is_some() => { + return Err(CedarEngineError::UnsupportedPolicy { + policy_id, + reason: "a @transport(\"tcp\") permit must name one DNS host and port, \ + in its scope (resource == Sandbox::NetworkEndpoint::\"host:port\") \ + or as `resource.host` and `resource.port` conditions; native TCP \ + has no hostless form" + .to_string(), + }); + } + None => hostless_permits.push(HostlessPermit { + policy_id, + port: condition_port(policy), + }), + } + } + } + + let dns_endpoints: Vec = dns_ports + .into_iter() + .map( + |((host, transport, allowed_ips), ports)| AuthorizedNetworkEndpoint { + host, + ports: ports.into_iter().collect(), + transport, + allowed_ips, + }, + ) + .collect(); + check_tcp_endpoints(&dns_endpoints, l7_declared.keys(), &hostless_permits)?; + + let mut l7_endpoints = BTreeMap::new(); + let mut l7_host_patterns = Vec::new(); + let mut warnings = Vec::new(); + for ((host, port), declared) in l7_declared { + let paths = declared.finish(&host, port, &mut warnings)?; + check_unambiguous_paths(&host, port, &paths)?; + if host.contains('*') { + // `host_glob` only produces patterns the YAML matcher compiles. + let pattern = + HostPattern::new(&host).map_err(|reason| CedarEngineError::UnsupportedPolicy { + policy_id: format!("{host}:{port}"), + reason: format!("host glob cannot be matched: {reason}"), + })?; + l7_host_patterns.push(L7HostPattern { + host: host.clone(), + port, + pattern, + }); + } + l7_endpoints.insert((host, port), paths); + } + check_unambiguous_hosts(&l7_endpoints)?; + + Ok(PolicyAnalysis { + filesystem: FilesystemGrants { + read_only: read_only.into_iter().collect(), + read_write: read_write.into_iter().collect(), + }, + dns_endpoints, + l7_endpoints, + l7_host_patterns, + query_keys, + network_permits, + unresolved, + binary_alias_paths, + binary_glob_patterns, + http_permits, + warnings, + }) +} + +/// A `Set` context field holding binary identities the supervisor +/// computes from the literals policies test it with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BinarySet { + /// `context.binary_aliases`: exact paths resolved as symlinks. + Aliases, + /// `context.binary_globs`: glob patterns matched against the binary and + /// its ancestors. + Globs, +} + +impl BinarySet { + fn field(self) -> &'static str { + match self { + Self::Aliases => context_fields::BINARY_ALIASES, + Self::Globs => context_fields::BINARY_GLOBS, + } + } + + /// The accepted forms of a test on the set, for errors. + fn forms(self) -> String { + format!( + "{} may only be tested with `.contains(\"{}\")`, `.containsAny([...])`, or \ + `.containsAll([...])` with literal {}", + self.field(), + match self { + Self::Aliases => "/path", + Self::Globs => "/pattern/*", + }, + match self { + Self::Aliases => "paths", + Self::Globs => "glob patterns", + } + ) + } + + fn error(self, policy_id: &str, reason: String) -> CedarEngineError { + let policy_id = policy_id.to_string(); + match self { + Self::Aliases => CedarEngineError::InvalidBinaryAlias { policy_id, reason }, + Self::Globs => CedarEngineError::InvalidBinaryGlob { policy_id, reason }, + } + } + + /// Checks one literal the set is tested with. + fn check_literal(self, literal: &str) -> Result<(), String> { + match self { + Self::Aliases => { + if !literal.starts_with('/') || literal.contains('*') { + return Err(format!( + "{literal:?} is not an absolute binary path without `*`; YAML \ + resolves only exact binary paths" + )); + } + } + Self::Globs => { + if !literal.starts_with('/') || !literal.contains('*') { + return Err(format!( + "{literal:?} is not an absolute binary glob pattern with `*`; test \ + an exact path with binary_path, ancestors, and binary_aliases" + )); + } + // The supervisor matches the pattern as the Rego rules match a + // YAML binary glob: `globset` with a literal `/` separator. + globset::GlobBuilder::new(literal) + .literal_separator(true) + .build() + .map_err(|error| format!("{literal:?} is not a valid glob: {error}"))?; + } + } + Ok(()) + } +} + +/// Collects the literals `conditions` tests `set` with; see the module docs +/// for the accepted forms. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::InvalidBinaryAlias`] or +/// [`CedarEngineError::InvalidBinaryGlob`] for a form whose literals cannot +/// be collected exactly. +fn collect_binary_set_literals( + conditions: &Value, + set: BinarySet, + policy_id: &str, + literals: &mut BTreeSet, +) -> Result<(), CedarEngineError> { + binary_set_literals_in(conditions, set, literals).map_err(|reason| set.error(policy_id, reason)) +} + +/// Adds the literals of every test of `set` within `expr`. +/// +/// Returns why a use of the set, or of the whole `context` record, which +/// includes it, is not one of the accepted forms. +fn binary_set_literals_in( + expr: &Value, + set: BinarySet, + literals: &mut BTreeSet, +) -> Result<(), String> { + match expr { + Value::Object(fields) => { + for (name, value) in fields { + if name == "Var" && value.as_str() == Some("context") { + return Err(format!( + "the whole `context` record includes {}; read its attributes instead", + set.field() + )); + } + if (name == "." || name == "has") + && let Some(attr) = context_attr(value) + { + if name == "." && attr == set.field() { + return Err(set.forms()); + } + continue; + } + if matches!(name.as_str(), "contains" | "containsAny" | "containsAll") + && value + .get("left") + .and_then(|left| left.get(".")) + .and_then(context_attr) + == Some(set.field()) + { + let right = value.get("right").unwrap_or(&Value::Null); + let elements = if name == "contains" { + vec![right] + } else { + right + .get("Set") + .and_then(Value::as_array) + .map(|elements| elements.iter().collect()) + .ok_or_else(|| set.forms())? + }; + for element in elements { + let literal = element + .get("Value") + .and_then(Value::as_str) + .ok_or_else(|| set.forms())?; + set.check_literal(literal)?; + literals.insert(literal.to_string()); + } + continue; + } + binary_set_literals_in(value, set, literals)?; + } + Ok(()) + } + Value::Array(values) => values + .iter() + .try_for_each(|value| binary_set_literals_in(value, set, literals)), + _ => Ok(()), + } +} + +/// Rejects `has` tests on `principal`, `resource`, or `context` of an +/// attribute the schema does not declare for a request the policy can +/// apply to; see the module docs. +/// +/// Each request environment of the schema whose action is in the policy's +/// scope and whose resource type its resource scope admits is checked, so a +/// policy on both network actions that tests `context has method` is +/// rejected for `NetworkConnect`, as a read of `context.method` is. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::PolicyValidation`] for the first such test. +fn check_has_tests( + conditions: &Value, + policy: &Policy, + policy_id: &str, + scope: &ActionScope, + schema: &Schema, + declared: &DeclaredAttributes, +) -> Result<(), CedarEngineError> { + let mut tests = Vec::new(); + collect_var_has_tests(conditions, &mut tests); + for (variable, attr) in tests { + for env in schema.request_envs() { + let action = env.action().id().unescaped(); + if !scope.includes(action) { + continue; + } + let (subject, attributes) = match variable { + "context" => (format!("action {action}"), declared.context(action)), + "principal" => ( + env.principal().to_string(), + declared.entity(&env.principal().to_string()), + ), + _ => { + let resource = env.resource().to_string(); + if !resource_may_be(policy, &resource) { + continue; + } + (resource.clone(), declared.entity(&resource)) + } + }; + if !attributes.contains(attr) { + return Err(CedarEngineError::PolicyValidation { + reason: format!( + "policy {policy_id:?} tests `{variable} has {attr}`, but the schema \ + declares no attribute {attr:?} on {subject}, so the test can never hold" + ), + }); + } + } + } + Ok(()) +} + +/// Collects the `(variable, attribute)` of every `has` node within `expr` +/// whose left operand is `principal`, `resource`, or `context`. +fn collect_var_has_tests<'a>(expr: &'a Value, tests: &mut Vec<(&'a str, &'a str)>) { + match expr { + Value::Object(fields) => { + for (name, value) in fields { + if name == "has" + && let Some(variable) = value + .get("left") + .and_then(|left| left.get("Var")) + .and_then(Value::as_str) + && let Some(attr) = value.get("attr").and_then(Value::as_str) + && matches!(variable, "principal" | "resource" | "context") + { + tests.push((variable, attr)); + } + collect_var_has_tests(value, tests); + } + } + Value::Array(values) => { + for value in values { + collect_var_has_tests(value, tests); + } + } + _ => {} + } +} + +/// Rejects delimited `like` patterns within `expr` whose `**` is next to a +/// character other than the delimiter; see the module docs. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::AmbiguousRecursiveWildcard`] for the first +/// such pattern. +fn check_delimited_patterns(expr: &Value, policy_id: &str) -> Result<(), CedarEngineError> { + match expr { + Value::Object(fields) => { + for (name, value) in fields { + if name == "like" + && let Some(delimiter) = value.get("delim").and_then(Value::as_str) + && let Some(elements) = value.get("pattern").and_then(Value::as_array) + && let Some(pattern) = ambiguous_recursive_wildcard(elements, delimiter) + { + return Err(CedarEngineError::AmbiguousRecursiveWildcard { + policy_id: policy_id.to_string(), + pattern, + delimiter: delimiter.to_string(), + }); + } + check_delimited_patterns(value, policy_id)?; + } + Ok(()) + } + Value::Array(values) => values + .iter() + .try_for_each(|value| check_delimited_patterns(value, policy_id)), + _ => Ok(()), + } +} + +/// Returns the pattern's text when a `MultiWildcard` in `elements` is next +/// to anything other than `delimiter` or the pattern's ends. +/// +/// Rego's glob matcher reads a `**` that is not a whole segment as `*`, +/// while Cedar's `**` always crosses segments, so such a pattern cannot mean +/// the same in both. +fn ambiguous_recursive_wildcard(elements: &[Value], delimiter: &str) -> Option { + // Each element as its text, with `*` and `**` for wildcards; an escaped + // `*` in a literal is written `\*`. + let texts: Vec = elements + .iter() + .map(|element| match element { + Value::String(kind) if kind == "Wildcard" => "*".to_string(), + Value::String(kind) if kind == "MultiWildcard" => "**".to_string(), + Value::Object(literal) => literal + .get("Literal") + .and_then(Value::as_str) + .unwrap_or_default() + .replace('*', "\\*"), + _ => String::new(), + }) + .collect(); + let adjacent_to_text = |neighbor: Option<&String>, from_end: bool| { + neighbor.is_some_and(|text| { + if text == "*" || text == "**" { + return true; + } + let boundary = if from_end { + text.chars().next_back() + } else { + text.chars().next() + }; + boundary.is_some_and(|c| !delimiter.starts_with(c)) + }) + }; + let ambiguous = texts.iter().enumerate().any(|(index, text)| { + text == "**" + && (adjacent_to_text(index.checked_sub(1).and_then(|i| texts.get(i)), true) + || adjacent_to_text(texts.get(index + 1), false)) + }); + ambiguous.then(|| texts.concat()) +} + +/// True if a `.` or `has` node within `expr` reads a `context` attribute +/// `accept` accepts, or the whole `context` record, which includes them. +fn reads_context_attr(expr: &Value, accept: impl Fn(&str) -> bool + Copy) -> bool { + match expr { + Value::Object(fields) => fields.iter().any(|(name, value)| { + if name == "Var" && value.as_str() == Some("context") { + return true; + } + if (name == "." || name == "has") + && let Some(attr) = context_attr(value) + { + return accept(attr); + } + reads_context_attr(value, accept) + }), + Value::Array(values) => values.iter().any(|value| reads_context_attr(value, accept)), + _ => false, + } +} + +/// True if `expr` reads `resource.`, or the whole `resource` record. +fn mentions_resource_attr(expr: &Value, attr: &str) -> bool { + match expr { + Value::Object(fields) => fields.iter().any(|(name, value)| { + if name == "Var" && value.as_str() == Some("resource") { + return true; + } + if name == "." && is_resource_attr(expr, attr) { + return true; + } + mentions_resource_attr(value, attr) + }), + Value::Array(values) => values + .iter() + .any(|value| mentions_resource_attr(value, attr)), + _ => false, + } +} + +/// What a permit's conditions can be for a GraphQL-over-WebSocket client +/// message, abstracted over everything but the method and the GraphQL +/// fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MessageTruth { + /// Always true for such a message. + True, + /// Never true for such a message. + False, + /// May be true, depending on fields other than `graphql_*`. + Maybe, + /// Can only be true through a condition on a `graphql_*` field. + Graphql, +} + +impl MessageTruth { + fn not(self) -> Self { + match self { + Self::True => Self::False, + Self::False => Self::True, + other => other, + } + } + + fn and(self, other: Self) -> Self { + match (self, other) { + (Self::False, _) | (_, Self::False) => Self::False, + (Self::Graphql, _) | (_, Self::Graphql) => Self::Graphql, + (Self::Maybe, _) | (_, Self::Maybe) => Self::Maybe, + (Self::True, Self::True) => Self::True, + } + } + + fn or(self, other: Self) -> Self { + match (self, other) { + (Self::True, _) | (_, Self::True) => Self::True, + (Self::Maybe, _) | (_, Self::Maybe) => Self::Maybe, + (Self::Graphql, _) | (_, Self::Graphql) => Self::Graphql, + (Self::False, Self::False) => Self::False, + } + } + + /// Whether the conditions can hold without a condition on a `graphql_*` + /// field. + fn satisfiable_without_graphql(self) -> bool { + matches!(self, Self::True | Self::Maybe) + } +} + +/// Returns whether the policy's conditions can hold for a request with +/// `method` through no condition on a `graphql_*` field. +/// +/// Evaluates the `when` and `unless` bodies in [`MessageTruth`]: a test of +/// `context.method` against literals is decided for `method`, a test that +/// reads a `graphql_*` field is [`MessageTruth::Graphql`], and any other +/// test may hold. So a permit whose conditions are `GET || WEBSOCKET_TEXT || +/// (WEBSOCKET_TEXT && graphql...)` can, while one with `GET || +/// (WEBSOCKET_TEXT && graphql...)` cannot. +fn permits_method_without_graphql(json: &Value, method: &str) -> bool { + let conditions = json + .get("conditions") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + conditions + .iter() + .fold(MessageTruth::True, |truth, condition| { + let body = condition.get("body").unwrap_or(&Value::Null); + let body = message_truth(body, method); + truth.and( + if condition.get("kind").and_then(Value::as_str) == Some("unless") { + body.not() + } else { + body + }, + ) + }) + .satisfiable_without_graphql() +} + +/// The string of a `Value` literal node. +fn string_literal(node: &Value) -> Option<&str> { + node.get("Value").and_then(Value::as_str) +} + +/// Evaluates `expr` in [`MessageTruth`] for a request with `method`. +fn message_truth(expr: &Value, method: &str) -> MessageTruth { + let Some(fields) = expr.as_object() else { + return MessageTruth::Maybe; + }; + let Some((name, value)) = fields.iter().next() else { + return MessageTruth::Maybe; + }; + let operands = |node: &Value| { + node.get("left") + .zip(node.get("right")) + .map(|(left, right)| (message_truth(left, method), message_truth(right, method))) + }; + let is_method = |node: &Value| { + node.get(".") + .and_then(context_attr) + .is_some_and(|attr| attr == context_fields::METHOD) + }; + match name.as_str() { + "Value" => match value { + Value::Bool(true) => MessageTruth::True, + Value::Bool(false) => MessageTruth::False, + _ => MessageTruth::Maybe, + }, + "&&" => operands(value).map_or(MessageTruth::Maybe, |(left, right)| left.and(right)), + "||" => operands(value).map_or(MessageTruth::Maybe, |(left, right)| left.or(right)), + "!" => message_truth(value.get("arg").unwrap_or(&Value::Null), method).not(), + "if-then-else" => { + let condition = message_truth(value.get("if").unwrap_or(&Value::Null), method); + let then = message_truth(value.get("then").unwrap_or(&Value::Null), method); + let otherwise = message_truth(value.get("else").unwrap_or(&Value::Null), method); + condition.and(then).or(condition.not().and(otherwise)) + } + "==" | "!=" => { + let (Some(left), Some(right)) = (value.get("left"), value.get("right")) else { + return MessageTruth::Maybe; + }; + let compared = if is_method(left) { + string_literal(right) + } else if is_method(right) { + string_literal(left) + } else { + None + }; + let truth = match compared { + Some(compared) if compared == method => MessageTruth::True, + Some(_) => MessageTruth::False, + None if reads_context_attr(expr, |attr| attr.starts_with(GRAPHQL_FIELD_PREFIX)) => { + MessageTruth::Graphql + } + None => MessageTruth::Maybe, + }; + if name == "!=" { truth.not() } else { truth } + } + "contains" + if value.get("right").is_some_and(is_method) + && let Some(elements) = value + .get("left") + .and_then(|left| left.get("Set")) + .and_then(Value::as_array) => + { + if elements + .iter() + .any(|element| string_literal(element) == Some(method)) + { + MessageTruth::True + } else if elements + .iter() + .all(|element| string_literal(element).is_some()) + { + MessageTruth::False + } else { + MessageTruth::Maybe + } + } + _ if reads_context_attr(expr, |attr| attr.starts_with(GRAPHQL_FIELD_PREFIX)) => { + MessageTruth::Graphql + } + _ => MessageTruth::Maybe, + } +} + +/// The attribute an `attr` or `has` node reads from `context`, if it reads +/// one from `context` directly. +fn context_attr(access: &Value) -> Option<&str> { + let reads_context = access + .get("left") + .and_then(|left| left.get("Var")) + .and_then(Value::as_str) + == Some("context"); + reads_context + .then(|| access.get("attr").and_then(Value::as_str)) + .flatten() +} + +/// How a `NetworkConnect` policy reads `context.destination_ip`. +enum DestinationIpUse { + /// It does not. + None, + /// A `forbid` reads it, in any form. It takes part in every decision as + /// written: before resolution with the address absent, so its guarded + /// address conditions are false, and once per resolved address after. + Forbid, + /// A `permit` requires the address to lie in `allowed_ips`. + Permit { + allowed_ips: Vec, + /// The permit without its address conjuncts. + unresolved: Box, + }, +} + +/// Classifies how a policy that can apply to `NetworkConnect` reads +/// `context.destination_ip`; see the module docs for the accepted forms. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::InvalidDestinationIp`] for a form whose +/// ranges cannot be derived exactly. +fn destination_ip_use( + policy: &Policy, + policy_id: &str, + scope: &ActionScope, +) -> Result { + let invalid = |reason: &str| CedarEngineError::InvalidDestinationIp { + policy_id: policy_id.to_string(), + reason: reason.to_string(), + }; + let Ok(mut json) = policy.to_json() else { + return Err(invalid("the policy has no JSON form to inspect")); + }; + let conditions = json + .get("conditions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !conditions.iter().any(reads_destination_ip) { + return Ok(DestinationIpUse::None); + } + if !scope.is_only(actions::NETWORK_CONNECT) { + return Err(invalid( + "a policy that reads destination_ip must have exactly the NetworkConnect action in \ + its scope", + )); + } + if policy.effect() == Effect::Forbid { + return Ok(DestinationIpUse::Forbid); + } + + let mut allowed_ips: Option> = None; + let mut stripped_conditions = Vec::with_capacity(conditions.len()); + for condition in &conditions { + let body = condition.get("body").unwrap_or(&Value::Null); + if condition.get("kind").and_then(Value::as_str) != Some("when") { + if reads_destination_ip(body) { + return Err(invalid( + "a permit may read destination_ip only in `when` conditions", + )); + } + stripped_conditions.push(condition.clone()); + continue; + } + let mut conjuncts = Vec::new(); + collect_conjuncts(body, &mut conjuncts); + let mut kept = Vec::new(); + for conjunct in conjuncts { + if !reads_destination_ip(conjunct) { + kept.push(conjunct.clone()); + continue; + } + if is_destination_ip_guard(conjunct) { + continue; + } + let mut literals = Vec::new(); + if !range_disjunction(conjunct, &mut literals) { + return Err(invalid( + "each `when` conjunct that reads destination_ip must be \ + `context has destination_ip` or an `||` of \ + `context.destination_ip.isInRange(ip(\"...\"))` tests", + )); + } + let ranges = literals + .iter() + .map(|literal| parse_range(literal).map_err(|reason| invalid(&reason))) + .collect::, _>>()?; + allowed_ips = Some(match allowed_ips { + None => normalize_ranges(ranges), + Some(previous) => intersect_ranges(&previous, &ranges), + }); + } + let mut stripped = condition.clone(); + stripped["body"] = kept + .into_iter() + .reduce(|left, right| serde_json::json!({ "&&": { "left": left, "right": right } })) + .unwrap_or_else(|| serde_json::json!({ "Value": true })); + stripped_conditions.push(stripped); + } + let Some(allowed_ips) = allowed_ips else { + return Err(invalid( + "a permit that reads destination_ip must require it to be in a range with \ + `context.destination_ip.isInRange(ip(\"...\"))`", + )); + }; + if allowed_ips.is_empty() { + return Err(invalid("the destination_ip ranges admit no address")); + } + json["conditions"] = Value::Array(stripped_conditions); + let unresolved = Policy::from_json(Some(policy.id().clone()), json) + .map_err(|e| invalid(&format!("the policy without its address conditions: {e}")))?; + Ok(DestinationIpUse::Permit { + allowed_ips, + unresolved: Box::new(unresolved), + }) +} + +/// True if `expr` reads `context.destination_ip`, tests for it, or uses the +/// whole `context` record, which includes it. +fn reads_destination_ip(expr: &Value) -> bool { + match expr { + Value::Object(fields) => fields.iter().any(|(name, value)| { + // A variable reference; a record key named `Var` holds an + // expression instead, which is inspected below. + if name == "Var" + && let Some(variable) = value.as_str() + { + return variable == "context"; + } + if (name == "." || name == "has") + && value + .get("left") + .and_then(|left| left.get("Var")) + .and_then(Value::as_str) + == Some("context") + { + return value.get("attr").and_then(Value::as_str) + == Some(context_fields::DESTINATION_IP); + } + reads_destination_ip(value) + }), + Value::Array(values) => values.iter().any(reads_destination_ip), + _ => false, + } +} + +/// True if `expr` is `context has destination_ip`. +fn is_destination_ip_guard(expr: &Value) -> bool { + expr.get("has").is_some_and(|has| { + has.get("left") + .and_then(|left| left.get("Var")) + .and_then(Value::as_str) + == Some("context") + && has.get("attr").and_then(Value::as_str) == Some(context_fields::DESTINATION_IP) + }) +} + +/// Collects the `ip("...")` literals of an `||` of +/// `context.destination_ip.isInRange(ip("..."))` tests. +/// +/// Returns `false` if any node has another form. +fn range_disjunction(expr: &Value, literals: &mut Vec) -> bool { + if let Some(or) = expr.get("||") { + return match (or.get("left"), or.get("right")) { + (Some(left), Some(right)) => { + range_disjunction(left, literals) && range_disjunction(right, literals) + } + _ => false, + }; + } + let Some([address, range]) = expr + .get("isInRange") + .and_then(Value::as_array) + .map(Vec::as_slice) + else { + return false; + }; + let reads_address = address.get(".").is_some_and(|access| { + access + .get("left") + .and_then(|left| left.get("Var")) + .and_then(Value::as_str) + == Some("context") + && access.get("attr").and_then(Value::as_str) == Some(context_fields::DESTINATION_IP) + }); + let literal = + range + .get("ip") + .and_then(Value::as_array) + .and_then(|args| match args.as_slice() { + [arg] => arg.get("Value").and_then(Value::as_str), + _ => None, + }); + match literal { + Some(literal) if reads_address => { + literals.push(literal.to_string()); + true + } + _ => false, + } +} + +/// Parses an `ip("...")` literal as a range, a bare address being a single +/// host, and rejects ranges the proxy always blocks. +fn parse_range(literal: &str) -> Result { + let range = literal + .parse::() + .or_else(|_| literal.parse::().map(IpNet::from)) + .map_err(|_| format!("ip({literal:?}) is not an address or CIDR range"))?; + if openshell_core::net::is_always_blocked_net(range) { + return Err(format!( + "ip({literal:?}) overlaps loopback, link-local, or unspecified addresses, which \ + are blocked regardless of policy" + )); + } + Ok(range) +} + +/// Sorts `ranges` and drops any range inside another. +fn normalize_ranges(mut ranges: Vec) -> Vec { + ranges.sort(); + ranges.dedup(); + let all = ranges.clone(); + ranges.retain(|range| { + !all.iter() + .any(|other| other != range && other.contains(range)) + }); + ranges +} + +/// Returns the addresses in both unions of ranges, as ranges. +/// +/// Two CIDR ranges either nest or are disjoint, so the intersection of two +/// unions is a union of ranges from either side. +fn intersect_ranges(left: &[IpNet], right: &[IpNet]) -> Vec { + let mut both = Vec::new(); + for a in left { + for b in right { + if a.contains(b) { + both.push(*b); + } else if b.contains(a) { + both.push(*a); + } + } + } + normalize_ranges(both) +} + +/// Longest DNS label, in octets. +const MAX_DNS_LABEL_LEN: usize = 63; + +/// Longest DNS name, in octets, without the root dot. +const MAX_DNS_NAME_LEN: usize = 253; + +/// Checks that a native TCP endpoint names a DNS host, as YAML requires of a +/// `protocol: tcp` endpoint. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::InvalidTcpHost`] for an IP literal or a host +/// that is not a valid DNS name. +fn check_tcp_host(policy_id: &str, host: &str) -> Result<(), CedarEngineError> { + let invalid = |reason: &str| CedarEngineError::InvalidTcpHost { + policy_id: policy_id.to_string(), + host: host.to_string(), + reason: reason.to_string(), + }; + let unbracketed = host + .strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .unwrap_or(host); + if unbracketed.parse::().is_ok() { + return Err(invalid( + "native TCP reaches hosts through policy DNS, so it cannot name an IP address", + )); + } + let valid = exact_host(host).is_some() + && host.len() <= MAX_DNS_NAME_LEN + && host + .split('.') + .all(|label| label.len() <= MAX_DNS_LABEL_LEN); + if !valid { + return Err(invalid("not a valid DNS host name")); + } + Ok(()) +} + +/// Rejects a native TCP endpoint that another endpoint can also select. +/// +/// YAML rejects a `protocol: tcp` endpoint that overlaps, on a host and +/// port, an endpoint without it or an inspected endpoint, because whether a +/// destination is native TCP must not depend on which endpoint matched. A +/// permit whose host cannot be derived may allow any host, so it overlaps +/// every native TCP endpoint on a port it may allow. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::ConflictingTransport`] for the first overlap. +fn check_tcp_endpoints<'a>( + endpoints: &[AuthorizedNetworkEndpoint], + inspected: impl Iterator + Clone, + hostless_permits: &[HostlessPermit], +) -> Result<(), CedarEngineError> { + let conflict = |tcp_host: &str, other_host: &str, port: u16, reason: &str| { + CedarEngineError::ConflictingTransport { + tcp_endpoint: format!("{tcp_host}:{port}"), + other_endpoint: format!("{other_host}:{port}"), + reason: reason.to_string(), + } + }; + for tcp in endpoints + .iter() + .filter(|endpoint| endpoint.transport == NetworkTransport::Tcp) + { + for permit in hostless_permits { + // A permit naming no port may allow every port of the endpoint. + let shared = permit.port.map_or_else( + || tcp.ports.first().copied(), + |port| tcp.ports.contains(&port).then_some(port), + ); + if let Some(port) = shared { + return Err(CedarEngineError::ConflictingTransport { + tcp_endpoint: format!("{}:{port}", tcp.host), + other_endpoint: permit + .port + .map_or_else(|| "*:*".to_string(), |port| format!("*:{port}")), + reason: format!( + "which NetworkConnect permit {:?} may allow without @transport(\"tcp\"): \ + its host cannot be derived from its conditions, so name the host with \ + `resource.host == \"...\"` or a `.`-delimited `resource.host like`, \ + with `resource.port`, as one top-level `when` conjunct each", + permit.policy_id + ), + }); + } + } + for (host, port) in inspected.clone() { + if tcp.ports.contains(port) && host_patterns_overlap(&tcp.host, host) { + return Err(conflict( + &tcp.host, + host, + *port, + "which HttpRequest policies inspect; native TCP is never inspected per request", + )); + } + } + for other in endpoints + .iter() + .filter(|endpoint| endpoint.transport != NetworkTransport::Tcp) + { + let shared = tcp.ports.iter().find(|port| other.ports.contains(port)); + if let Some(port) = shared + && host_patterns_overlap(&tcp.host, &other.host) + { + return Err(conflict( + &tcp.host, + &other.host, + *port, + "which a NetworkConnect permit names without @transport(\"tcp\")", + )); + } + } + } + Ok(()) +} + +/// One element of a host pattern, for [`host_patterns_overlap`]. +#[derive(Clone, Copy)] +enum HostToken { + Literal(char), + /// `*` (within one label) or `**` (across labels). + Star { + crosses_labels: bool, + }, +} + +fn host_tokens(pattern: &str) -> Vec { + let mut tokens = Vec::new(); + let mut chars = pattern.chars().peekable(); + while let Some(c) = chars.next() { + if c == '*' { + let crosses_labels = chars.next_if_eq(&'*').is_some(); + while chars.next_if_eq(&'*').is_some() {} + tokens.push(HostToken::Star { crosses_labels }); + } else { + tokens.push(HostToken::Literal(c)); + } + } + tokens +} + +/// Returns whether some host matches both host patterns, each an exact +/// host or a host glob in policy DNS syntax. +pub fn host_patterns_overlap(left: &str, right: &str) -> bool { + let (left, right) = (host_tokens(left), host_tokens(right)); + let accepts = |token: HostToken, c: char| match token { + HostToken::Literal(literal) => literal == c, + HostToken::Star { crosses_labels } => crosses_labels || c != '.', + }; + // Search the states (position in left, position in right) reachable + // while reading one host through both patterns. + let mut seen = vec![vec![false; right.len() + 1]; left.len() + 1]; + let mut pending = vec![(0, 0)]; + while let Some((i, j)) = pending.pop() { + if std::mem::replace(&mut seen[i][j], true) { + continue; + } + if i == left.len() && j == right.len() { + return true; + } + let (l, r) = (left.get(i).copied(), right.get(j).copied()); + // A star may match nothing. + if let Some(HostToken::Star { .. }) = l { + pending.push((i + 1, j)); + } + if let Some(HostToken::Star { .. }) = r { + pending.push((i, j + 1)); + } + // Read one character that both patterns accept. Two stars reading + // the same character stay where they are, so only a literal moves. + match (l, r) { + (Some(HostToken::Literal(a)), Some(HostToken::Literal(b))) if a == b => { + pending.push((i + 1, j + 1)); + } + (Some(HostToken::Literal(c)), Some(star @ HostToken::Star { .. })) + if accepts(star, c) => + { + pending.push((i + 1, j)); + } + (Some(star @ HostToken::Star { .. }), Some(HostToken::Literal(c))) + if accepts(star, c) => + { + pending.push((i, j + 1)); + } + _ => {} + } + } + false +} + +/// Checks an `@path` value against the YAML endpoint `path` grammar. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::InvalidL7Path`] if the value is empty or +/// neither starts with `/` nor is `**`. +fn validate_path(policy_id: &str, path: &str) -> Result { + let invalid = |reason: &str| CedarEngineError::InvalidL7Path { + policy_id: policy_id.to_string(), + path: path.to_string(), + reason: reason.to_string(), + }; + if path.is_empty() { + return Err(invalid( + "must not be empty; omit @path for the endpoint's path-less inspection", + )); + } + if !path.starts_with('/') && path != "**" { + return Err(invalid("must start with '/' or be '**'")); + } + Ok(path.to_string()) +} + +/// Ranks a path as the relay ranks endpoint paths when selecting one for a +/// request: the matching path with the most characters other than `*` wins. +fn path_specificity(path: &str) -> usize { + path.chars().filter(|c| *c != '*').count() +} + +/// Rejects two paths of one endpoint that the relay could not order. +/// +/// The relay selects the matching path with the highest +/// [`path_specificity`]; between equally specific paths it would fall back +/// to their order. Such paths must inspect the same way when a request can +/// match both, as YAML requires of endpoints sharing a `host:port`. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::AmbiguousL7Paths`] for the first such pair. +fn check_unambiguous_paths( + host: &str, + port: u16, + paths: &BTreeMap, +) -> Result<(), CedarEngineError> { + for (index, first) in paths.iter().enumerate() { + if let Some((first, second)) = paths + .iter() + .skip(index + 1) + .find_map(|second| ambiguous_path_pair(first, second)) + { + return Err(CedarEngineError::AmbiguousL7Paths { + endpoint: format!("{host}:{port}"), + first: first.to_string(), + second: second.to_string(), + }); + } + } + Ok(()) +} + +/// Returns the two paths if the relay could not order them: equally +/// specific, able to match one request, and inspected differently. +fn ambiguous_path_pair<'a>( + (first, first_endpoint): (&'a String, &L7Endpoint), + (second, second_endpoint): (&'a String, &L7Endpoint), +) -> Option<(&'a str, &'a str)> { + (path_specificity(first) == path_specificity(second) + && !first_endpoint.same_inspection(*second_endpoint) + && paths_may_overlap(first, second)) + .then_some((first.as_str(), second.as_str())) +} + +/// Rejects two inspected endpoints that can select one host but inspect an +/// equally specific overlapping path differently. +/// +/// A host matched by an exact entry and a glob, or by two globs, is routed +/// to the most specific matching path of every entry selecting it, so the +/// paths of such entries must agree where [`check_unambiguous_paths`] would +/// require it of one entry. YAML rejects endpoints whose hosts and ports +/// overlap with the same conflict. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::AmbiguousL7Hosts`] for the first such pair. +fn check_unambiguous_hosts( + endpoints: &BTreeMap<(String, u16), BTreeMap>, +) -> Result<(), CedarEngineError> { + for (index, ((first_host, port), first_paths)) in endpoints.iter().enumerate() { + for ((second_host, second_port), second_paths) in endpoints.iter().skip(index + 1) { + if port != second_port || !host_patterns_overlap(first_host, second_host) { + continue; + } + let conflict = first_paths.iter().find_map(|first| { + second_paths + .iter() + .find_map(|second| ambiguous_path_pair(first, second)) + }); + if let Some((first_path, second_path)) = conflict { + return Err(CedarEngineError::AmbiguousL7Hosts { + first: format!("{first_host}:{port}"), + second: format!("{second_host}:{port}"), + first_path: first_path.to_string(), + second_path: second_path.to_string(), + }); + } + } + } + Ok(()) +} + +/// An endpoint path pattern in the shapes [`paths_may_overlap`] compares exactly. +enum PathShape<'a> { + /// Matches every path. + Any, + /// Matches exactly this path. + Literal(&'a str), + /// Matches this path and its descendants. + Subtree(&'a str), + /// Any other glob. + Glob, +} + +impl<'a> PathShape<'a> { + fn of(path: &'a str) -> Self { + let has_glob = |text: &str| text.contains(['*', '?', '[', ']', '{', '}']); + if path.is_empty() || path == "**" || path == "/**" { + Self::Any + } else if let Some(prefix) = path.strip_suffix("/**") + && !has_glob(prefix) + { + Self::Subtree(prefix) + } else if has_glob(path) { + Self::Glob + } else { + Self::Literal(path) + } + } +} + +/// Returns whether some request path could match both patterns. +/// +/// Exact for literal paths and `/prefix/**` subtrees with literal prefixes; +/// any other glob is assumed to overlap, which only rejects more policies. +fn paths_may_overlap(first: &str, second: &str) -> bool { + let under = |path: &str, prefix: &str| { + path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }; + match (PathShape::of(first), PathShape::of(second)) { + (PathShape::Literal(left), PathShape::Literal(right)) => left == right, + (PathShape::Literal(path), PathShape::Subtree(prefix)) + | (PathShape::Subtree(prefix), PathShape::Literal(path)) => under(path, prefix), + (PathShape::Subtree(left), PathShape::Subtree(right)) => { + under(left, right) || under(right, left) + } + _ => true, + } +} + +/// Which `Sandbox::Action`s a policy's action scope can match. +enum ActionScope { + /// Unconstrained `action`: every action in the schema. + Any, + /// The listed action ids. + Listed(BTreeSet), +} + +impl ActionScope { + fn of(policy: &Policy) -> Self { + let ids = |uids: &[EntityUid]| { + uids.iter() + .filter(|uid| uid.type_name().to_string() == actions::ACTION_TYPE) + .map(|uid| uid.id().unescaped().to_string()) + .collect() + }; + match policy.action_constraint() { + ActionConstraint::Any => Self::Any, + ActionConstraint::Eq(uid) => Self::Listed(ids(&[uid])), + ActionConstraint::In(uids) => Self::Listed(ids(&uids)), + } + } + + fn includes(&self, action: &str) -> bool { + match self { + Self::Any => true, + Self::Listed(ids) => ids.contains(action), + } + } + + /// True if the scope lists exactly `action`. + fn is_only(&self, action: &str) -> bool { + matches!(self, Self::Listed(ids) if ids.len() == 1 && ids.contains(action)) + } + + /// True if every action in scope is `ReadFile` or `WriteFile`. + fn is_filesystem_only(&self) -> bool { + match self { + Self::Any => false, + Self::Listed(ids) => ids + .iter() + .all(|id| id == actions::READ_FILE || id == actions::WRITE_FILE), + } + } +} + +/// True if the policy's resource scope can match an entity of `type_name`. +fn resource_may_be(policy: &Policy, type_name: &str) -> bool { + match policy.resource_constraint() { + ResourceConstraint::Any => true, + ResourceConstraint::Is(entity_type) | ResourceConstraint::IsIn(entity_type, _) => { + entity_type.to_string() == type_name + } + ResourceConstraint::Eq(uid) | ResourceConstraint::In(uid) => { + uid.type_name().to_string() == type_name + } + } +} + +/// Returns the `NetworkEndpoint` id named in the policy's resource scope. +/// +/// `NetworkEndpoint` has no parent types, so `resource in E` matches exactly +/// `E`, the same as `resource == E`. +fn scope_endpoint(policy: &Policy) -> Option { + match policy.resource_constraint() { + ResourceConstraint::Eq(uid) + | ResourceConstraint::In(uid) + | ResourceConstraint::IsIn(_, uid) + if uid.type_name().to_string() == entity_types::NETWORK_ENDPOINT => + { + Some(uid.id().unescaped().to_string()) + } + _ => None, + } +} + +/// Splits a `"host:port"` endpoint id into a lowercase host and a port. +fn parse_endpoint(policy_id: &str, endpoint: &str) -> Result<(String, u16), CedarEngineError> { + let invalid = |reason: &str| CedarEngineError::InvalidEndpoint { + policy_id: policy_id.to_string(), + endpoint: endpoint.to_string(), + reason: reason.to_string(), + }; + let (host, port) = endpoint + .rsplit_once(':') + .ok_or_else(|| invalid("expected \"host:port\""))?; + let port = port + .parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| invalid("port must be in 1..=65535"))?; + if host.is_empty() { + return Err(invalid("host must not be empty")); + } + if host.ends_with('.') { + return Err(invalid("host must not end with '.'")); + } + if host != host.to_ascii_lowercase() { + // Requests are matched against the lowercased host, so an uppercase + // literal could never match. + return Err(invalid("host must be lowercase")); + } + if host.contains('*') { + // An entity id matches only itself, so `*` here is a literal star that + // no host contains, while policy DNS would read it as a wildcard. + return Err(invalid( + "host must not contain '*'; match hosts with \ + `resource.host like(\"*.example.com\", \".\")` in a `when` clause", + )); + } + Ok((host.to_string(), port)) +} + +/// Delimiter that makes a `like` on `resource.host` a DNS-label host glob. +const HOST_LABEL_DELIMITER: &str = "."; + +/// Returns the host and port a `permit`'s `when` conditions require. +/// +/// Every top-level `&&` operand of a `when` condition is necessary for the +/// policy to apply, so a host and port found there bound every endpoint the +/// policy can allow, the same over-approximation YAML eligibility makes by +/// ignoring binaries. Returns `None`, so the policy grants no eligibility, +/// unless exactly one operand constrains the host and one the port, both in a +/// recognised form. A `resource.host_port` literal constrains both, so it +/// counts as the host operand and the port operand. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::InvalidEndpoint`] for a `host_port` literal +/// that could never match a request's endpoint, as for a scope literal. +fn condition_endpoint( + policy: &Policy, + policy_id: &str, +) -> Result, CedarEngineError> { + let Some(json) = policy.to_json().ok() else { + return Ok(None); + }; + let Some(operands) = when_conjuncts(&json) else { + return Ok(None); + }; + let mut hosts = operands.iter().filter_map(|expr| required_host(expr)); + let Some(host) = hosts.next() else { + return Ok(None); + }; + if hosts.next().is_some() { + return Ok(None); + } + let port = match &host { + // `host_port` names the port too; another port operand would make + // two, which the exactly-one rule rejects. + HostConstraint::Endpoint(_) + if operands.iter().any(|expr| required_port(expr).is_some()) => + { + return Ok(None); + } + HostConstraint::Endpoint(literal) => { + return parse_endpoint(policy_id, literal).map(Some); + } + HostConstraint::Eligible(_) | HostConstraint::Ineligible => single_port(&operands), + }; + Ok(match (host, port) { + (HostConstraint::Eligible(host), Some(port)) => Some((host, port)), + _ => None, + }) +} + +/// Returns the host glob and port an `HttpRequest` policy's `when` +/// conditions require, in the forms [`condition_endpoint`] accepts. +/// +/// Only a glob counts: an exact host belongs in the policy's scope, where +/// `parse_endpoint` validates it. A glob here inspects every host it +/// matches, since the conditions hold for no other host. +/// +/// # Errors +/// +/// Returns [`CedarEngineError::InvalidEndpoint`] as [`condition_endpoint`] +/// does. +fn condition_host_glob( + policy: &Policy, + policy_id: &str, +) -> Result, CedarEngineError> { + Ok(condition_endpoint(policy, policy_id)?.filter(|(host, _)| host.contains('*'))) +} + +/// Returns the port a `permit`'s `when` conditions require, if exactly one +/// top-level `&&` operand constrains `resource.port`, whatever they say of +/// the host. +fn condition_port(policy: &Policy) -> Option { + let json = policy.to_json().ok()?; + single_port(&when_conjuncts(&json)?) +} + +/// Returns the port named by exactly one `resource.port == ` operand. +fn single_port(operands: &[&Value]) -> Option { + let mut ports = operands.iter().filter_map(|expr| required_port(expr)); + let port = ports.next()?; + ports.next().is_none().then_some(port) +} + +/// Flattens the top-level `&&` operands of every `when` condition of a +/// policy's JSON form. +fn when_conjuncts(json: &Value) -> Option> { + let mut operands = Vec::new(); + for condition in json.get("conditions")?.as_array()? { + if condition.get("kind")?.as_str()? == "when" { + collect_conjuncts(condition.get("body")?, &mut operands); + } + } + Some(operands) +} + +/// Flattens an `&&` tree into its operands. +fn collect_conjuncts<'a>(expr: &'a Value, operands: &mut Vec<&'a Value>) { + if let Some(and) = expr.get("&&") + && let (Some(left), Some(right)) = (and.get("left"), and.get("right")) + { + collect_conjuncts(left, operands); + collect_conjuncts(right, operands); + } else { + operands.push(expr); + } +} + +/// True if `expr` is `resource.`. +fn is_resource_attr(expr: &Value, attr: &str) -> bool { + expr.get(".").is_some_and(|access| { + access + .get("left") + .and_then(|left| left.get("Var")) + .and_then(Value::as_str) + == Some("resource") + && access.get("attr").and_then(Value::as_str) == Some(attr) + }) +} + +/// Returns the operand of `resource. == `, in either order. +fn equality_with_resource_attr<'a>(expr: &'a Value, attr: &str) -> Option<&'a Value> { + let eq = expr.get("==")?; + let (left, right) = (eq.get("left")?, eq.get("right")?); + let literal = if is_resource_attr(left, attr) { + right + } else if is_resource_attr(right, attr) { + left + } else { + return None; + }; + literal.get("Value") +} + +/// A `when` operand that constrains `resource.host` or `resource.host_port`. +enum HostConstraint { + /// An exact host or host glob policy DNS can match exactly. + Eligible(String), + /// A `resource.host_port` literal, as written, naming the host and the + /// port together; `parse_endpoint` validates it. + Endpoint(String), + /// A host constraint in a form that grants no eligibility. + Ineligible, +} + +impl From> for HostConstraint { + fn from(host: Option) -> Self { + host.map_or(Self::Ineligible, Self::Eligible) + } +} + +/// Classifies an operand, returning `None` if it does not constrain the host. +/// +/// `resource.host_port == "h:p"` and `["h:p"].contains(resource.host_port)` +/// with exactly one element are [`HostConstraint::Endpoint`]; any other test +/// of `host_port`, such as a set with several elements or a `like`, is +/// [`HostConstraint::Ineligible`], since it does not name one endpoint. +fn required_host(expr: &Value) -> Option { + if let Some(value) = equality_with_resource_attr(expr, endpoint_fields::HOST) { + return Some(value.as_str().and_then(exact_host).into()); + } + if let Some(value) = equality_with_resource_attr(expr, endpoint_fields::HOST_PORT) { + return Some( + value + .as_str() + .map_or(HostConstraint::Ineligible, |literal| { + HostConstraint::Endpoint(literal.to_string()) + }), + ); + } + if let Some(contains) = expr.get("contains") + && contains + .get("right") + .is_some_and(|right| is_resource_attr(right, endpoint_fields::HOST_PORT)) + { + let elements = contains + .get("left") + .and_then(|left| left.get("Set")) + .and_then(Value::as_array); + return Some(match elements.map(Vec::as_slice) { + Some([element]) => string_literal(element) + .map_or(HostConstraint::Ineligible, |literal| { + HostConstraint::Endpoint(literal.to_string()) + }), + _ => HostConstraint::Ineligible, + }); + } + let like = expr.get("like")?; + let subject = like.get("left")?; + if is_resource_attr(subject, endpoint_fields::HOST_PORT) { + // A glob over "host:port" names no one endpoint. + return Some(HostConstraint::Ineligible); + } + if !is_resource_attr(subject, endpoint_fields::HOST) { + return None; + } + if like.get("delim").and_then(Value::as_str) != Some(HOST_LABEL_DELIMITER) { + return Some(HostConstraint::Ineligible); + } + Some( + like.get("pattern") + .and_then(Value::as_array) + .and_then(|elems| host_glob(elems)) + .into(), + ) +} + +/// Returns the port of a `resource.port == ` operand. +fn required_port(expr: &Value) -> Option { + equality_with_resource_attr(expr, "port")? + .as_u64() + .and_then(|port| u16::try_from(port).ok()) + .filter(|port| *port != 0) +} + +/// Accepts a lowercase host without wildcards or empty labels. +fn exact_host(host: &str) -> Option { + let valid = !host.is_empty() + && host == host.to_ascii_lowercase() + && !host.split('.').any(str::is_empty) + && host.chars().all(|c| c == '.' || is_host_char(c)); + valid.then(|| host.to_string()) +} + +/// Characters a host label may contain. Excludes glob metacharacters, so a +/// literal in a Cedar pattern can never be read as a wildcard by policy DNS. +fn is_host_char(c: char) -> bool { + c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_' +} + +/// One element of a delimited `like` pattern on `resource.host`. +#[derive(Clone, Copy)] +enum PatternChar { + Literal(char), + /// `*`: any run of characters within one label. + Wildcard, + /// `**`: any run of characters, across labels. + MultiWildcard, +} + +/// One `.`-separated label of a delimited host pattern. +enum HostLabel { + /// Literal characters, with `*` standing for a Cedar `Wildcard`. + Glob(String), + /// A whole-label Cedar `MultiWildcard`, written `**`. + Recursive, +} + +/// Converts a delimited `like` pattern on `resource.host` into the host glob +/// policy DNS matches, if both mean the same thing. +/// +/// Cedar's delimited `*` stays within one label and its `**` crosses labels, +/// as policy DNS does for YAML wildcard hosts. The two agree on every valid +/// host when the pattern also satisfies YAML's wildcard host rules: `**` only +/// as the whole first label, `*` within the first label or as a whole later +/// label, and no wildcard top-level domain such as `*.com`. +fn host_glob(elems: &[Value]) -> Option { + let mut labels = vec![Vec::new()]; + for elem in elems { + match elem { + Value::String(kind) if kind == "Wildcard" => { + labels.last_mut()?.push(PatternChar::Wildcard); + } + Value::String(kind) if kind == "MultiWildcard" => { + labels.last_mut()?.push(PatternChar::MultiWildcard); + } + Value::Object(literal) => { + for c in literal.get("Literal")?.as_str()?.chars() { + if c == '.' { + labels.push(Vec::new()); + } else if is_host_char(c) { + labels.last_mut()?.push(PatternChar::Literal(c)); + } else { + return None; + } + } + } + _ => return None, + } + } + let labels = labels + .into_iter() + .map(|label| match label.as_slice() { + [] => None, + [PatternChar::MultiWildcard] => Some(HostLabel::Recursive), + _ => { + let mut glob = String::new(); + for elem in &label { + match *elem { + // Adjacent `*`s within a label match the same text as one. + PatternChar::Wildcard if glob.ends_with('*') => {} + PatternChar::Wildcard => glob.push('*'), + PatternChar::Literal(c) => glob.push(c), + PatternChar::MultiWildcard => return None, + } + } + Some(HostLabel::Glob(glob)) + } + }) + .collect::>>()?; + + let (first, rest) = labels.split_first()?; + let later_labels_valid = rest.iter().all(|label| match label { + HostLabel::Recursive => false, + HostLabel::Glob(glob) => glob == "*" || !glob.contains('*'), + }); + let whole_first_wildcard = + matches!(first, HostLabel::Recursive) || matches!(first, HostLabel::Glob(g) if g == "*"); + let tld_wildcard = whole_first_wildcard && labels.len() <= 2; + if !later_labels_valid || tld_wildcard { + return None; + } + Some( + labels + .iter() + .map(|label| match label { + HostLabel::Recursive => "**", + HostLabel::Glob(glob) => glob.as_str(), + }) + .collect::>() + .join("."), + ) +} + +/// Paths and access level granted by one filesystem `permit`. +struct FilesystemGrant { + paths: Vec, + writes: bool, +} + +/// Converts one filesystem-touching policy into a Landlock grant. +/// +/// # Errors +/// +/// Returns [`CedarEngineError`] if the policy's meaning cannot be expressed +/// exactly as a Landlock allow-list entry. +fn filesystem_grant( + policy: &Policy, + policy_id: &str, + scope: &ActionScope, +) -> Result { + let unsupported = |reason: &str| CedarEngineError::UnsupportedPolicy { + policy_id: policy_id.to_string(), + reason: reason.to_string(), + }; + if policy.effect() == Effect::Forbid { + return Err(CedarEngineError::FilesystemForbidUnsupported { + policy_id: policy_id.to_string(), + }); + } + if !scope.is_filesystem_only() { + return Err(unsupported( + "a policy that can apply to files must list only ReadFile and/or WriteFile \ + in its action scope", + )); + } + let principal_unconstrained = match policy.principal_constraint() { + PrincipalConstraint::Any => true, + PrincipalConstraint::Is(entity_type) => entity_type.to_string() == entity_types::PROCESS, + _ => false, + }; + if !principal_unconstrained { + return Err(unsupported( + "filesystem grants apply to every sandbox process; use `principal` or \ + `principal is Sandbox::Process`", + )); + } + + let paths = match policy.resource_constraint() { + ResourceConstraint::In(uid) | ResourceConstraint::IsIn(_, uid) + if !policy.has_non_scope_constraint() => + { + vec![uid.id().unescaped().to_string()] + } + ResourceConstraint::Eq(_) => { + return Err(unsupported( + "`resource ==` names one exact path, but Landlock grants the whole \ + subtree; use `resource in Sandbox::FilesystemPath::\"/path\"`", + )); + } + ResourceConstraint::Any | ResourceConstraint::Is(_) => when_clause_paths(policy) + .ok_or_else(|| { + unsupported( + "name paths in the scope (`resource in Sandbox::FilesystemPath::\"/path\"`) \ + or in a single `when` clause that only ORs `resource in` tests", + ) + })?, + ResourceConstraint::In(_) | ResourceConstraint::IsIn(..) => { + return Err(unsupported( + "`when`/`unless` conditions on a filesystem policy cannot be enforced by \ + Landlock", + )); + } + }; + + let writes = scope.includes(actions::WRITE_FILE); + if writes && !scope.includes(actions::READ_FILE) { + return Err(unsupported( + "Landlock read-write grants include read; list both ReadFile and WriteFile", + )); + } + Ok(FilesystemGrant { paths, writes }) +} + +/// Returns the paths of a `when { resource in P1 || resource in P2 ... }` body. +/// +/// Returns `None` unless the policy has exactly one condition, it is `when`, +/// and its body consists only of `||` over `resource in` tests against +/// `FilesystemPath` literals. +fn when_clause_paths(policy: &Policy) -> Option> { + let json = policy.to_json().ok()?; + let conditions = json.get("conditions")?.as_array()?; + let [condition] = conditions.as_slice() else { + return None; + }; + if condition.get("kind")?.as_str()? != "when" { + return None; + } + let mut paths = Vec::new(); + collect_resource_in_paths(condition.get("body")?, &mut paths).then_some(paths) +} + +/// Collects `resource in FilesystemPath::"..."` paths from an `||` tree. +/// +/// Returns `false` if any node is not `||` or such a `resource in` test. +fn collect_resource_in_paths(expr: &Value, paths: &mut Vec) -> bool { + if let Some(or) = expr.get("||") { + return match (or.get("left"), or.get("right")) { + (Some(left), Some(right)) => { + collect_resource_in_paths(left, paths) && collect_resource_in_paths(right, paths) + } + _ => false, + }; + } + let Some(test) = expr.get("in") else { + return false; + }; + let is_resource = test + .get("left") + .and_then(|left| left.get("Var")) + .and_then(Value::as_str) + == Some("resource"); + let entity = test + .get("right") + .and_then(|right| right.get("Value")) + .and_then(|value| value.get("__entity")); + let Some(entity) = entity else { + return false; + }; + let is_path = entity.get("type").and_then(Value::as_str) == Some(entity_types::FILESYSTEM_PATH); + match entity.get("id").and_then(Value::as_str) { + Some(id) if is_resource && is_path => { + paths.push(id.to_string()); + true + } + _ => false, + } +} + +/// Returns the policy's `@id` annotation, or its Cedar-assigned id. +pub fn display_id(policy: &Policy) -> String { + policy + .annotation(ID_ANNOTATION) + .map_or_else(|| policy.id().to_string(), ToString::to_string) +} diff --git a/crates/openshell-policy-cedar/src/error.rs b/crates/openshell-policy-cedar/src/error.rs new file mode 100644 index 0000000000..0dfc570ff0 --- /dev/null +++ b/crates/openshell-policy-cedar/src/error.rs @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Typed errors for [`crate::CedarEngine`]. + +use miette::Diagnostic; +use thiserror::Error; + +/// Errors produced while loading or evaluating a sandbox Cedar policy. +#[derive(Debug, Error, Diagnostic)] +pub enum CedarEngineError { + /// The canonical schema failed to load. Indicates a broken build, not a + /// caller-input error. + #[error(transparent)] + #[diagnostic(transparent)] + SchemaLoad(#[from] openshell_policy_cedar_schema::CedarSchemaLoadError), + + /// The Cedar policy set source failed to parse. + #[error("Cedar policy parse error: {0}")] + #[diagnostic(code(openshell::policy_cedar::policy_parse))] + PolicyParse(#[source] Box), + + /// The enforced subset of a policy set could not be assembled. + #[error("Cedar policy set error: {0}")] + #[diagnostic(code(openshell::policy_cedar::policy_set))] + PolicySet(#[source] Box), + + /// An entity type name used to build a request did not parse (e.g. + /// `Sandbox::Process`). + #[error("invalid Cedar entity type name: {0}")] + #[diagnostic(code(openshell::policy_cedar::entity_type))] + EntityTypeParse(#[source] Box), + + /// Building one of the request's Cedar entities failed attribute + /// evaluation. + #[error("failed to build Cedar entity: {0}")] + #[diagnostic(code(openshell::policy_cedar::entity_build))] + EntityBuild(#[source] Box), + + /// Assembling the request's `Entities` store failed (duplicate ids or a + /// schema-conformance failure). + #[error("failed to build Cedar entities store: {0}")] + #[diagnostic(code(openshell::policy_cedar::entities_build))] + EntitiesBuild(#[source] Box), + + /// Building the request `Context` failed. + #[error("failed to build Cedar context: {0}")] + #[diagnostic(code(openshell::policy_cedar::context_build))] + ContextBuild(#[source] Box), + + /// The assembled `Request` was rejected by schema validation. + #[error("Cedar request failed schema validation: {0}")] + #[diagnostic(code(openshell::policy_cedar::request_build))] + RequestBuild(#[source] Box), + + /// A `forbid` policy can apply to `ReadFile` or `WriteFile`. + /// + /// Landlock's flat allow-list cannot express forbid-over-permit + /// carve-outs (e.g. "allow /usr except /usr/secret"), so this is rejected + /// rather than silently dropped or guessed. + #[error( + "policy {policy_id:?} is a forbid that can apply to files, which a Landlock \ + allow-list cannot represent; narrow the permit instead, or limit the forbid's \ + action scope to network actions" + )] + #[diagnostic(code(openshell::policy_cedar::filesystem_forbid_unsupported))] + FilesystemForbidUnsupported { + /// The id of the offending `forbid` policy. + policy_id: String, + }, + + /// The policy set failed strict validation against the canonical schema. + #[error("Cedar policy failed schema validation: {reason}")] + #[diagnostic(code(openshell::policy_cedar::policy_validation))] + PolicyValidation { + /// Every validation error, joined with `"; "`. + reason: String, + }, + + /// A policy's meaning cannot be enforced exactly as written. + #[error("policy {policy_id:?} is not supported: {reason}")] + #[diagnostic(code(openshell::policy_cedar::unsupported_policy))] + UnsupportedPolicy { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// What about the policy cannot be enforced, and how to fix it. + reason: String, + }, + + /// A `NetworkEndpoint` literal in a policy scope is not `"host:port"`. + #[error("policy {policy_id:?} names invalid endpoint {endpoint:?}: {reason}")] + #[diagnostic(code(openshell::policy_cedar::invalid_endpoint))] + InvalidEndpoint { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// The endpoint literal as written. + endpoint: String, + /// Why the literal is invalid. + reason: String, + }, + + /// An `@protocol` annotation names a protocol Cedar cannot enforce. + #[error( + "policy {policy_id:?} declares @protocol({protocol:?}); supported values are \ + \"rest\", \"json-rpc\", \"mcp\", \"graphql\", \"websocket\", and \ + \"websocket-graphql\"" + )] + #[diagnostic(code(openshell::policy_cedar::unsupported_l7_protocol))] + UnsupportedL7Protocol { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// The annotation value as written. + protocol: String, + }, + + /// An `@enforcement` annotation has a value other than `enforce`, + /// `audit`, or `staged`. + #[error( + "policy {policy_id:?} declares @enforcement({enforcement:?}); supported values are \ + \"enforce\", \"audit\", and \"staged\"" + )] + #[diagnostic(code(openshell::policy_cedar::unsupported_enforcement))] + UnsupportedEnforcement { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// The annotation value as written. + enforcement: String, + }, + + /// An `@path` annotation is not a valid endpoint path pattern. + #[error("policy {policy_id:?} declares invalid @path({path:?}): {reason}")] + #[diagnostic(code(openshell::policy_cedar::invalid_l7_path))] + InvalidL7Path { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// The annotation value as written. + path: String, + /// Why the value is invalid. + reason: String, + }, + + /// Two policies declare different `@protocol` values for one endpoint path. + #[error( + "endpoint {endpoint:?} is declared as both @protocol({first:?}) and @protocol({second:?})" + )] + #[diagnostic(code(openshell::policy_cedar::conflicting_l7_protocol))] + ConflictingL7Protocol { + /// The endpoint literal as written, followed by its `@path`, if any. + endpoint: String, + /// The protocol declared first. + first: String, + /// The conflicting protocol declared later. + second: String, + }, + + /// Two equally specific `@path` patterns of one endpoint can match the + /// same request but inspect it differently. + /// + /// The relay picks the most specific matching path, so between these two + /// the choice would depend on their order. + #[error( + "endpoint {endpoint:?} declares @path({first:?}) and @path({second:?}), which are \ + equally specific and can match the same request, with a different protocol or \ + enforcement; make one path more specific or give both the same inspection" + )] + #[diagnostic(code(openshell::policy_cedar::ambiguous_l7_paths))] + AmbiguousL7Paths { + /// The endpoint, as `"host:port"`. + endpoint: String, + /// One of the two paths; empty for the path-less endpoint. + first: String, + /// The other path. + second: String, + }, + + /// Two `HttpRequest` endpoints that can select one host inspect equally + /// specific paths that can match the same request differently. + /// + /// A host matched by both is routed to the most specific matching path + /// of either, so between these two the choice would depend on their + /// order. YAML rejects endpoints whose hosts overlap with the same + /// conflict. + #[error( + "endpoints {first:?} and {second:?} can select the same host, and their equally \ + specific paths @path({first_path:?}) and @path({second_path:?}) can match the same \ + request with a different protocol or enforcement; make one host or path more \ + specific or give both the same inspection" + )] + #[diagnostic(code(openshell::policy_cedar::ambiguous_l7_hosts))] + AmbiguousL7Hosts { + /// One endpoint, as `"host:port"`; the host may be a glob. + first: String, + /// The other endpoint, as `"host:port"`; the host may be a glob. + second: String, + /// The path of `first`; empty for its path-less inspection. + first_path: String, + /// The path of `second`; empty for its path-less inspection. + second_path: String, + }, + + /// An `@transport` annotation names a transport other than `tcp`. + #[error( + "policy {policy_id:?} declares @transport({transport:?}); the supported value is \"tcp\"" + )] + #[diagnostic(code(openshell::policy_cedar::unsupported_transport))] + UnsupportedTransport { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// The annotation value as written. + transport: String, + }, + + /// A `@transport("tcp")` permit names a host native TCP cannot reach. + #[error("policy {policy_id:?} declares @transport(\"tcp\") for host {host:?}: {reason}")] + #[diagnostic(code(openshell::policy_cedar::invalid_tcp_host))] + InvalidTcpHost { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// The host as written. + host: String, + /// Why the host is invalid. + reason: String, + }, + + /// A native TCP endpoint overlaps an endpoint that is not native TCP. + #[error( + "@transport(\"tcp\") endpoint {tcp_endpoint:?} overlaps {other_endpoint:?}, {reason}; \ + a host and port must be native TCP for every policy or for none" + )] + #[diagnostic(code(openshell::policy_cedar::conflicting_transport))] + ConflictingTransport { + /// The native TCP endpoint, as `"host:port"`; the host may be a glob. + tcp_endpoint: String, + /// The overlapping endpoint, as `"host:port"`; the host may be a + /// glob, or `*` for a permit whose host cannot be derived (`"*:*"` + /// when its port cannot be either). + other_endpoint: String, + /// How the overlapping endpoint is declared. + reason: String, + }, + + /// A policy reads `context.destination_ip` in a form whose address + /// ranges cannot be derived exactly. + #[error("policy {policy_id:?} has an unsupported destination_ip condition: {reason}")] + #[diagnostic(code(openshell::policy_cedar::invalid_destination_ip))] + InvalidDestinationIp { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// Why the condition is rejected. + reason: String, + }, + + /// A policy reads `context.binary_aliases` in a form whose binary paths + /// cannot be collected exactly. + #[error("policy {policy_id:?} has an unsupported binary_aliases condition: {reason}")] + #[diagnostic(code(openshell::policy_cedar::invalid_binary_alias))] + InvalidBinaryAlias { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// Why the condition is rejected. + reason: String, + }, + + /// A policy reads `context.binary_globs` in a form whose glob patterns + /// cannot be collected exactly. + #[error("policy {policy_id:?} has an unsupported binary_globs condition: {reason}")] + #[diagnostic(code(openshell::policy_cedar::invalid_binary_glob))] + InvalidBinaryGlob { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// Why the condition is rejected. + reason: String, + }, + + /// A `permit` attached to a `websocket-graphql` path could allow a + /// client message without a GraphQL condition. + /// + /// YAML rejects a generic `WEBSOCKET_TEXT` rule on a WebSocket endpoint + /// with GraphQL operation policy; such a permit would allow every + /// operation. + #[error( + "policy {policy_id:?} on the websocket-graphql path {path:?} could permit a client \ + message without a GraphQL condition; GraphQL-over-WebSocket message permits must \ + condition on graphql_operation_type, graphql_operation_name, or graphql_fields, as \ + YAML rejects a generic WEBSOCKET_TEXT rule there; to permit the upgrade alone, pin \ + the method with a top-level `when` conjunct such as `context.method == \"GET\"`" + )] + #[diagnostic(code(openshell::policy_cedar::generic_websocket_graphql_permit))] + GenericWebsocketGraphqlPermit { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// The path the permit is attached to; empty for the path-less + /// endpoint. + path: String, + }, + + /// A delimited `like` pattern has `**` next to a character other than + /// the delimiter, which YAML and Cedar read differently. + #[error( + "policy {policy_id:?} has the delimited pattern {pattern:?} with `**` next to a \ + character other than the delimiter {delimiter:?}; YAML reads such a `**` as `*`, \ + within one segment, while Cedar's `**` crosses segments; write `*`, or `**` as a \ + whole segment such as `/**`" + )] + #[diagnostic(code(openshell::policy_cedar::ambiguous_recursive_wildcard))] + AmbiguousRecursiveWildcard { + /// The `@id` annotation of the policy, or its Cedar-assigned id. + policy_id: String, + /// The pattern as written, with `*` and `**` for its wildcards. + pattern: String, + /// The pattern's delimiter. + delimiter: String, + }, + + /// A request's repeated query keys have more value combinations than + /// Cedar evaluates for one request. + /// + /// Each combination is a separate Cedar evaluation, so the count is + /// capped and a request over it is denied. + #[error( + "request query parameters have {combinations} value combinations, more than the \ + {limit} Cedar evaluates for one request" + )] + #[diagnostic(code(openshell::policy_cedar::too_many_query_combinations))] + TooManyQueryCombinations { + /// The number of combinations, saturating at `usize::MAX`. + combinations: usize, + /// The cap, [`crate::MAX_QUERY_COMBINATIONS`]. + limit: usize, + }, + + /// Cedar reported errors while evaluating a request. + /// + /// Cedar skips a policy that errors during evaluation, which could turn + /// an intended `forbid` into an allow, so the request is failed instead. + #[error("Cedar policy evaluation failed: {reason}")] + #[diagnostic(code(openshell::policy_cedar::evaluation))] + Evaluation { + /// Every evaluation error, joined with `"; "`. + reason: String, + }, +} diff --git a/crates/openshell-policy-cedar/src/lib.rs b/crates/openshell-policy-cedar/src/lib.rs new file mode 100644 index 0000000000..55f1189e9b --- /dev/null +++ b/crates/openshell-policy-cedar/src/lib.rs @@ -0,0 +1,1071 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Cedar policy evaluation for `OpenShell` sandboxes. +//! +//! [`CedarEngine`] is the authoritative policy engine for a sandbox +//! whose policy is authored in Cedar (`SandboxPolicy.cedar_policy_source`). +//! It evaluates `NetworkConnect` and per-request `HttpRequest` decisions at +//! request time, and derives Landlock grants, L7 inspection routing, and DNS +//! eligibility from the policy text once, at construction. Construction +//! rejects any policy whose meaning those derived artifacts could not +//! enforce exactly; see [`analysis`](crate::analysis) for the accepted +//! shapes. +//! +//! The schema itself lives in `openshell-policy-cedar-schema`, the single +//! source of truth for Cedar entity/action names across every Cedar-aware +//! consumer. + +mod analysis; +mod error; +mod token_grants; + +pub use analysis::{AnalysisWarning, L7Endpoint, L7Enforcement, L7Protocol, NetworkTransport}; +pub use error::CedarEngineError; +pub use token_grants::{ + Handling, ProviderEndpoint, ProviderPolicy, RuleLocation, RuleMatcher, + TOKEN_GRANT_OWNER_ANNOTATION, TokenGrantRules, UntranslatedRule, ValueMatcher, +}; + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::net::IpAddr; +use std::str::FromStr; + +use cedar_policy::{ + Authorizer, Context, Entities, Entity, EntityId, EntityTypeName, EntityUid, PolicyId, + PolicySet, Request, RestrictedExpression, Schema, +}; +use openshell_policy_cedar_schema::{actions, context_fields, endpoint_fields, entity_types}; + +use analysis::{PolicyAnalysis, QueryKeys}; +pub use ipnet::IpNet; + +/// Most evaluations one `HttpRequest` gets for the combinations of its +/// repeated query values. +/// +/// A request over the cap is not evaluated; see +/// [`CedarEngineError::TooManyQueryCombinations`]. +pub const MAX_QUERY_COMBINATIONS: usize = 4096; + +/// Synthetic id for the single `Process` entity built per request. +/// +/// Each request is evaluated on its own and never cross-references other +/// processes, so a fixed id is sufficient. +const CURRENT_PROCESS: &str = "current"; + +/// Synthetic id for the single `HttpQuery` entity built per evaluation. +const CURRENT_QUERY: &str = "current"; + +/// `Process` attribute holding the process's `User` entity. +const PROCESS_USER_ATTR: &str = "user"; + +/// `Process` attribute holding the process's `Group` entity. +const PROCESS_GROUP_ATTR: &str = "group"; + +/// The input to one `Sandbox::Action::"NetworkConnect"` authorization. +/// +/// Mirrors the fields `openshell_supervisor_network::policy_types::NetworkInput` +/// supplies to a policy engine. No request has been read at CONNECT time, so +/// the `NetworkConnect` context has no `method`, `path`, or `command`; a +/// policy that reads them on this action fails validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkConnectRequest { + /// Sandbox process user identity (`Sandbox::User` entity id). + pub user: String, + /// Sandbox process group identity (`Sandbox::Group` entity id). + pub group: String, + /// Destination host. + pub host: String, + /// Destination port. + pub port: u16, + /// Absolute path of the binary making the connection. + pub binary_path: String, + /// Absolute paths of the calling process's ancestors (parent, + /// grandparent, ...). Excludes cmdline/argv0, which is spoofable. + pub ancestors: Vec, + /// The paths of [`CedarEngine::binary_alias_paths`] that resolve, as + /// symlinks in the sandbox, to `binary_path` or one of `ancestors`. + pub binary_aliases: Vec, + /// The patterns of [`CedarEngine::binary_glob_patterns`] that match + /// `binary_path` or one of `ancestors` with the YAML binary glob matcher. + pub binary_globs: Vec, + /// One resolved address of the destination, or `None` before the host + /// is resolved; see [`CedarEngine::authorize_network`]. + pub destination_ip: Option, +} + +/// Outcome of [`CedarEngine::authorize_network`], with what the allowing +/// permits require of the destination address. +/// +/// The address fields describe the determining permits of an allow and are +/// empty or `false` for a deny. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkEvaluation { + /// The decision. + pub decision: Decision, + /// The `destination_ip` ranges of each determining permit that has a + /// `destination_ip` condition, one list per permit. + pub address_conditions: Vec>, + /// A determining permit has no `destination_ip` condition. + pub unconstrained_permit: bool, + /// A determining permit names the request's host and port exactly: in + /// its scope, or as `resource.host == "..."` and `resource.port == ...` + /// conditions. A host glob never counts. + pub names_endpoint: bool, +} + +/// Outcome of evaluating a request against a Cedar policy set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Decision { + /// A `permit` policy matched and no `forbid` overrode it. + Allow { + /// Policies that contributed to the decision, by `@id` annotation or + /// Cedar-assigned id. + matched_policies: Vec, + }, + /// No `permit` matched, or a `forbid` matched. + Deny { + /// Policies that contributed to the decision, by `@id` annotation or + /// Cedar-assigned id. + matched_policies: Vec, + }, +} + +impl Decision { + /// Returns `true` for [`Decision::Allow`]. + #[must_use] + pub fn is_allow(&self) -> bool { + matches!(self, Self::Allow { .. }) + } +} + +/// Outcome of evaluating one `HttpRequest`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct L7Evaluation { + /// The decision of the endpoint's deciding policies: every policy but + /// the staged ones. + /// + /// On an [`L7Enforcement::Audit`] path the proxy logs a deny instead of + /// blocking it. + pub decision: Decision, + /// The decision with the endpoint's staged policies included, present + /// only when it differs from [`Self::decision`]. + pub staged: Option, +} + +impl L7Evaluation { + /// Returns `true` if [`Self::decision`] allows the request. + #[must_use] + pub fn is_allow(&self) -> bool { + self.decision.is_allow() + } +} + +/// The input to one `Sandbox::Action::"HttpRequest"` authorization. +/// +/// Evaluated per request within a connection that `NetworkConnect` already +/// permitted. +/// Mirrors the fields `openshell_supervisor_network::l7::relay::L7EvalContext` +/// / `L7RequestInfo` supply, narrowed to what the `Sandbox::HttpRequest` +/// Cedar action declares in its schema. Fields that do not apply to a +/// request's protocol keep their default (`""`, `false`, or empty). +#[derive(Debug, Clone, Default)] +pub struct HttpRequestInput { + /// Sandbox process user identity (`Sandbox::User` entity id), same as + /// the connection's `NetworkConnect` request. Identity doesn't change + /// within a connection, but policies may still guard every action + /// (including `HttpRequest`) on it, e.g. a top-level identity `forbid`. + pub user: String, + /// Sandbox process group identity (`Sandbox::Group` entity id). + pub group: String, + /// Absolute path of the binary that owns this connection. + pub binary_path: String, + /// Absolute paths of the connection-owning process's ancestors. + pub ancestors: Vec, + /// The paths of [`CedarEngine::binary_alias_paths`] that resolve, as + /// symlinks in the sandbox, to `binary_path` or one of `ancestors`. + pub binary_aliases: Vec, + /// The patterns of [`CedarEngine::binary_glob_patterns`] that match + /// `binary_path` or one of `ancestors` with the YAML binary glob matcher. + pub binary_globs: Vec, + /// Destination host (same endpoint the `NetworkConnect` matched). + pub host: String, + /// Destination port. + pub port: u16, + /// HTTP method, when known; empty string when not applicable. + pub method: String, + /// REST request path, when known; empty string when not applicable. + pub path: String, + /// SQL command verb, when known; empty string when not applicable. + pub command: String, + /// JSON-RPC method name, when known; empty string when not applicable. + pub jsonrpc_method: String, + /// An MCP GET that opens the server-to-client stream. + pub jsonrpc_receive_stream: bool, + /// The body carries client-to-server JSON-RPC response frames. + pub jsonrpc_response: bool, + /// MCP `tools/call` tool name; empty string when not applicable. + pub mcp_tool: String, + /// MCP method classification (`"available"` or `"extension"`); empty + /// string for non-MCP requests. + pub mcp_method_class: String, + /// GraphQL operation type; empty string for a hash-only persisted query + /// or a non-GraphQL request. + pub graphql_operation_type: String, + /// GraphQL operation name; empty string when anonymous. + pub graphql_operation_name: String, + /// The GraphQL operation's top-level fields. + pub graphql_fields: Vec, + /// The proxy parsed this request as GraphQL: an operation of a `graphql` + /// path, or a GraphQL-over-WebSocket client message. On such a request + /// the routed path is authoritative; see [`CedarEngine::evaluate_l7`]. + pub graphql_request: bool, + /// The `@path` of the inspected endpoint path the proxy routed this + /// request to, which selects its protocol and enforcement; empty for the + /// endpoint declared by policies without `@path`. + pub endpoint_path: String, + /// The decoded query parameters: every value of each key, in request + /// order. Policies read one value per key as a tag of + /// `context.query`; see [`CedarEngine::evaluate_l7`] for repeated keys. + pub query: BTreeMap>, +} + +/// Landlock path grants derived from a Cedar policy set. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FilesystemGrants { + /// Path subtrees granted read-only access. + pub read_only: Vec, + /// Path subtrees granted read-write access. + pub read_write: Vec, +} + +/// One host a Cedar policy set permits `NetworkConnect` to, with its ports +/// and transport. +/// +/// Used for DNS eligibility, not CONNECT-time matching. Includes endpoints +/// named in a `permit` scope, and hosts and host globs a `permit`'s `when` +/// conditions require together with a port (see the `analysis` module). A +/// host matched any other way, such as `resource.host like "*.example.com"` +/// without a delimiter, is not included, so DNS resolution for it fails +/// closed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizedNetworkEndpoint { + /// Destination host, lowercased, or a host glob in policy DNS syntax: `*` + /// within one DNS label, and `**` as a whole first label spanning one or + /// more labels. + pub host: String, + /// Ports this host is permitted on. + pub ports: Vec, + /// How connections to these ports are carried. A host with native TCP + /// ports and other ports has one entry for each. + pub transport: NetworkTransport, + /// The `destination_ip` ranges of the permits behind this entry, like a + /// YAML endpoint's `allowed_ips`; empty when they have no such + /// condition. Permits with different ranges get separate entries. + pub allowed_ips: Vec, +} + +/// Entity type names and ids every request needs, built once per engine. +#[derive(Debug, Clone)] +pub(crate) struct RequestUids { + user_type: EntityTypeName, + group_type: EntityTypeName, + endpoint_type: EntityTypeName, + process: EntityUid, + query: EntityUid, + network_connect: EntityUid, + pub(crate) http_request: EntityUid, +} + +impl RequestUids { + fn new() -> Result { + Ok(Self { + user_type: entity_type(entity_types::USER)?, + group_type: entity_type(entity_types::GROUP)?, + endpoint_type: entity_type(entity_types::NETWORK_ENDPOINT)?, + process: entity_uid(entity_types::PROCESS, CURRENT_PROCESS)?, + query: entity_uid(entity_types::HTTP_QUERY, CURRENT_QUERY)?, + network_connect: entity_uid(actions::ACTION_TYPE, actions::NETWORK_CONNECT)?, + http_request: entity_uid(actions::ACTION_TYPE, actions::HTTP_REQUEST)?, + }) + } +} + +/// Cedar-backed sandbox policy evaluator. +/// +/// Loads a Cedar schema and policy set once, validates and analyzes them, +/// then evaluates [`NetworkConnectRequest`]s and [`HttpRequestInput`]s against them. +#[derive(Debug)] +pub struct CedarEngine { + evaluator: Evaluator, + /// Every authored policy, including staged ones. + policies: PolicySet, + /// The policies that decide requests: every policy except staged ones. + deciding_policies: PolicySet, + /// The policies that decide a connection before its host is resolved: + /// the deciding policies, with permits stripped of their + /// `destination_ip` conditions. Forbids are kept as written; with the + /// address absent, their `context has destination_ip` guards are false. + unresolved_policies: PolicySet, + /// For each `graphql` or `websocket-graphql` path with permits attached + /// to other paths of its endpoint that are not GraphQL-aware, the policy + /// sets without those permits, keyed by `(host, port, path)`. + graphql_policies: HashMap<(String, u16, String), GraphqlPolicies>, + analysis: PolicyAnalysis, +} + +/// The policy sets that decide a request parsed as GraphQL on one path. +#[derive(Debug)] +struct GraphqlPolicies { + /// [`CedarEngine::deciding_policies`] without the excluded permits. + deciding: PolicySet, + /// [`CedarEngine::policies`] without the excluded permits. + all: PolicySet, +} + +impl CedarEngine { + /// Parses and validates a policy set against the canonical sandbox schema. + /// + /// The schema is [`openshell_policy_cedar_schema::SANDBOX_SCHEMA_SRC`]. + /// + /// # Errors + /// + /// Returns [`CedarEngineError`] if the policy set fails to parse, fails + /// strict schema validation, or uses a shape this crate cannot enforce + /// exactly. + pub fn from_policy_str(policy_src: &str) -> Result { + let evaluator = Evaluator::new()?; + let policies = PolicySet::from_str(policy_src) + .map_err(|e| CedarEngineError::PolicyParse(Box::new(e)))?; + let analysis = analysis::analyze(&evaluator.schema, &policies)?; + let policy_set = |keep: &dyn Fn(&cedar_policy::Policy) -> bool| { + PolicySet::from_policies(policies.policies().filter(|policy| keep(policy)).cloned()) + .map_err(|e| CedarEngineError::PolicySet(Box::new(e))) + }; + let deciding_policies = policy_set(&|policy| !analysis::is_staged(policy))?; + let unresolved_policies = + PolicySet::from_policies(deciding_policies.policies().map(|policy| { + analysis + .unresolved + .get(policy.id()) + .map_or_else(|| policy.clone(), Clone::clone) + })) + .map_err(|e| CedarEngineError::PolicySet(Box::new(e)))?; + let mut graphql_policies = HashMap::new(); + for ((host, port), paths) in &analysis.l7_endpoints { + for (path, endpoint) in paths { + if !matches!( + endpoint.protocol, + L7Protocol::Graphql | L7Protocol::WebsocketGraphql + ) { + continue; + } + // Permits of other paths of this endpoint, or of another + // entry that can select a host with it, that do not mean to + // apply to GraphQL requests. A permit of an entry that does + // not select the request's host cannot fire for it, so + // leaving it out over-approximates safely. + let excluded = |policy: &cedar_policy::Policy| { + analysis + .http_permits + .get(policy.id()) + .is_some_and(|permit| { + permit.endpoint.1 == *port + && analysis::host_patterns_overlap(&permit.endpoint.0, host) + && permit.path != *path + && !permit.graphql_aware + }) + }; + if !policies.policies().any(excluded) { + continue; + } + graphql_policies.insert( + (host.clone(), *port, path.clone()), + GraphqlPolicies { + deciding: policy_set(&|policy| { + !analysis::is_staged(policy) && !excluded(policy) + })?, + all: policy_set(&|policy| !excluded(policy))?, + }, + ); + } + } + Ok(Self { + evaluator, + policies, + deciding_policies, + unresolved_policies, + graphql_policies, + analysis, + }) + } + + /// Returns the Landlock path grants this policy set authorizes. + #[must_use] + pub fn filesystem_grants(&self) -> &FilesystemGrants { + &self.analysis.filesystem + } + + /// Returns the shapes in this policy set that load but are likely not + /// what the author meant; see [`AnalysisWarning`]. + /// + /// Warnings never change enforcement. Callers that validate or load a + /// policy should show them to its author. + #[must_use] + pub fn warnings(&self) -> &[AnalysisWarning] { + &self.analysis.warnings + } + + /// Whether any policy can permit `NetworkConnect`, the Cedar counterpart + /// of a YAML policy with network rules. + #[must_use] + pub fn has_network_permits(&self) -> bool { + !self.analysis.network_permits.is_empty() + } + + /// Returns the `NetworkConnect` hosts and host globs eligible for policy DNS. + #[must_use] + pub fn dns_endpoints(&self) -> &[AuthorizedNetworkEndpoint] { + &self.analysis.dns_endpoints + } + + /// Returns the exact binary paths policies test with + /// `context.binary_aliases`, sorted. + /// + /// The caller resolves each one inside the sandbox, as YAML resolves a + /// symlinked binary path, and lists in a request's `binary_aliases` the + /// paths that resolve to its binary or one of its ancestors. + pub fn binary_alias_paths(&self) -> impl Iterator { + self.analysis.binary_alias_paths.iter().map(String::as_str) + } + + /// Returns the binary glob patterns policies test with + /// `context.binary_globs`, sorted. + /// + /// The caller matches each one against a request's binary and ancestors + /// with the YAML binary glob matcher (`*` within one path segment, `**` + /// as a whole segment across segments), and lists the matching patterns + /// in the request's `binary_globs`. + pub fn binary_glob_patterns(&self) -> impl Iterator { + self.analysis + .binary_glob_patterns + .iter() + .map(String::as_str) + } + + /// Returns the L7 protocol policies without `@path` declare for + /// `host:port`, if any. + /// + /// `None` means no `HttpRequest` policy without `@path` names this + /// endpoint. See [`Self::l7_endpoints`] for every inspected path. + #[must_use] + pub fn l7_protocol(&self, host: &str, port: u16) -> Option { + self.l7_endpoint(host, port) + .map(|endpoint| endpoint.protocol) + } + + /// Returns how the proxy inspects `host:port` for policies without + /// `@path`, if any names it. + #[must_use] + pub fn l7_endpoint(&self, host: &str, port: u16) -> Option { + self.l7_endpoint_at(host, port, "") + } + + /// Returns how the proxy inspects requests to `host:port` routed to + /// `path`, an `@path` pattern, or `""` for policies without `@path`. + /// + /// The host is selected by the endpoint named exactly, or else by the + /// host globs it matches; see [`Self::l7_endpoints`]. + #[must_use] + pub fn l7_endpoint_at(&self, host: &str, port: u16, path: &str) -> Option { + self.analysis + .l7_selecting(&normalize_host(host), port) + .find_map(|(_, paths)| paths.get(path)) + .copied() + } + + /// Returns every inspected path of `host:port` with its inspection, + /// ordered by path. + /// + /// Empty means no `HttpRequest` policy names this endpoint, so an + /// allowed connection to it is relayed without per-request checks. The + /// empty path is the endpoint declared by policies without `@path`. + /// + /// A host is inspected by the endpoint that names it exactly and by + /// every endpoint whose host glob matches it, with the matcher YAML + /// endpoints and policy DNS use; their paths are inspected together, + /// and the exact endpoint's declaration of a path takes precedence. + /// Load-time analysis rejects entries that would inspect a request + /// differently, so the merge is well defined. + pub fn l7_endpoints( + &self, + host: &str, + port: u16, + ) -> impl Iterator { + let mut merged: BTreeMap = BTreeMap::new(); + // `l7_selecting` yields the exact entry first; inserting it last + // lets it win where another entry declares the same path. + let selecting: Vec<_> = self + .analysis + .l7_selecting(&normalize_host(host), port) + .collect(); + for (_, paths) in selecting.into_iter().rev() { + merged.extend( + paths + .iter() + .map(|(path, endpoint)| (path.clone(), *endpoint)), + ); + } + merged.into_iter() + } + + /// Evaluates one network-connect request against the loaded policy set. + /// + /// # Errors + /// + /// Returns [`CedarEngineError`] if the request cannot be represented in + /// the loaded schema, or if Cedar reports an error while evaluating any + /// policy. + pub fn evaluate_network( + &self, + request: &NetworkConnectRequest, + ) -> Result { + Ok(self.authorize_network(request)?.decision) + } + + /// Evaluates one network-connect request, reporting what the allowing + /// permits require of the destination address. + /// + /// Without `destination_ip` the request is decided as before its host is + /// resolved: permits are evaluated without their `destination_ip` + /// conditions, so an allow means some resolved address may be allowed, + /// and forbids are evaluated as written with the address absent, so a + /// forbid's other conditions still deny and its guarded address + /// conditions are false. With `destination_ip` every enforced policy + /// decides the request for that address. + /// + /// # Errors + /// + /// Returns [`CedarEngineError`] if the request cannot be represented in + /// the loaded schema, or if Cedar reports an error while evaluating any + /// policy. + pub fn authorize_network( + &self, + request: &NetworkConnectRequest, + ) -> Result { + let protocol = self + .l7_endpoint(&request.host, request.port) + .map_or("", |endpoint| endpoint.protocol.as_str()); + let mut context = vec![ + (context_fields::BINARY_PATH, string(&request.binary_path)), + (context_fields::ANCESTORS, string_set(&request.ancestors)), + ( + context_fields::BINARY_ALIASES, + string_set(&request.binary_aliases), + ), + ( + context_fields::BINARY_GLOBS, + string_set(&request.binary_globs), + ), + ]; + if let Some(ip) = request.destination_ip { + context.push(( + context_fields::DESTINATION_IP, + RestrictedExpression::new_ip(ip.to_string()), + )); + } + let policies = if request.destination_ip.is_some() { + &self.deciding_policies + } else { + &self.unresolved_policies + }; + let outcome = self.evaluator.authorize( + policies, + &self.evaluator.uids.network_connect, + &Principal { + user: &request.user, + group: &request.group, + }, + &request.host, + request.port, + protocol, + context, + None, + )?; + let mut evaluation = NetworkEvaluation { + decision: Decision::Deny { + matched_policies: Vec::new(), + }, + address_conditions: Vec::new(), + unconstrained_permit: false, + names_endpoint: false, + }; + if outcome.allowed { + let host = normalize_host(&request.host); + for id in &outcome.reasons { + let permit = self.analysis.network_permits.get(id); + match permit.map(|permit| &permit.allowed_ips) { + Some(ranges) if !ranges.is_empty() => { + evaluation.address_conditions.push(ranges.clone()); + } + _ => evaluation.unconstrained_permit = true, + } + if permit + .and_then(|permit| permit.endpoint.as_ref()) + .is_some_and(|(named, port)| *named == host && *port == request.port) + { + evaluation.names_endpoint = true; + } + } + } + evaluation.decision = outcome.into_decision(policies); + Ok(evaluation) + } + + /// Evaluates one per-request `HttpRequest` within a permitted connection. + /// + /// `context.query` holds one value per query key. When keys repeat, the + /// request is evaluated once per combination of their values, and it is + /// allowed only if no evaluation is denied and one `permit` allows every + /// evaluation. This matches YAML query matchers: an allow rule must match + /// every value of each key it names, and a deny rule fires when each of + /// its keys has one matching value. Keys no policy reads are left out, + /// so their values do not multiply evaluations. The same combined + /// decision is computed for the deciding policies (every policy but the + /// staged ones) and, when the endpoint has staged policies, with them. + /// + /// A request the proxy parsed as GraphQL (`graphql_request`) on a + /// `graphql` or `websocket-graphql` path is decided without the permits + /// attached to other paths of the endpoint that neither read a + /// `graphql_*` field nor test `resource.protocol`, as a YAML GraphQL + /// endpoint is authoritative for the requests it parses. Forbids always + /// apply. + /// + /// # Errors + /// + /// Returns [`CedarEngineError::TooManyQueryCombinations`] if the query + /// has more than [`MAX_QUERY_COMBINATIONS`] value combinations, and + /// another [`CedarEngineError`] if the request cannot be represented in + /// the loaded schema, or if Cedar reports an error while evaluating any + /// policy. + pub fn evaluate_l7( + &self, + request: &HttpRequestInput, + ) -> Result { + let endpoint = self.l7_endpoint_at(&request.host, request.port, &request.endpoint_path); + let protocol = endpoint.map_or("", |endpoint| endpoint.protocol.as_str()); + let query = QueryCombinations::new(&request.query, &self.analysis.query_keys)?; + let evaluate = |policies: &PolicySet| self.decide_l7(policies, request, protocol, &query); + // Any entry selecting the host that declares the routed path works: + // its exclusion set covers every entry that can select the host. + let graphql = request + .graphql_request + .then(|| { + self.analysis + .l7_selecting(&normalize_host(&request.host), request.port) + .find_map(|(key, _)| { + self.graphql_policies.get(&( + key.to_string(), + request.port, + request.endpoint_path.clone(), + )) + }) + }) + .flatten(); + let (deciding, all) = graphql.map_or((&self.deciding_policies, &self.policies), |sets| { + (&sets.deciding, &sets.all) + }); + let decision = evaluate(deciding)?; + // Compare decisions, not the staged set's decision alone: Cedar denies + // by default, so a set of only staged `forbid`s denies every request + // whether or not one of them matched. + let staged = if endpoint.is_some_and(|endpoint| endpoint.staged) { + let staged = evaluate(all)?; + (staged.is_allow() != decision.is_allow()).then_some(staged) + } else { + None + }; + Ok(L7Evaluation { decision, staged }) + } + + /// Decides one `HttpRequest` with `policies` over every combination of + /// its query values; see [`Self::evaluate_l7`]. + /// + /// A denied combination denies the request with its determining + /// policies. When every combination is allowed but no single `permit` + /// allows them all, the request is denied with no determining policy. + fn decide_l7( + &self, + policies: &PolicySet, + request: &HttpRequestInput, + protocol: &str, + query: &QueryCombinations<'_>, + ) -> Result { + let context = self.evaluator.l7_context(request); + // The permits that allowed every combination so far. + let mut common_permits: Option> = None; + for index in 0..query.count { + let tags = query.combination(index); + let query_entity = self.evaluator.query_entity(tags)?; + let outcome = self.evaluator.authorize( + policies, + &self.evaluator.uids.http_request, + &Principal { + user: &request.user, + group: &request.group, + }, + &request.host, + request.port, + protocol, + context.clone(), + Some(query_entity), + )?; + if !outcome.allowed { + return Ok(outcome.into_decision(policies)); + } + let permits = common_permits.get_or_insert_with(|| outcome.reasons.clone()); + permits.retain(|id| outcome.reasons.contains(id)); + if permits.is_empty() { + break; + } + } + // No combination at all (a key without values) also denies. + Ok(match common_permits { + Some(permits) if !permits.is_empty() => Outcome { + allowed: true, + reasons: permits, + } + .into_decision(policies), + _ => Decision::Deny { + matched_policies: Vec::new(), + }, + }) + } +} + +/// The schema, authorizer, and entity ids every evaluation needs. +#[derive(Debug)] +pub(crate) struct Evaluator { + pub(crate) schema: Schema, + authorizer: Authorizer, + pub(crate) uids: RequestUids, +} + +impl Evaluator { + /// Loads the canonical schema. + pub(crate) fn new() -> Result { + Ok(Self { + schema: openshell_policy_cedar_schema::load_schema()?, + authorizer: Authorizer::new(), + uids: RequestUids::new()?, + }) + } + + /// Builds the `HttpRequest` context of `request`, whose `query` is the + /// per-evaluation `HttpQuery` entity. + pub(crate) fn l7_context( + &self, + request: &HttpRequestInput, + ) -> Vec<(&'static str, RestrictedExpression)> { + vec![ + (context_fields::BINARY_PATH, string(&request.binary_path)), + (context_fields::ANCESTORS, string_set(&request.ancestors)), + ( + context_fields::BINARY_ALIASES, + string_set(&request.binary_aliases), + ), + ( + context_fields::BINARY_GLOBS, + string_set(&request.binary_globs), + ), + (context_fields::METHOD, string(&request.method)), + (context_fields::PATH, string(&request.path)), + (context_fields::COMMAND, string(&request.command)), + ( + context_fields::JSONRPC_METHOD, + string(&request.jsonrpc_method), + ), + ( + context_fields::JSONRPC_RECEIVE_STREAM, + RestrictedExpression::new_bool(request.jsonrpc_receive_stream), + ), + ( + context_fields::JSONRPC_RESPONSE, + RestrictedExpression::new_bool(request.jsonrpc_response), + ), + (context_fields::MCP_TOOL, string(&request.mcp_tool)), + ( + context_fields::MCP_METHOD_CLASS, + string(&request.mcp_method_class), + ), + ( + context_fields::GRAPHQL_OPERATION_TYPE, + string(&request.graphql_operation_type), + ), + ( + context_fields::GRAPHQL_OPERATION_NAME, + string(&request.graphql_operation_name), + ), + ( + context_fields::GRAPHQL_FIELDS, + string_set(&request.graphql_fields), + ), + ( + context_fields::QUERY, + RestrictedExpression::new_entity_uid(self.uids.query.clone()), + ), + ] + } + + /// Builds the `HttpQuery` entity holding one value per query key. + pub(crate) fn query_entity<'a>( + &self, + tags: impl Iterator, + ) -> Result { + Entity::new_with_tags( + self.uids.query.clone(), + [], + [], + tags.map(|(key, value)| (key.to_string(), string(value))), + ) + .map_err(|e| CedarEngineError::EntityBuild(Box::new(e))) + } + + /// Runs one authorization query. + /// + /// `protocol` is the endpoint's `resource.protocol`: the inspection + /// protocol of the request's routed path, or `""` when not inspected. + #[expect( + clippy::too_many_arguments, + reason = "one call per action; the arguments are the request's distinct parts" + )] + pub(crate) fn authorize( + &self, + policies: &PolicySet, + action: &EntityUid, + principal: &Principal<'_>, + host: &str, + port: u16, + protocol: &str, + context: Vec<(&str, RestrictedExpression)>, + query: Option, + ) -> Result { + let host = normalize_host(host); + let host_port = format!("{host}:{port}"); + + let user_uid = EntityUid::from_type_name_and_id( + self.uids.user_type.clone(), + entity_id(principal.user), + ); + let group_uid = EntityUid::from_type_name_and_id( + self.uids.group_type.clone(), + entity_id(principal.group), + ); + let endpoint_uid = EntityUid::from_type_name_and_id( + self.uids.endpoint_type.clone(), + entity_id(&host_port), + ); + + let process = Entity::new( + self.uids.process.clone(), + HashMap::from([ + ( + PROCESS_USER_ATTR.to_string(), + RestrictedExpression::new_entity_uid(user_uid.clone()), + ), + ( + PROCESS_GROUP_ATTR.to_string(), + RestrictedExpression::new_entity_uid(group_uid.clone()), + ), + ]), + HashSet::new(), + ) + .map_err(|e| CedarEngineError::EntityBuild(Box::new(e)))?; + let user = Entity::new_no_attrs(user_uid, HashSet::new()); + let group = Entity::new_no_attrs(group_uid, HashSet::new()); + let endpoint = Entity::new( + endpoint_uid.clone(), + HashMap::from([ + ( + endpoint_fields::HOST.to_string(), + RestrictedExpression::new_string(host), + ), + ( + endpoint_fields::PORT.to_string(), + RestrictedExpression::new_long(i64::from(port)), + ), + ( + endpoint_fields::PROTOCOL.to_string(), + RestrictedExpression::new_string(protocol.to_string()), + ), + ( + endpoint_fields::HOST_PORT.to_string(), + RestrictedExpression::new_string(host_port), + ), + ]), + HashSet::new(), + ) + .map_err(|e| CedarEngineError::EntityBuild(Box::new(e)))?; + + let entities = Entities::from_entities( + [process, user, group, endpoint].into_iter().chain(query), + Some(&self.schema), + ) + .map_err(|e| CedarEngineError::EntitiesBuild(Box::new(e)))?; + let context = Context::from_pairs( + context + .into_iter() + .map(|(field, value)| (field.to_string(), value)), + ) + .map_err(|e| CedarEngineError::ContextBuild(Box::new(e)))?; + let request = Request::new( + self.uids.process.clone(), + action.clone(), + endpoint_uid, + context, + Some(&self.schema), + ) + .map_err(|e| CedarEngineError::RequestBuild(Box::new(e)))?; + + let response = self.authorizer.is_authorized(&request, policies, &entities); + let errors: Vec = response + .diagnostics() + .errors() + .map(ToString::to_string) + .collect(); + if !errors.is_empty() { + return Err(CedarEngineError::Evaluation { + reason: errors.join("; "), + }); + } + Ok(Outcome { + allowed: response.decision() == cedar_policy::Decision::Allow, + reasons: response.diagnostics().reason().cloned().collect(), + }) + } +} + +/// The result of one authorization query. +pub(crate) struct Outcome { + pub(crate) allowed: bool, + /// The determining policies: the satisfied `permit`s of an allow, or + /// the satisfied `forbid`s of a deny (none when no `permit` matched). + pub(crate) reasons: Vec, +} + +impl Outcome { + /// Converts to a [`Decision`], naming policies by their display ids. + fn into_decision(self, policies: &PolicySet) -> Decision { + let matched_policies = self + .reasons + .iter() + .map(|id| { + policies + .policy(id) + .map_or_else(|| id.to_string(), analysis::display_id) + }) + .collect(); + if self.allowed { + Decision::Allow { matched_policies } + } else { + Decision::Deny { matched_policies } + } + } +} + +/// The value combinations of a request's query keys that policies read. +pub(crate) struct QueryCombinations<'a> { + /// The readable keys with their values, ordered by key. + keys: Vec<(&'a str, &'a [String])>, + /// The number of combinations: the product of the value counts, which is + /// 1 for a query without readable keys and 0 when a key has no values. + pub(crate) count: usize, +} + +impl<'a> QueryCombinations<'a> { + /// Selects the keys of `query` that `readable` includes. + /// + /// # Errors + /// + /// Returns [`CedarEngineError::TooManyQueryCombinations`] when there are + /// more than [`MAX_QUERY_COMBINATIONS`] combinations. + pub(crate) fn new( + query: &'a BTreeMap>, + readable: &QueryKeys, + ) -> Result { + let keys: Vec<_> = query + .iter() + .filter(|(key, _)| readable.includes(key)) + .map(|(key, values)| (key.as_str(), values.as_slice())) + .collect(); + let count = keys.iter().fold(1_usize, |count, (_, values)| { + count.saturating_mul(values.len()) + }); + if count > MAX_QUERY_COMBINATIONS { + return Err(CedarEngineError::TooManyQueryCombinations { + combinations: count, + limit: MAX_QUERY_COMBINATIONS, + }); + } + Ok(Self { keys, count }) + } + + /// Returns combination `index` (below [`Self::count`]): one value per key. + pub(crate) fn combination( + &self, + index: usize, + ) -> impl Iterator + '_ { + let mut rest = index; + self.keys.iter().map(move |(key, values)| { + // `count` is nonzero, so every key has a value. + let value = values[rest % values.len()].as_str(); + rest /= values.len(); + (*key, value) + }) + } +} + +/// The user and group a request is evaluated as. +pub(crate) struct Principal<'a> { + pub(crate) user: &'a str, + pub(crate) group: &'a str, +} + +fn string(value: &str) -> RestrictedExpression { + RestrictedExpression::new_string(value.to_string()) +} + +fn string_set(values: &[String]) -> RestrictedExpression { + RestrictedExpression::new_set(values.iter().map(|value| string(value))) +} + +/// Lowercases `host` and strips one trailing `.`. +/// +/// DNS-resolved hostnames (as published by `policy_dns` and read back via +/// `ResolvedEndpointStore::lookup`) are absolute FQDNs with a trailing dot +/// (see `NormalizedName::parse`); authored Cedar policy host literals never +/// have one. Without this, every request whose host came from a DNS +/// resolution would fail to match an otherwise-identical policy host. +/// Lowercasing is ASCII-only, matching how endpoint literals are checked at +/// load. +#[must_use] +pub fn normalize_host(host: &str) -> String { + host.strip_suffix('.').unwrap_or(host).to_ascii_lowercase() +} + +fn entity_type(type_name: &str) -> Result { + EntityTypeName::from_str(type_name).map_err(|e| CedarEngineError::EntityTypeParse(Box::new(e))) +} + +fn entity_id(id: &str) -> EntityId { + // `EntityId::from_str` is infallible: any string is a valid entity id. + EntityId::from_str(id).unwrap_or_else(|never| match never {}) +} + +/// Builds an [`EntityUid`] from a Cedar entity type name and id. +fn entity_uid(type_name: &str, id: &str) -> Result { + Ok(EntityUid::from_type_name_and_id( + entity_type(type_name)?, + entity_id(id), + )) +} diff --git a/crates/openshell-policy-cedar/src/token_grants.rs b/crates/openshell-policy-cedar/src/token_grants.rs new file mode 100644 index 0000000000..79ee160baf --- /dev/null +++ b/crates/openshell-policy-cedar/src/token_grants.rs @@ -0,0 +1,1770 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Token-grant owner selection for Cedar sandboxes, compiled from provider +//! rules to Cedar. +//! +//! The gateway stamps each attached provider endpoint with the owner of its +//! token grants. A YAML sandbox admits owner O for a request when an +//! endpoint stamped with O matches the request and its own rules allow it, +//! and no deny rule of any provider endpoint fires. [`TokenGrantRules`] +//! compiles the provider rules into a Cedar policy set with that meaning and +//! evaluates it with the same `HttpRequest` request the sandbox's own Cedar +//! policy decides: +//! +//! - each allow rule of an owned endpoint becomes a `permit` annotated with +//! `@token_grant_owner`, conditioned on its policy's binaries, the +//! endpoint's host, ports, and path, and the rule's matchers; +//! - each deny rule of any endpoint becomes a `forbid`, as do the protocol +//! denials of JSON-RPC and GraphQL endpoints, because a YAML deny admits no +//! owner at all; +//! - an owner is admitted when one of its endpoints is admitted by every +//! evaluation of the request (one per GraphQL operation), and within one +//! evaluation by a single `permit` for every combination of repeated query +//! values, as a YAML allow rule must match every value. +//! +//! Glob matchers are translated to delimited `like` patterns with the +//! semantics of the Rego `glob.match` they replace. A binary glob becomes a +//! `context.binary_globs.contains(pattern)` test, since the supervisor +//! matches the patterns [`TokenGrantRules::binary_glob_patterns`] lists +//! against the binary and its ancestors with the YAML matcher. A matcher +//! Cedar cannot express exactly never widens what is admitted: a `permit` +//! loses it (the rule admits nothing, or less), a `forbid` ignores it (the +//! rule denies more), and a pattern Rego could fail to evaluate admits no +//! owner at all. [`TokenGrantRules::untranslated`] lists each such case. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt::{self, Write as _}; + +use cedar_policy::{Policy, PolicyId, PolicySet, ValidationMode, Validator}; + +use crate::analysis::QueryKeys; +use crate::{CedarEngineError, Evaluator, HttpRequestInput, Principal, QueryCombinations}; + +/// Annotation naming the owner whose grant a `permit` admits. +pub const TOKEN_GRANT_OWNER_ANNOTATION: &str = "token_grant_owner"; + +/// Most `**` path components of one glob that may also match no component. +/// +/// Each one doubles the patterns the glob is translated to. +const MAX_ZERO_COMPONENT_WILDCARDS: usize = 4; + +/// One provider's network rule, as the YAML engine evaluates it. +/// +/// Built from the normalized policy data: access presets expanded, aliases +/// normalized, and symlinked binaries listed with their targets. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProviderPolicy { + /// The rule's key, used to name generated policies. + pub name: String, + /// Binary paths and globs, including resolved symlink targets. + pub binaries: Vec, + /// The rule's endpoints, in order. + pub endpoints: Vec, +} + +/// One provider endpoint and its L7 rules. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProviderEndpoint { + /// Host or host glob as authored; empty for none. + pub host: String, + /// Ports; an endpoint without ports matches nothing. + pub ports: Vec, + /// Endpoint path pattern; empty for every path. + pub path: String, + /// The endpoint has `allowed_ips`, which lets a host-less endpoint match + /// every host. + pub has_allowed_ips: bool, + /// Canonical protocol (`rest`, `json-rpc`, `mcp`, `graphql`, ...), or + /// empty. + pub protocol: String, + /// The owner the gateway stamped; empty for none. + pub token_grant_owner: String, + /// The MCP option allowing every available method. + pub mcp_allow_all_known_methods: bool, + /// Allow rules. + pub rules: Vec, + /// Deny rules. + pub deny_rules: Vec, +} + +/// The selectors of one allow or deny rule; `None` when absent. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RuleMatcher { + /// HTTP or JSON-RPC method. + pub method: Option, + /// Request path glob. + pub path: Option, + /// SQL command. + pub command: Option, + /// GraphQL operation type. + pub operation_type: Option, + /// GraphQL operation name glob. + pub operation_name: Option, + /// GraphQL root field globs. + pub fields: Vec, + /// Query parameter matchers by key. + pub query: BTreeMap, + /// JSON-RPC params matchers by key. + pub params: BTreeMap, +} + +/// A query or params value matcher: matches a value any of `globs` matches. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ValueMatcher { + /// Globs with `.` as the delimiter. + pub globs: Vec, +} + +/// Where an untranslated selector appears. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleLocation { + /// The policy's binaries. + Binaries, + /// The endpoint's host, ports, or path. + Endpoint, + /// Allow rule `n`. + Rule(usize), + /// Deny rule `n`. + DenyRule(usize), + /// The denial a GraphQL or WebSocket endpoint applies to operations its + /// rules do not allow. + ProtocolDeny, +} + +/// How an untranslated selector is compiled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Handling { + /// The `permit` admits less than the YAML rule. + PermitNarrowed, + /// The rule admits no owner. + PermitDropped, + /// The `forbid` fires where the YAML rule does and in more cases. + ForbidWidened, + /// No owner is admitted for any request. + FailClosed, +} + +impl fmt::Display for Handling { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::PermitNarrowed => "allow rule narrowed", + Self::PermitDropped => "allow rule admits no owner", + Self::ForbidWidened => "deny widened", + Self::FailClosed => "no owner is admitted", + }) + } +} + +/// A provider selector Cedar cannot express exactly, and how it is compiled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UntranslatedRule { + /// The provider policy's name. + pub policy: String, + /// The endpoint's index, if the selector belongs to one. + pub endpoint: Option, + /// Where in the policy or endpoint it appears. + pub location: RuleLocation, + /// Why it cannot be translated exactly. + pub reason: String, + /// How it is compiled instead. + pub handling: Handling, +} + +impl fmt::Display for UntranslatedRule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.policy)?; + if let Some(endpoint) = self.endpoint { + write!(f, ".endpoints[{endpoint}]")?; + } + match self.location { + RuleLocation::Binaries => write!(f, ".binaries")?, + RuleLocation::Endpoint => {} + RuleLocation::Rule(rule) => write!(f, ".rules[{rule}]")?, + RuleLocation::DenyRule(rule) => write!(f, ".deny_rules[{rule}]")?, + RuleLocation::ProtocolDeny => write!(f, " (protocol deny)")?, + } + write!(f, ": {} ({})", self.reason, self.handling) + } +} + +/// Provider rules compiled to Cedar for token-grant owner selection. +#[derive(Debug)] +pub struct TokenGrantRules { + evaluator: Evaluator, + policies: PolicySet, + /// The endpoint slot each owner-annotated `permit` admits. + permit_slots: HashMap, + /// The owner of each endpoint slot. + owners: Vec, + query_keys: QueryKeys, + /// The binary glob patterns the generated policies test. + binary_globs: BTreeSet, + source: String, + untranslated: Vec, + fail_closed: Option, +} + +impl TokenGrantRules { + /// Compiles provider rules and validates the result against the schema. + /// + /// Without a stamped owner the result admits no owner and holds no + /// policies. + /// + /// # Errors + /// + /// Returns [`CedarEngineError`] if the schema fails to load or a + /// generated policy fails to parse or validate, which indicates a + /// compiler defect rather than a policy error. + pub fn compile(policies: &[ProviderPolicy]) -> Result { + let evaluator = Evaluator::new()?; + let mut compiler = Compiler::default(); + let owned = policies + .iter() + .flat_map(|policy| &policy.endpoints) + .any(|endpoint| !endpoint.token_grant_owner.is_empty()); + if owned { + for policy in policies { + compiler.policy(policy); + } + } + let source = compiler + .sources + .iter() + .map(|(_, text)| text.as_str()) + .collect::>() + .join("\n"); + let mut set = PolicySet::new(); + if compiler.fail_closed.is_none() { + for (id, text) in &compiler.sources { + let policy = Policy::parse(Some(PolicyId::new(id)), text) + .map_err(|e| CedarEngineError::PolicyParse(Box::new(e)))?; + set.add(policy) + .map_err(|e| CedarEngineError::PolicySet(Box::new(e)))?; + } + let validation = + Validator::new(evaluator.schema.clone()).validate(&set, ValidationMode::Strict); + if !validation.validation_passed() { + let reason = validation + .validation_errors() + .map(ToString::to_string) + .collect::>() + .join("; "); + return Err(CedarEngineError::PolicyValidation { reason }); + } + } + Ok(Self { + evaluator, + policies: set, + permit_slots: compiler + .permit_slots + .into_iter() + .map(|(id, slot)| (PolicyId::new(id), slot)) + .collect(), + owners: compiler.owners, + query_keys: QueryKeys::Named(compiler.query_keys), + binary_globs: compiler.binary_globs, + source, + untranslated: compiler.untranslated, + fail_closed: compiler.fail_closed, + }) + } + + /// Returns the generated Cedar policies. + #[must_use] + pub fn policy_text(&self) -> &str { + &self.source + } + + /// Returns the selectors compiled other than exactly. + #[must_use] + pub fn untranslated(&self) -> &[UntranslatedRule] { + &self.untranslated + } + + /// Returns the binary glob patterns the generated policies test with + /// `context.binary_globs`, sorted. + /// + /// The caller lists in a request's `binary_globs` the ones that match + /// its binary or an ancestor with the YAML binary glob matcher, as for + /// `CedarEngine::binary_glob_patterns`. + pub fn binary_glob_patterns(&self) -> impl Iterator { + self.binary_globs.iter().map(String::as_str) + } + + /// Returns why no owner is ever admitted, if that is the case. + #[must_use] + pub fn fail_closed_reason(&self) -> Option<&str> { + self.fail_closed.as_deref() + } + + /// Returns the owners admitted for one request. + /// + /// `requests` are the request's evaluations, as the sandbox's own Cedar + /// policy evaluated and allowed them: one per GraphQL operation, else + /// one. An owner is admitted when one of its endpoints admits every + /// evaluation and no `forbid` fires in any. + /// + /// # Errors + /// + /// Returns [`CedarEngineError::TooManyQueryCombinations`] when the query + /// has too many combinations of the keys provider rules read, and + /// another [`CedarEngineError`] when Cedar fails to evaluate a request. + pub fn admitted_owners( + &self, + requests: &[HttpRequestInput], + ) -> Result, CedarEngineError> { + if self.fail_closed.is_some() + || self.permit_slots.is_empty() + || requests.is_empty() + || requests.iter().any(|request| !comparable(request)) + { + return Ok(BTreeSet::new()); + } + let mut admitted: Option> = None; + for request in requests { + let query = QueryCombinations::new(&request.query, &self.query_keys)?; + let context = self.evaluator.l7_context(request); + // The permits that allowed every combination so far. + let mut permits: Option> = None; + for index in 0..query.count { + let outcome = self.evaluator.authorize( + &self.policies, + &self.evaluator.uids.http_request, + &Principal { + user: &request.user, + group: &request.group, + }, + &request.host, + request.port, + "", + context.clone(), + Some(self.evaluator.query_entity(query.combination(index))?), + )?; + // A denial, by a `forbid` or for want of a `permit`, admits + // no owner for this evaluation and so for the request. + if !outcome.allowed { + return Ok(BTreeSet::new()); + } + let common = permits.get_or_insert_with(|| outcome.reasons.clone()); + common.retain(|id| outcome.reasons.contains(id)); + } + let slots: BTreeSet = permits + .unwrap_or_default() + .iter() + .filter_map(|id| self.permit_slots.get(id).copied()) + .collect(); + let slots = match admitted { + Some(previous) => previous.intersection(&slots).copied().collect(), + None => slots, + }; + if slots.is_empty() { + return Ok(BTreeSet::new()); + } + admitted = Some(slots); + } + Ok(admitted + .unwrap_or_default() + .into_iter() + .map(|slot| self.owners[slot].clone()) + .collect()) + } +} + +/// Returns whether Cedar sees `request` as the Rego rules see it. +/// +/// Cedar strips a trailing `.` from the host, which the Rego host comparison +/// keeps, and Rego fails to glob-match a string holding NUL. +fn comparable(request: &HttpRequestInput) -> bool { + let no_nul = |value: &str| !value.contains('\0'); + !request.host.ends_with('.') + && [ + &request.host, + &request.binary_path, + &request.method, + &request.path, + &request.jsonrpc_method, + &request.mcp_tool, + &request.graphql_operation_name, + ] + .into_iter() + .all(|value| no_nul(value)) + && request.ancestors.iter().all(|value| no_nul(value)) + && request.graphql_fields.iter().all(|value| no_nul(value)) + && request + .query + .iter() + .all(|(key, values)| no_nul(key) && values.iter().all(|value| no_nul(value))) +} + +/// A generated condition. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Cond { + /// Never holds. + Never, + /// Holds when every conjunct holds; empty for `true`. + All(Vec), +} + +impl Cond { + const TRUE: Self = Self::All(Vec::new()); + + fn expr(text: impl Into) -> Self { + Self::All(vec![text.into()]) + } + + fn all(parts: impl IntoIterator) -> Self { + let mut conjuncts = Vec::new(); + for part in parts { + match part { + Self::Never => return Self::Never, + Self::All(more) => conjuncts.extend(more), + } + } + Self::All(conjuncts) + } + + fn any(parts: impl IntoIterator) -> Self { + let mut disjuncts = Vec::new(); + for part in parts { + match part { + Self::Never => {} + Self::All(conjuncts) if conjuncts.is_empty() => return Self::TRUE, + part @ Self::All(_) => disjuncts.push(part), + } + } + match disjuncts.len() { + 0 => Self::Never, + 1 => disjuncts.remove(0), + _ => Self::expr(format!( + "({})", + disjuncts + .iter() + .map(Self::inline) + .collect::>() + .join(" || ") + )), + } + } + + fn not(self) -> Self { + match self { + Self::Never => Self::TRUE, + Self::All(conjuncts) if conjuncts.is_empty() => Self::Never, + Self::All(conjuncts) => Self::expr(format!("!({})", conjuncts.join(" && "))), + } + } + + fn inline(&self) -> String { + match self { + Self::Never => "false".to_string(), + Self::All(conjuncts) => match conjuncts.len() { + 0 => "true".to_string(), + 1 => conjuncts[0].clone(), + _ => format!("({})", conjuncts.join(" && ")), + }, + } + } +} + +/// Which effect a condition is built for, which decides how an +/// untranslatable selector is approximated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Side { + Permit, + Forbid, +} + +/// The state of building one condition. +#[derive(Debug)] +struct Scope { + side: Side, + /// Why selectors were approximated. + reasons: Vec, + /// Set when a pattern could fail Rego evaluation. + fail: Option, + /// The query keys the condition reads. + query_keys: BTreeSet, +} + +impl Scope { + fn new(side: Side) -> Self { + Self { + side, + reasons: Vec::new(), + fail: None, + query_keys: BTreeSet::new(), + } + } + + /// Approximates a selector Cedar cannot express: a `permit` loses it, a + /// `forbid` ignores it. + fn unsupported(&mut self, reason: impl Into) -> Cond { + self.reasons.push(reason.into()); + match self.side { + Side::Permit => Cond::Never, + Side::Forbid => Cond::TRUE, + } + } + + /// Records a pattern Rego could fail to evaluate. + fn may_fail(&mut self, reason: String) -> Cond { + self.fail.get_or_insert(reason); + Cond::Never + } + + /// Builds `target like pattern`, or `==` for a pattern without wildcards. + fn glob(&mut self, target: &str, pattern: &str, delimiter: char) -> Cond { + match translate_glob(pattern, delimiter) { + Glob::Patterns(patterns) => Cond::any(patterns.into_iter().map(|pattern| { + if pattern.contains('*') { + Cond::expr(format!(r#"{target} like("{pattern}", "{delimiter}")"#)) + } else { + Cond::expr(format!(r#"{target} == "{pattern}""#)) + } + })), + Glob::Unsupported(why) => self.unsupported(format!("glob {pattern:?}: {why}")), + Glob::MayFail(why) => self.may_fail(format!("glob {pattern:?}: {why}")), + } + } + + /// Matches `target` against a query or params matcher. + fn value(&mut self, target: &str, matcher: &ValueMatcher) -> Cond { + let parts: Vec = matcher + .globs + .iter() + .map(|glob| self.glob(target, glob, '.')) + .collect(); + Cond::any(parts) + } +} + +/// A glob translated to delimited `like` patterns. +#[derive(Debug, PartialEq, Eq)] +enum Glob { + /// Patterns, escaped for a Cedar string, one of which must match. + Patterns(Vec), + /// The glob uses a feature Cedar cannot express. + Unsupported(&'static str), + /// The glob may be invalid, which fails Rego evaluation. + MayFail(&'static str), +} + +/// Translates a Rego `glob.match` pattern with one delimiter. +/// +/// Rego builds the glob with `globset` and a literal separator. `*` matches +/// within one segment and `**` spans segments only as a whole segment; +/// elsewhere it is `*`. With the `/` delimiter, a leading `**/` and an inner +/// `/**/` also match no segment, which a Cedar `**` does not, so each yields +/// one pattern with and one without it. With the `.` delimiter, Rego's +/// rewriting of `.` into `/./` makes the two agree. +fn translate_glob(pattern: &str, delimiter: char) -> Glob { + if pattern + .chars() + .any(|c| matches!(c, '[' | ']' | '{' | '}' | '\\' | '\0')) + { + return Glob::MayFail("character classes, alternatives, and escapes are not supported"); + } + if pattern.contains('?') { + return Glob::Unsupported("`?` has no Cedar equivalent"); + } + if pattern.contains("***") || pattern.contains(&format!("**{delimiter}**")) { + return Glob::Unsupported("adjacent recursive wildcards are not supported"); + } + let chars: Vec = pattern.chars().collect(); + let mut variants = vec![String::new()]; + let mut zero_component = 0; + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c != '*' { + let escaped = escape_char(c); + for v in &mut variants { + v.push_str(&escaped); + } + i += 1; + continue; + } + if chars.get(i + 1) != Some(&'*') { + for v in &mut variants { + v.push('*'); + } + i += 1; + continue; + } + let prev = i.checked_sub(1).map(|p| chars[p]); + let next = chars.get(i + 2).copied(); + let whole_segment = + prev.is_none_or(|p| p == delimiter) && next.is_none_or(|n| n == delimiter); + if !whole_segment { + for v in &mut variants { + v.push('*'); + } + } else if delimiter == '/' && next == Some('/') { + zero_component += 1; + if zero_component > MAX_ZERO_COMPONENT_WILDCARDS { + return Glob::Unsupported("too many `**` components"); + } + variants = variants + .into_iter() + .flat_map(|v| [format!("{v}**/"), v]) + .collect(); + i += 3; + continue; + } else { + for v in &mut variants { + v.push_str("**"); + } + } + i += 2; + } + variants.dedup(); + Glob::Patterns(variants) +} + +/// Escapes one character for a Cedar string or pattern. +fn escape_char(c: char) -> String { + match c { + '"' => "\\\"".to_string(), + '\\' => "\\\\".to_string(), + c if c.is_control() => format!("\\u{{{:x}}}", u32::from(c)), + c => c.to_string(), + } +} + +/// Quotes `value` as a Cedar string literal. +fn quote(value: &str) -> String { + format!("\"{}\"", value.chars().map(escape_char).collect::()) +} + +/// Quotes `value` as a literal Cedar `like` pattern, escaping `*`. +fn quote_literal_pattern(value: &str) -> String { + quote(value).replace('*', "\\*") +} + +/// The binary condition of one policy for each side, and why it is +/// approximated. +#[derive(Debug)] +struct Binaries { + permit: Cond, + permit_reasons: Vec, + forbid: Cond, + forbid_reasons: Vec, +} + +/// Characters a binary glob may hold that Rego's matcher could fail to +/// compile, which fails the YAML evaluation and so admits no owner. +const MAY_FAIL_GLOB_CHARS: [char; 6] = ['[', ']', '{', '}', '\\', '\0']; + +/// Accumulates generated policies. +#[derive(Debug, Default)] +struct Compiler { + /// `(policy id, text)` in generation order. + sources: Vec<(String, String)>, + permit_slots: Vec<(String, usize)>, + owners: Vec, + query_keys: BTreeSet, + binary_globs: BTreeSet, + untranslated: Vec, + fail_closed: Option, +} + +impl Compiler { + fn policy(&mut self, policy: &ProviderPolicy) { + let binaries = self.binaries(policy); + let mut noted = (false, false); + for (index, endpoint) in policy.endpoints.iter().enumerate() { + self.endpoint(policy, index, endpoint, &binaries, &mut noted); + } + } + + /// Builds a policy's binary condition: an exact path matches the binary + /// or an ancestor, and a glob matches the binary or an ancestor through + /// `context.binary_globs`, which the supervisor fills with the YAML + /// matcher. + fn binaries(&mut self, policy: &ProviderPolicy) -> Binaries { + let mut globs = BTreeSet::new(); + let mut build = |side| { + let mut scope = Scope::new(side); + let parts: Vec = policy + .binaries + .iter() + .map(|binary| { + let path = quote(binary); + if !binary.contains('*') { + return Cond::expr(format!( + "(context.binary_path == {path} || context.ancestors.contains({path}))" + )); + } + if binary.contains(MAY_FAIL_GLOB_CHARS) { + return scope.may_fail(format!( + "binary glob {binary:?}: character classes, alternatives, and \ + escapes are not supported" + )); + } + globs.insert(binary.clone()); + Cond::expr(format!("context.binary_globs.contains({path})")) + }) + .collect(); + (Cond::any(parts), scope) + }; + let (permit, permit_scope) = build(Side::Permit); + let (forbid, forbid_scope) = build(Side::Forbid); + self.binary_globs.extend(globs); + for fail in [permit_scope.fail, forbid_scope.fail].into_iter().flatten() { + self.fail(&policy.name, None, RuleLocation::Binaries, fail); + } + Binaries { + permit, + permit_reasons: permit_scope.reasons, + forbid, + forbid_reasons: forbid_scope.reasons, + } + } + + fn fail( + &mut self, + policy: &str, + endpoint: Option, + location: RuleLocation, + reason: String, + ) { + self.untranslated.push(UntranslatedRule { + policy: policy.to_string(), + endpoint, + location, + reason: reason.clone(), + handling: Handling::FailClosed, + }); + self.fail_closed + .get_or_insert_with(|| format!("{policy}: {reason}")); + } + + #[expect( + clippy::too_many_lines, + reason = "one place lists every Rego allow and deny clause of an endpoint" + )] + fn endpoint( + &mut self, + policy: &ProviderPolicy, + index: usize, + endpoint: &ProviderEndpoint, + binaries: &Binaries, + noted: &mut (bool, bool), + ) { + let jsonrpc_family = matches!(endpoint.protocol.as_str(), "json-rpc" | "mcp"); + let mcp = endpoint.protocol == "mcp"; + let ctx = Ctx { + policy, + index, + endpoint, + }; + + if !endpoint.token_grant_owner.is_empty() { + let slot = self.owners.len(); + self.owners.push(endpoint.token_grant_owner.clone()); + for (rule_index, rule) in endpoint.rules.iter().enumerate() { + let location = RuleLocation::Rule(rule_index); + if rule.method.is_some() { + let label = format!("rules[{rule_index}]"); + if jsonrpc_family { + self.emit(&ctx, binaries, noted, Some(slot), location, &label, |s| { + jsonrpc_cond(s, endpoint, rule, true) + }); + } else { + self.emit(&ctx, binaries, noted, Some(slot), location, &label, |s| { + rest_cond(s, rule) + }); + } + } + if rule.operation_type.is_some() { + let label = format!("rules[{rule_index}]/graphql"); + self.emit(&ctx, binaries, noted, Some(slot), location, &label, |s| { + graphql_cond(s, rule, true) + }); + } + } + if mcp && endpoint.mcp_allow_all_known_methods { + // The tool rules narrow `tools/call` instead. + let narrowed = endpoint + .rules + .iter() + .any(|rule| rule.params.contains_key("name")); + self.emit( + &ctx, + binaries, + noted, + Some(slot), + RuleLocation::Endpoint, + "mcp_allow_all_known_methods", + |_| { + Cond::all([ + jsonrpc_call(), + Cond::expr("!context.jsonrpc_response"), + Cond::expr(r#"context.mcp_method_class == "available""#), + if narrowed { + Cond::expr(r#"context.jsonrpc_method != "tools/call""#) + } else { + Cond::TRUE + }, + ]) + }, + ); + } + if mcp { + // An MCP endpoint admits client response frames and its GET + // receive stream. + self.emit( + &ctx, + binaries, + noted, + Some(slot), + RuleLocation::Endpoint, + "mcp_response", + |_| Cond::expr("context.jsonrpc_response"), + ); + self.emit( + &ctx, + binaries, + noted, + Some(slot), + RuleLocation::Endpoint, + "mcp_receive_stream", + |_| { + Cond::all([ + Cond::expr(r#"context.method == "GET""#), + Cond::expr("context.jsonrpc_receive_stream"), + Cond::expr(r#"context.jsonrpc_method == """#), + Cond::expr("!context.jsonrpc_response"), + ]) + }, + ); + } + } + + for (rule_index, rule) in endpoint.deny_rules.iter().enumerate() { + let location = RuleLocation::DenyRule(rule_index); + if rule.method.is_some() { + let label = format!("deny_rules[{rule_index}]"); + if jsonrpc_family { + self.emit(&ctx, binaries, noted, None, location, &label, |s| { + jsonrpc_cond(s, endpoint, rule, false) + }); + } else { + self.emit(&ctx, binaries, noted, None, location, &label, |s| { + rest_cond(s, rule) + }); + } + } + if rule.operation_type.is_some() { + let label = format!("deny_rules[{rule_index}]/graphql"); + self.emit(&ctx, binaries, noted, None, location, &label, |s| { + graphql_cond(s, rule, false) + }); + } + } + if endpoint.protocol == "json-rpc" { + // A generic JSON-RPC endpoint denies client response frames. + self.emit( + &ctx, + binaries, + noted, + None, + RuleLocation::ProtocolDeny, + "json_rpc_response", + |_| Cond::expr("context.jsonrpc_response"), + ); + } + if matches!(endpoint.protocol.as_str(), "graphql" | "websocket") { + // A GraphQL operation its rules do not allow is denied, even + // when another endpoint's rules allow the request. + self.emit( + &ctx, + binaries, + noted, + None, + RuleLocation::ProtocolDeny, + "graphql_operations", + |s| { + let mut allow = Scope::new(Side::Permit); + let allowed: Vec = endpoint + .rules + .iter() + .filter(|rule| rule.operation_type.is_some()) + .map(|rule| graphql_cond(&mut allow, rule, true)) + .collect(); + s.reasons.extend(allow.reasons); + if let Some(fail) = allow.fail { + s.fail.get_or_insert(fail); + } + Cond::all([ + Cond::expr(r#"context.graphql_operation_type != """#), + Cond::any(allowed).not(), + ]) + }, + ); + } + } + + /// Generates one policy: a `permit` for `slot`, or a `forbid`. + #[expect( + clippy::too_many_arguments, + reason = "the arguments locate and annotate one generated policy" + )] + fn emit( + &mut self, + ctx: &Ctx<'_>, + binaries: &Binaries, + noted: &mut (bool, bool), + slot: Option, + location: RuleLocation, + label: &str, + build: impl FnOnce(&mut Scope) -> Cond, + ) { + let side = if slot.is_some() { + Side::Permit + } else { + Side::Forbid + }; + let mut scope = Scope::new(side); + let matched = endpoint_cond(&mut scope, ctx.endpoint); + let endpoint_reasons = std::mem::take(&mut scope.reasons); + let rule = build(&mut scope); + let binary = match side { + Side::Permit => binaries.permit.clone(), + Side::Forbid => binaries.forbid.clone(), + }; + let cond = Cond::all([binary, matched, rule]); + if let Some(fail) = scope.fail { + self.fail(&ctx.policy.name, Some(ctx.index), location, fail); + return; + } + let handling = match (side, &cond) { + (Side::Permit, Cond::Never) => Handling::PermitDropped, + (Side::Permit, _) => Handling::PermitNarrowed, + (Side::Forbid, _) => Handling::ForbidWidened, + }; + for (location, reason) in endpoint_reasons + .into_iter() + .map(|reason| (RuleLocation::Endpoint, reason)) + .chain(scope.reasons.into_iter().map(|reason| (location, reason))) + { + let note = UntranslatedRule { + policy: ctx.policy.name.clone(), + endpoint: Some(ctx.index), + location, + reason, + handling, + }; + if !self.untranslated.contains(¬e) { + self.untranslated.push(note); + } + } + if cond == Cond::Never { + return; + } + // Binary approximations are reported once per policy and side. + let (seen, reasons, handling) = match side { + Side::Permit => ( + &mut noted.0, + &binaries.permit_reasons, + Handling::PermitNarrowed, + ), + Side::Forbid => ( + &mut noted.1, + &binaries.forbid_reasons, + Handling::ForbidWidened, + ), + }; + if !*seen { + *seen = true; + for reason in reasons { + self.untranslated.push(UntranslatedRule { + policy: ctx.policy.name.clone(), + endpoint: None, + location: RuleLocation::Binaries, + reason: reason.clone(), + handling, + }); + } + } + self.query_keys.extend(scope.query_keys); + + let id = format!( + "{}{}", + if slot.is_some() { "grant" } else { "deny" }, + self.sources.len() + ); + let mut text = format!( + "@id({})\n", + quote(&format!( + "{}/endpoints[{}]/{label}", + ctx.policy.name, ctx.index + )) + ); + if let Some(slot) = slot { + let _ = writeln!( + text, + "@{TOKEN_GRANT_OWNER_ANNOTATION}({})", + quote(&self.owners[slot]) + ); + self.permit_slots.push((id.clone(), slot)); + } + let _ = write!( + text, + "{} (\n principal,\n action == Sandbox::Action::\"HttpRequest\",\n resource is Sandbox::NetworkEndpoint\n)", + if slot.is_some() { "permit" } else { "forbid" } + ); + match cond { + Cond::All(conjuncts) if !conjuncts.is_empty() => { + let _ = write!( + text, + "\nwhen {{\n {}\n}};\n", + conjuncts.join("\n && ") + ); + } + _ => text.push_str(";\n"), + } + self.sources.push((id, text)); + } +} + +/// The endpoint a generated policy belongs to. +struct Ctx<'a> { + policy: &'a ProviderPolicy, + index: usize, + endpoint: &'a ProviderEndpoint, +} + +/// Matches the request's host, port, and path against the endpoint's. +fn endpoint_cond(scope: &mut Scope, endpoint: &ProviderEndpoint) -> Cond { + let host = if endpoint.host.is_empty() { + // A host-less endpoint with `allowed_ips` matches any host. + if endpoint.has_allowed_ips { + Cond::TRUE + } else { + Cond::Never + } + } else if !endpoint.host.is_ascii() { + scope.unsupported("a non-ASCII host is lowercased differently") + } else { + let host = endpoint.host.to_ascii_lowercase(); + if host.contains('*') { + scope.glob("resource.host", &host, '.') + } else { + Cond::expr(format!("resource.host == {}", quote(&host))) + } + }; + let ports = match endpoint.ports.as_slice() { + [] => Cond::Never, + [port] => Cond::expr(format!("resource.port == {port}")), + ports => Cond::expr(format!( + "[{}].contains(resource.port)", + ports + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + )), + }; + Cond::all([host, ports, endpoint_path_cond(scope, &endpoint.path)]) +} + +/// Matches the request path against an endpoint path pattern, as +/// `openshell_core::endpoint_path::matches` does: empty, `**`, and `/**` +/// match every path, `P/**` matches `P` and below, and other patterns are +/// globs whose `*` also matches `/`. +fn endpoint_path_cond(scope: &mut Scope, path: &str) -> Cond { + if path.is_empty() || path == "**" || path == "/**" { + return Cond::TRUE; + } + if let Some(prefix) = path.strip_suffix("/**") { + // The prefix is literal: its `*` are escaped in the pattern. + let below = quote_literal_pattern(&format!("{prefix}/")); + let below = format!("{}*\"", below.strip_suffix('"').unwrap_or(&below)); + return Cond::expr(format!( + "(context.path == {} || context.path like {below})", + quote(prefix) + )); + } + if path.contains(['?', '[']) || path.contains("**") { + return scope.unsupported(format!( + "endpoint path {path:?}: only `*` wildcards and a `/**` suffix are supported" + )); + } + if path.contains('*') { + Cond::expr(format!("context.path like {}", quote(path))) + } else { + Cond::expr(format!("context.path == {}", quote(path))) + } +} + +/// A REST allow or deny rule: method, path, and query matchers. +fn rest_cond(scope: &mut Scope, rule: &RuleMatcher) -> Cond { + let method = rule + .method + .as_deref() + .map_or(Cond::Never, |method| method_cond(scope, method)); + let path = match rule.path.as_deref() { + None => Cond::Never, + Some("**") => Cond::TRUE, + Some(path) => scope.glob("context.path", path, '/'), + }; + let query: Vec = rule + .query + .iter() + .map(|(key, matcher)| { + scope.query_keys.insert(key.clone()); + let key = quote(key); + let value = scope.value(&format!("context.query.getTag({key})"), matcher); + Cond::all([Cond::expr(format!("context.query.hasTag({key})")), value]) + }) + .collect(); + Cond::all([method, path, Cond::all(query)]) +} + +/// Matches an HTTP method case-insensitively, `GET` also matching `HEAD`. +/// +/// A JSON-RPC call is evaluated with an empty method, and is always sent +/// with `POST`, so `POST` also matches an empty method. +fn method_cond(scope: &mut Scope, method: &str) -> Cond { + if method == "*" { + return Cond::TRUE; + } + if !method.is_ascii() { + return scope.unsupported("a non-ASCII method is uppercased differently"); + } + let method = method.to_ascii_uppercase(); + match method.as_str() { + "" => Cond::Never, + "GET" => Cond::expr(r#"["GET", "HEAD"].contains(context.method)"#), + "POST" => Cond::expr(r#"["POST", ""].contains(context.method)"#), + method => Cond::expr(format!("context.method == {}", quote(method))), + } +} + +/// A JSON-RPC call: the only evaluation with an empty method. +fn jsonrpc_call() -> Cond { + Cond::all([ + Cond::expr(r#"context.method == """#), + Cond::expr(r#"context.jsonrpc_method != """#), + ]) +} + +/// A JSON-RPC or MCP allow or deny rule. +fn jsonrpc_cond( + scope: &mut Scope, + endpoint: &ProviderEndpoint, + rule: &RuleMatcher, + allow: bool, +) -> Cond { + let mcp = endpoint.protocol == "mcp"; + let rule_method = rule.method.as_deref().unwrap_or_default(); + if rule_method.is_empty() { + return Cond::Never; + } + let method = if mcp { + scope.glob("context.jsonrpc_method", rule_method, '.') + } else if rule_method == "*" { + Cond::TRUE + } else { + Cond::expr(format!("context.jsonrpc_method == {}", quote(rule_method))) + }; + // Only MCP rules match params, and a call exposes only `params.name`, + // the tool of a `tools/call`. + let params: Vec = if mcp { + rule.params + .iter() + .map(|(key, matcher)| { + if key == "name" { + Cond::all([ + Cond::expr(r#"context.jsonrpc_method == "tools/call""#), + scope.value("context.mcp_tool", matcher), + ]) + } else { + Cond::Never + } + }) + .collect() + } else { + Vec::new() + }; + // An extension method needs an exact rule. + let class = if mcp && allow { + Cond::any([ + Cond::expr(r#"context.mcp_method_class == "available""#), + Cond::expr(format!( + r#"(context.mcp_method_class == "extension" && context.jsonrpc_method == {})"#, + quote(rule_method) + )), + ]) + } else { + Cond::TRUE + }; + let response = if allow { + Cond::expr("!context.jsonrpc_response") + } else { + Cond::TRUE + }; + Cond::all([jsonrpc_call(), response, method, Cond::all(params), class]) +} + +/// A GraphQL allow or deny rule, for one operation. +fn graphql_cond(scope: &mut Scope, rule: &RuleMatcher, allow: bool) -> Cond { + let operation_type = match rule.operation_type.as_deref() { + None => return Cond::Never, + Some("*") => Cond::expr(r#"context.graphql_operation_type != """#), + Some(kind) if !kind.is_ascii() => { + return scope.unsupported("a non-ASCII operation type is lowercased differently"); + } + Some(kind) => { + let kind = kind.to_ascii_lowercase(); + if kind.is_empty() { + return Cond::Never; + } + Cond::expr(format!( + "context.graphql_operation_type == {}", + quote(&kind) + )) + } + }; + let name = match rule.operation_name.as_deref() { + None | Some("") => Cond::TRUE, + Some(name) => scope.glob("context.graphql_operation_name", name, '.'), + }; + Cond::all([ + operation_type, + name, + fields_cond(scope, &rule.fields, allow), + ]) +} + +/// Matches root fields: an allow rule needs every field to match a pattern, +/// a deny rule one. Only literal fields and `*` translate. +fn fields_cond(scope: &mut Scope, patterns: &[String], allow: bool) -> Cond { + if patterns.is_empty() { + return Cond::TRUE; + } + let any_field = Cond::expr("!context.graphql_fields.isEmpty()"); + // A GraphQL field name has no `.`, so a run of `*` matches every field. + if patterns + .iter() + .any(|p| !p.is_empty() && p.chars().all(|c| c == '*')) + { + return any_field; + } + if let Some(pattern) = patterns.iter().find(|p| { + p.chars() + .any(|c| matches!(c, '[' | ']' | '{' | '}' | '\\' | '\0')) + }) { + return scope.may_fail(format!("field glob {pattern:?} may be invalid")); + } + let literals: Vec = patterns + .iter() + .filter(|p| !p.contains(['*', '?'])) + .map(|p| quote(p)) + .collect(); + let globbed = literals.len() != patterns.len(); + let list = format!("[{}]", literals.join(", ")); + if allow { + if globbed { + scope + .reasons + .push("field globs need every field to match, which Cedar cannot test".into()); + } + if literals.is_empty() { + return Cond::Never; + } + Cond::all([ + any_field, + Cond::expr(format!("{list}.containsAll(context.graphql_fields)")), + ]) + } else if globbed { + scope.reasons.push("field globs cannot be tested".into()); + any_field + } else { + Cond::expr(format!("context.graphql_fields.containsAny({list})")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HOST: &str = "api.example.com"; + const CURL: &str = "/usr/bin/curl"; + + fn rule(method: &str, path: &str) -> RuleMatcher { + RuleMatcher { + method: Some(method.to_string()), + path: Some(path.to_string()), + ..Default::default() + } + } + + fn endpoint(owner: &str, protocol: &str, rules: Vec) -> ProviderEndpoint { + ProviderEndpoint { + host: HOST.to_string(), + ports: vec![443], + protocol: protocol.to_string(), + token_grant_owner: owner.to_string(), + rules, + ..Default::default() + } + } + + fn policy(name: &str, endpoints: Vec) -> ProviderPolicy { + ProviderPolicy { + name: name.to_string(), + binaries: vec![CURL.to_string()], + endpoints, + } + } + + fn rest(method: &str, path: &str) -> HttpRequestInput { + HttpRequestInput { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + binary_path: CURL.to_string(), + host: HOST.to_string(), + port: 443, + method: method.to_string(), + path: path.to_string(), + ..Default::default() + } + } + + fn with_query(mut request: HttpRequestInput, key: &str, values: &[&str]) -> HttpRequestInput { + request.query.insert( + key.to_string(), + values.iter().map(ToString::to_string).collect(), + ); + request + } + + fn compile(policies: &[ProviderPolicy]) -> TokenGrantRules { + TokenGrantRules::compile(policies).unwrap_or_else(|e| panic!("compiles: {e}")) + } + + fn owners(rules: &TokenGrantRules, requests: &[HttpRequestInput]) -> Vec { + rules + .admitted_owners(requests) + .expect("evaluates") + .into_iter() + .collect() + } + + fn single(path_rule: RuleMatcher) -> TokenGrantRules { + compile(&[policy("p", vec![endpoint("o", "rest", vec![path_rule])])]) + } + + #[test] + fn get_also_admits_head_and_methods_ignore_case() { + let rules = single(rule("get", "**")); + assert_eq!(owners(&rules, &[rest("GET", "/x")]), ["o"]); + assert_eq!(owners(&rules, &[rest("HEAD", "/x")]), ["o"]); + assert!(owners(&rules, &[rest("POST", "/x")]).is_empty()); + let rules = single(rule("POST", "**")); + // A JSON-RPC call carries an empty method and was sent with POST. + assert_eq!(owners(&rules, &[rest("", "/x")]), ["o"]); + assert!(owners(&rules, &[rest("HEAD", "/x")]).is_empty()); + let rules = single(rule("*", "**")); + assert_eq!(owners(&rules, &[rest("DELETE", "/x")]), ["o"]); + } + + #[test] + fn path_globs_follow_rego_glob_semantics() { + let cases: &[(&str, &[(&str, bool)])] = &[ + ("/a/*", &[("/a/b", true), ("/a/b/c", false), ("/a/", true)]), + ("/a/**", &[("/a/b/c", true), ("/a", false), ("/a/", true)]), + ("/a**", &[("/abc", true), ("/a/b", false)]), + ("/a/**b", &[("/a/xb", true), ("/a/x/b", false)]), + ( + "/**/git-upload-pack", + &[ + ("/git-upload-pack", true), + ("/o/r/git-upload-pack", true), + ("/o/r/git-receive-pack", false), + ], + ), + ( + "/a/**/b", + &[("/a/b", true), ("/a/x/y/b", true), ("/ab", false)], + ), + ("**/b", &[("b", true), ("/b", true), ("/x/b", true)]), + ("**", &[("/any/thing", true)]), + ]; + for (pattern, paths) in cases { + let rules = single(rule("GET", pattern)); + for (path, expected) in *paths { + assert_eq!( + !owners(&rules, &[rest("GET", path)]).is_empty(), + *expected, + "{pattern} vs {path}" + ); + } + } + } + + #[test] + fn query_allow_needs_every_value_and_any_lists_alternatives() { + let mut allow = rule("GET", "**"); + allow.query.insert( + "env".to_string(), + ValueMatcher { + globs: vec!["prod-*".to_string(), "staging".to_string()], + }, + ); + let rules = single(allow); + let get = || rest("GET", "/x"); + assert_eq!( + owners(&rules, &[with_query(get(), "env", &["prod-a"])]), + ["o"] + ); + assert_eq!( + owners(&rules, &[with_query(get(), "env", &["prod-a", "staging"])]), + ["o"] + ); + assert!(owners(&rules, &[with_query(get(), "env", &["prod-a", "dev"])]).is_empty()); + assert!(owners(&rules, &[get()]).is_empty(), "missing key"); + // The `.` delimiter keeps `*` within one label. + assert!(owners(&rules, &[with_query(get(), "env", &["prod-a.b"])]).is_empty()); + } + + #[test] + fn deny_rules_are_global_and_fire_on_any_value() { + let mut deny = rule("GET", "**"); + deny.query.insert( + "force".to_string(), + ValueMatcher { + globs: vec!["true".to_string()], + }, + ); + let mut denying = endpoint("", "rest", Vec::new()); + denying.deny_rules = vec![deny]; + let rules = compile(&[ + policy( + "owned", + vec![endpoint("o", "rest", vec![rule("GET", "**")])], + ), + policy("other", vec![denying]), + ]); + let get = || rest("GET", "/x"); + assert_eq!(owners(&rules, &[get()]), ["o"], "missing key never denies"); + assert!(owners(&rules, &[with_query(get(), "force", &["no", "true"])]).is_empty()); + assert_eq!( + owners(&rules, &[with_query(get(), "force", &["no"])]), + ["o"] + ); + } + + #[test] + fn owners_need_their_own_endpoint_and_binary() { + let mut other = endpoint("narrow", "rest", vec![rule("POST", "/a/**")]); + other.path = "/a/**".to_string(); + let rules = compile(&[ + policy( + "p", + vec![endpoint("broad", "rest", vec![rule("GET", "**")]), other], + ), + ProviderPolicy { + name: "glob".to_string(), + binaries: vec!["/opt/tools/*".to_string()], + endpoints: vec![endpoint("tool", "rest", vec![rule("GET", "**")])], + }, + ]); + assert_eq!(owners(&rules, &[rest("GET", "/a/x")]), ["broad"]); + assert_eq!(owners(&rules, &[rest("POST", "/a/x")]), ["narrow"]); + assert!( + owners(&rules, &[rest("POST", "/b")]).is_empty(), + "endpoint path" + ); + let mut ancestor = rest("GET", "/x"); + ancestor.binary_path = "/usr/bin/other".to_string(); + ancestor.ancestors = vec![CURL.to_string(), "/opt/tools/run".to_string()]; + // An exact binary matches an ancestor, and so does a glob: the + // supervisor lists the patterns that match the binary or an ancestor + // in `binary_globs`. + assert_eq!(owners(&rules, &[ancestor.clone()]), ["broad"]); + ancestor.binary_globs = vec!["/opt/tools/*".to_string()]; + assert_eq!(owners(&rules, &[ancestor]), ["broad", "tool"]); + assert_eq!( + rules.binary_glob_patterns().collect::>(), + ["/opt/tools/*"] + ); + assert!( + rules + .untranslated() + .iter() + .all(|note| note.location != RuleLocation::Binaries), + "{:?}", + rules.untranslated() + ); + let mut tool = rest("GET", "/x"); + tool.binary_path = "/opt/tools/run".to_string(); + assert!( + owners(&rules, &[tool.clone()]).is_empty(), + "no pattern matched" + ); + tool.binary_globs = vec!["/opt/tools/*".to_string()]; + assert_eq!(owners(&rules, &[tool]), ["tool"]); + // A glob Rego could fail to compile admits no owner at all. + let failing = compile(&[ProviderPolicy { + name: "glob".to_string(), + binaries: vec!["/opt/{a,b}/*".to_string()], + endpoints: vec![endpoint("tool", "rest", vec![rule("GET", "**")])], + }]); + assert!(failing.fail_closed_reason().is_some()); + assert_eq!(failing.binary_glob_patterns().count(), 0); + let mut other_host = rest("GET", "/x"); + other_host.host = "other.example.com".to_string(); + assert!(owners(&rules, &[other_host]).is_empty()); + } + + #[test] + fn host_globs_and_hostless_endpoints() { + let mut glob = endpoint("glob", "rest", vec![rule("GET", "**")]); + glob.host = "*.Example.com".to_string(); + let mut hostless = endpoint("hostless", "rest", vec![rule("GET", "**")]); + hostless.host = String::new(); + hostless.has_allowed_ips = true; + hostless.ports = vec![8443]; + let rules = compile(&[policy("p", vec![glob, hostless])]); + assert_eq!(owners(&rules, &[rest("GET", "/")]), ["glob"]); + let mut deep = rest("GET", "/"); + deep.host = "a.b.example.com".to_string(); + assert!(owners(&rules, &[deep]).is_empty()); + let mut anywhere = rest("GET", "/"); + anywhere.host = "else.test".to_string(); + anywhere.port = 8443; + assert_eq!(owners(&rules, &[anywhere]), ["hostless"]); + let mut dotted = rest("GET", "/"); + dotted.host = format!("{HOST}."); + assert!(owners(&rules, &[dotted]).is_empty(), "trailing dot"); + } + + #[test] + fn untranslatable_allow_is_dropped_and_deny_is_widened() { + let rules = single(rule("GET", "/a/?")); + assert!(owners(&rules, &[rest("GET", "/a/b")]).is_empty()); + assert_eq!(rules.untranslated()[0].handling, Handling::PermitDropped); + + let mut denying = endpoint("o", "rest", vec![rule("GET", "**")]); + denying.deny_rules = vec![rule("GET", "/secret/?")]; + let rules = compile(&[policy("p", vec![denying])]); + // The deny ignores its path, so it denies every GET. + assert!(owners(&rules, &[rest("GET", "/public")]).is_empty()); + assert_eq!(rules.untranslated()[0].handling, Handling::ForbidWidened); + + let rules = single(rule("GET", "/a/[bc]")); + assert!(rules.fail_closed_reason().is_some()); + assert!(owners(&rules, &[rest("GET", "/x")]).is_empty()); + } + + fn call(method: &str, tool: &str, class: &str) -> HttpRequestInput { + HttpRequestInput { + method: String::new(), + jsonrpc_method: method.to_string(), + mcp_tool: tool.to_string(), + mcp_method_class: class.to_string(), + ..rest("POST", "/mcp") + } + } + + #[test] + fn mcp_rules_match_methods_tools_and_protocol_frames() { + let mut tool_rule = RuleMatcher { + method: Some("tools/call".to_string()), + ..Default::default() + }; + tool_rule.params.insert( + "name".to_string(), + ValueMatcher { + globs: vec!["github.*".to_string()], + }, + ); + let glob_rule = RuleMatcher { + method: Some("resources/*".to_string()), + ..Default::default() + }; + let extension = RuleMatcher { + method: Some("vendor/inspect".to_string()), + ..Default::default() + }; + let mut mcp = endpoint("o", "mcp", vec![tool_rule, glob_rule, extension]); + mcp.path = "/mcp".to_string(); + let rules = compile(&[policy("p", vec![mcp.clone()])]); + let available = "available"; + assert_eq!( + owners(&rules, &[call("tools/call", "github.read", available)]), + ["o"] + ); + assert!(owners(&rules, &[call("tools/call", "other", available)]).is_empty()); + assert_eq!( + owners(&rules, &[call("resources/read", "", available)]), + ["o"] + ); + assert!(owners(&rules, &[call("resources/x", "", "extension")]).is_empty()); + assert_eq!( + owners(&rules, &[call("vendor/inspect", "", "extension")]), + ["o"] + ); + let response = HttpRequestInput { + jsonrpc_response: true, + ..rest("POST", "/mcp") + }; + assert_eq!(owners(&rules, &[response]), ["o"]); + let stream = HttpRequestInput { + jsonrpc_receive_stream: true, + ..rest("GET", "/mcp") + }; + assert_eq!(owners(&rules, &[stream]), ["o"]); + + mcp.mcp_allow_all_known_methods = true; + let rules = compile(&[policy("p", vec![mcp])]); + assert_eq!( + owners(&rules, &[call("prompts/list", "", available)]), + ["o"] + ); + // Tool rules still narrow `tools/call`. + assert!(owners(&rules, &[call("tools/call", "other", available)]).is_empty()); + } + + #[test] + fn jsonrpc_endpoints_deny_response_frames() { + let rpc = endpoint( + "o", + "json-rpc", + vec![RuleMatcher { + method: Some("*".to_string()), + ..Default::default() + }], + ); + let rest_owner = endpoint("r", "rest", vec![rule("POST", "**")]); + let rules = compile(&[policy("p", vec![rpc, rest_owner])]); + assert_eq!(owners(&rules, &[call("anything", "", "")]), ["o", "r"]); + let response = HttpRequestInput { + jsonrpc_response: true, + ..rest("POST", "/rpc") + }; + assert!(owners(&rules, &[response]).is_empty()); + } + + fn operation(kind: &str, name: &str, fields: &[&str]) -> HttpRequestInput { + HttpRequestInput { + graphql_operation_type: kind.to_string(), + graphql_operation_name: name.to_string(), + graphql_fields: fields.iter().map(ToString::to_string).collect(), + ..rest("POST", "/graphql") + } + } + + #[test] + fn graphql_rules_match_operations_and_deny_unlisted_ones() { + let query = RuleMatcher { + operation_type: Some("Query".to_string()), + ..Default::default() + }; + let named = RuleMatcher { + operation_type: Some("mutation".to_string()), + operation_name: Some("Create*".to_string()), + fields: vec!["createIssue".to_string()], + ..Default::default() + }; + let globbed = RuleMatcher { + operation_type: Some("mutation".to_string()), + fields: vec!["delete*".to_string()], + ..Default::default() + }; + let mut graphql = endpoint("g", "graphql", vec![query, named, globbed]); + graphql.path = "/graphql".to_string(); + let rest_owner = endpoint("r", "rest", vec![rule("POST", "**")]); + let rules = compile(&[policy("p", vec![graphql, rest_owner])]); + assert_eq!( + owners(&rules, &[operation("query", "", &["viewer"])]), + ["g", "r"] + ); + assert_eq!( + owners( + &rules, + &[operation("mutation", "CreateOne", &["createIssue"])] + ), + ["g", "r"] + ); + // An operation the GraphQL endpoint does not allow denies every + // owner, including the REST one. + assert!(owners(&rules, &[operation("mutation", "Other", &["createIssue"])]).is_empty()); + assert!( + owners( + &rules, + &[operation("mutation", "CreateOne", &["createIssue", "x"])] + ) + .is_empty() + ); + // Field globs are dropped from the allow rule, so the deny widens. + assert!(owners(&rules, &[operation("mutation", "", &["deleteRepo"])]).is_empty()); + // Every operation must be admitted by the same endpoint. + assert!( + owners( + &rules, + &[ + operation("query", "", &["viewer"]), + operation("subscription", "", &["x"]) + ] + ) + .is_empty() + ); + assert_eq!( + owners( + &rules, + &[rest("POST", "/graphql"), operation("query", "", &["a"])] + ), + ["r"] + ); + } + + #[test] + fn generated_policies_are_readable_and_annotated() { + let rules = single(rule("GET", "/repos/**")); + let text = rules.policy_text(); + assert!(text.contains(r#"@id("p/endpoints[0]/rules[0]")"#), "{text}"); + assert!(text.contains(r#"@token_grant_owner("o")"#), "{text}"); + assert!( + text.contains(r#"context.path like("/repos/**", "/")"#), + "{text}" + ); + } + + #[test] + fn glob_translation() { + assert_eq!( + translate_glob("/a/**/b/**/c", '/'), + Glob::Patterns(vec![ + "/a/**/b/**/c".to_string(), + "/a/**/b/c".to_string(), + "/a/b/**/c".to_string(), + "/a/b/c".to_string(), + ]) + ); + assert_eq!( + translate_glob("**.example.com", '.'), + Glob::Patterns(vec!["**.example.com".to_string()]) + ); + assert_eq!( + translate_glob("a**b", '.'), + Glob::Patterns(vec!["a*b".to_string()]) + ); + assert!(matches!(translate_glob("a?", '.'), Glob::Unsupported(_))); + assert!(matches!(translate_glob("{a,b}", '.'), Glob::MayFail(_))); + } + + #[test] + fn no_owner_without_stamped_endpoints() { + let rules = compile(&[policy( + "p", + vec![endpoint("", "rest", vec![rule("GET", "**")])], + )]); + assert!(rules.policy_text().is_empty()); + assert!(owners(&rules, &[rest("GET", "/")]).is_empty()); + } +} diff --git a/crates/openshell-policy-cedar/tests/audit.rs b/crates/openshell-policy-cedar/tests/audit.rs new file mode 100644 index 0000000000..5340b26a55 --- /dev/null +++ b/crates/openshell-policy-cedar/tests/audit.rs @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks for `@enforcement("audit")` and `@enforcement("staged")` on +//! `HttpRequest` policies: an endpoint whose policies are all audit is an +//! audit endpoint, audit policies decide requests like enforced ones, and +//! staged policies never change a path's mode or a decision. + +use openshell_policy_cedar::{ + CedarEngine, CedarEngineError, Decision, HttpRequestInput, L7Enforcement, L7Protocol, +}; + +const ENDPOINT: &str = r#"Sandbox::NetworkEndpoint::"api.github.com:443""#; + +fn engine(policy: &str) -> CedarEngine { + CedarEngine::from_policy_str(policy).expect("policy must load") +} + +/// An `HttpRequest` policy on the test endpoint. +fn http_policy(annotation: &str, effect: &str, condition: &str) -> String { + format!( + r#"{annotation} +{effect} ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == {ENDPOINT} +) +when {{ {condition} }}; +"# + ) +} + +fn request(method: &str, path: &str) -> HttpRequestInput { + HttpRequestInput { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + binary_path: "/usr/bin/curl".to_string(), + ancestors: Vec::new(), + host: "api.github.com".to_string(), + port: 443, + method: method.to_string(), + path: path.to_string(), + command: String::new(), + jsonrpc_method: String::new(), + ..Default::default() + } +} + +const AUDIT: &str = r#"@enforcement("audit")"#; +const STAGED: &str = r#"@enforcement("staged")"#; +const GET_REPOS: &str = r#"context.method == "GET" && context.path like("/repos/**", "/")"#; + +#[test] +fn an_endpoint_whose_policies_are_all_audit_only_is_an_audit_endpoint() { + let engine = engine(&http_policy(AUDIT, "permit", GET_REPOS)); + let endpoint = engine + .l7_endpoint("api.github.com", 443) + .expect("endpoint is inspected"); + assert_eq!(endpoint.enforcement, L7Enforcement::Audit); + assert_eq!(endpoint.protocol, L7Protocol::Rest); + + // The audit endpoint's own policies decide, so the relay can log the deny. + let evaluation = engine + .evaluate_l7(&request("DELETE", "/repos/a/b")) + .unwrap(); + assert!(!evaluation.is_allow()); + assert_eq!(evaluation.staged, None); + assert!( + engine + .evaluate_l7(&request("GET", "/repos/a/b")) + .unwrap() + .is_allow() + ); +} + +#[test] +fn enforced_and_explicitly_enforced_endpoints_are_enforced() { + for annotation in ["", r#"@enforcement("enforce")"#] { + let engine = engine(&http_policy(annotation, "permit", GET_REPOS)); + assert_eq!( + engine + .l7_endpoint("api.github.com", 443) + .unwrap() + .enforcement, + L7Enforcement::Enforce, + "{annotation:?}" + ); + } +} + +#[test] +fn audit_policies_decide_requests_on_an_enforced_path() { + // As in YAML, where the rules of every matching endpoint apply and the + // selected endpoint's enforcement decides whether a denial blocks: an + // audit forbid denies, and an audit permit allows, on the enforced path. + let policy = format!( + "{}{}{}", + http_policy("", "permit", GET_REPOS), + http_policy( + &format!(r#"{AUDIT} @id("no-hooks")"#), + "forbid", + r#"context.path like("/repos/*/*/hooks", "/")"# + ), + http_policy(AUDIT, "permit", r#"context.method == "POST""#), + ); + let engine = engine(&policy); + assert_eq!( + engine + .l7_endpoint("api.github.com", 443) + .unwrap() + .enforcement, + L7Enforcement::Enforce, + "an enforced policy keeps the path enforced" + ); + let hooks = engine + .evaluate_l7(&request("GET", "/repos/nvidia/openshell/hooks")) + .unwrap(); + assert!( + matches!(&hooks.decision, Decision::Deny { matched_policies } if matched_policies == &["no-hooks"]), + "{hooks:?}" + ); + assert_eq!(hooks.staged, None, "nothing is staged"); + assert!( + engine + .evaluate_l7(&request("POST", "/repos/nvidia/openshell/issues")) + .unwrap() + .is_allow() + ); +} + +#[test] +fn staged_forbid_logs_without_denying() { + let policy = format!( + "{}{}", + http_policy("", "permit", GET_REPOS), + http_policy( + &format!(r#"{STAGED} @id("no-hooks")"#), + "forbid", + r#"context.path like("/repos/*/*/hooks", "/")"# + ), + ); + let engine = engine(&policy); + assert_eq!( + engine + .l7_endpoint("api.github.com", 443) + .unwrap() + .enforcement, + L7Enforcement::Enforce + ); + + let hooks = engine + .evaluate_l7(&request("GET", "/repos/nvidia/openshell/hooks")) + .unwrap(); + assert!(hooks.is_allow(), "a staged forbid never denies"); + assert!( + matches!(&hooks.staged, Some(Decision::Deny { matched_policies }) if matched_policies == &["no-hooks"]), + "{hooks:?}" + ); +} + +#[test] +fn staged_forbid_reports_nothing_when_it_does_not_match() { + // Cedar denies by default, so evaluating the staged policies on their + // own would deny this clean request. Only a changed decision is staged. + let policy = format!( + "{}{}", + http_policy("", "permit", GET_REPOS), + http_policy( + STAGED, + "forbid", + r#"context.path like("/repos/*/*/hooks", "/")"# + ), + ); + let evaluation = engine(&policy) + .evaluate_l7(&request("GET", "/repos/nvidia/openshell")) + .unwrap(); + assert!(evaluation.is_allow()); + assert_eq!(evaluation.staged, None); +} + +#[test] +fn staged_permit_logs_without_allowing() { + let policy = format!( + "{}{}", + http_policy("", "permit", GET_REPOS), + http_policy(STAGED, "permit", r#"context.method == "POST""#), + ); + let evaluation = engine(&policy) + .evaluate_l7(&request("POST", "/repos/nvidia/openshell/issues")) + .unwrap(); + assert!(!evaluation.is_allow(), "a staged permit never allows"); + assert!( + matches!(evaluation.staged, Some(Decision::Allow { .. })), + "{evaluation:?}" + ); +} + +#[test] +fn staged_policies_never_change_a_paths_mode() { + // A path declared only by a staged policy is enforced, and a staged + // policy beside audit policies leaves the path in audit mode. + let staged_only = format!( + "{}{}", + http_policy("", "permit", GET_REPOS), + http_policy( + &format!(r#"{STAGED} @path("/admin/**")"#), + "forbid", + r#"context.path like("/admin/**", "/")"# + ), + ); + let staged_only = engine(&staged_only); + assert_eq!( + staged_only + .l7_endpoint_at("api.github.com", 443, "/admin/**") + .unwrap() + .enforcement, + L7Enforcement::Enforce + ); + assert_eq!(staged_only.warnings(), []); + let mut admin = request("GET", "/admin/users"); + admin.endpoint_path = "/admin/**".to_string(); + let evaluation = staged_only.evaluate_l7(&admin).unwrap(); + assert!(!evaluation.is_allow(), "the enforced permit does not match"); + assert_eq!( + evaluation.staged, None, + "the staged forbid would not change a denial" + ); + + let audited = format!( + "{}{}", + http_policy(AUDIT, "permit", GET_REPOS), + http_policy(STAGED, "forbid", r#"context.method == "GET""#), + ); + let audited = engine(&audited); + assert_eq!( + audited + .l7_endpoint("api.github.com", 443) + .unwrap() + .enforcement, + L7Enforcement::Audit + ); + let evaluation = audited.evaluate_l7(&request("GET", "/repos/a/b")).unwrap(); + assert!(evaluation.is_allow(), "the audit path's own decision"); + assert!( + matches!(evaluation.staged, Some(Decision::Deny { .. })), + "{evaluation:?}" + ); +} + +#[test] +fn audit_and_staged_policies_never_affect_network_connect() { + for annotation in [AUDIT, STAGED] { + let policy = format!( + r#"permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == {ENDPOINT} +); +{}"#, + http_policy(annotation, "forbid", "true") + ); + let decision = engine(&policy) + .evaluate_network(&openshell_policy_cedar::NetworkConnectRequest { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + host: "api.github.com".to_string(), + port: 443, + binary_path: "/usr/bin/curl".to_string(), + ancestors: Vec::new(), + binary_aliases: Vec::new(), + binary_globs: Vec::new(), + destination_ip: None, + }) + .unwrap(); + assert!(decision.is_allow(), "{annotation}"); + } +} + +#[test] +fn rejects_unknown_enforcement_values() { + let error = CedarEngine::from_policy_str(&http_policy( + r#"@enforcement("observe")"#, + "permit", + GET_REPOS, + )) + .expect_err("unknown value"); + assert!( + matches!(error, CedarEngineError::UnsupportedEnforcement { .. }), + "{error}" + ); +} + +#[test] +fn rejects_enforcement_outside_http_request_only_policies() { + for policy in [ + format!( + r#"@enforcement("audit") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource == {ENDPOINT});"# + ), + format!( + r#"@enforcement("staged") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource == {ENDPOINT});"# + ), + format!( + r#"@enforcement("audit") +permit ( + principal, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == {ENDPOINT} +);"# + ), + ] { + let error = CedarEngine::from_policy_str(&policy).expect_err("rejected"); + assert!( + matches!(error, CedarEngineError::UnsupportedPolicy { .. }), + "{error}" + ); + } +} diff --git a/crates/openshell-policy-cedar/tests/binary_aliases.rs b/crates/openshell-policy-cedar/tests/binary_aliases.rs new file mode 100644 index 0000000000..b99b43d62f --- /dev/null +++ b/crates/openshell-policy-cedar/tests/binary_aliases.rs @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks `context.binary_aliases`: which binary paths a policy set asks the +//! supervisor to resolve, the forms it accepts, and how the set decides +//! requests on both network actions. + +use openshell_policy_cedar::{ + CedarEngine, CedarEngineError, HttpRequestInput, NetworkConnectRequest, +}; + +fn load(policy: &str) -> Result { + CedarEngine::from_policy_str(policy) +} + +fn connect(binary: &str, ancestors: &[&str], aliases: &[&str]) -> NetworkConnectRequest { + NetworkConnectRequest { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + host: "pypi.org".to_string(), + port: 443, + binary_path: binary.to_string(), + ancestors: ancestors.iter().map(ToString::to_string).collect(), + binary_aliases: aliases.iter().map(ToString::to_string).collect(), + binary_globs: Vec::new(), + destination_ip: None, + } +} + +/// The Cedar form of a YAML binary entry for `/usr/bin/python3`. +const PYTHON: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443") +when { + context.binary_path == "/usr/bin/python3" + || context.ancestors.contains("/usr/bin/python3") + || context.binary_aliases.contains("/usr/bin/python3") +}; +"#; + +#[test] +fn alias_paths_are_collected_from_every_accepted_form() { + let policy = format!( + r#"{PYTHON} +forbid (principal, action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"pypi.org:443") +when {{ + context.binary_aliases.containsAny(["/usr/bin/node", "/usr/bin/python3"]) + || context.binary_aliases.containsAll(["/usr/local/bin/tool"]) +}}; +"# + ); + let engine = load(&policy).expect("policy loads"); + assert_eq!( + engine.binary_alias_paths().collect::>(), + ["/usr/bin/node", "/usr/bin/python3", "/usr/local/bin/tool"] + ); + assert_eq!( + load( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { context.binary_path == "/x" };"# + ) + .expect("policy loads") + .binary_alias_paths() + .count(), + 0 + ); +} + +#[test] +fn unsupported_alias_forms_are_rejected() { + for condition in [ + "context.binary_aliases.isEmpty()", + r#"context.binary_aliases == ["/usr/bin/python3"]"#, + "context.binary_aliases.contains(context.binary_path)", + r"context.binary_aliases.containsAny(context.ancestors)", + r#"context.binary_aliases.contains("python3")"#, + r#"context.binary_aliases.contains("/usr/bin/*")"#, + r#"context.binary_aliases.containsAny(["/usr/bin/python3", "/opt/*/python"])"#, + r"context == context", + ] { + let policy = format!( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ {condition} }};"# + ); + let error = load(&policy).expect_err(condition); + assert!( + matches!(error, CedarEngineError::InvalidBinaryAlias { .. }), + "{condition}: {error}" + ); + } +} + +#[test] +fn reading_other_context_fields_needs_no_alias_paths() { + let engine = load( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { context has binary_aliases && context.ancestors.contains("/bin/bash") };"#, + ) + .expect("policy loads"); + assert_eq!(engine.binary_alias_paths().count(), 0); +} + +#[test] +fn an_alias_matches_a_connection_like_the_symlinked_path() { + let engine = load(PYTHON).expect("policy loads"); + let allows = |request: NetworkConnectRequest| { + engine + .evaluate_network(&request) + .expect("evaluates") + .is_allow() + }; + assert!(allows(connect("/usr/bin/python3", &[], &[]))); + assert!(allows(connect( + "/usr/bin/python3.11", + &[], + &["/usr/bin/python3"] + ))); + assert!(allows(connect( + "/usr/bin/curl", + &["/usr/bin/python3.11"], + &["/usr/bin/python3"] + ))); + assert!(!allows(connect("/usr/bin/python3.11", &[], &[]))); +} + +#[test] +fn an_alias_reaches_a_forbid_on_requests() { + let engine = load( + r#" +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"pypi.org:443"); +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"pypi.org:443") +when { context.binary_aliases.contains("/usr/bin/python3") }; +"#, + ) + .expect("policy loads"); + let allows = |aliases: &[&str]| { + engine + .evaluate_l7(&HttpRequestInput { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + binary_path: "/usr/bin/python3.11".to_string(), + binary_aliases: aliases.iter().map(ToString::to_string).collect(), + binary_globs: Vec::new(), + host: "pypi.org".to_string(), + port: 443, + method: "GET".to_string(), + path: "/simple/".to_string(), + ..Default::default() + }) + .expect("evaluates") + .is_allow() + }; + assert!(allows(&[])); + assert!(!allows(&["/usr/bin/python3"])); +} diff --git a/crates/openshell-policy-cedar/tests/binary_globs.rs b/crates/openshell-policy-cedar/tests/binary_globs.rs new file mode 100644 index 0000000000..cb812c05be --- /dev/null +++ b/crates/openshell-policy-cedar/tests/binary_globs.rs @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks `context.binary_globs`: which binary glob patterns a policy set +//! asks the supervisor to match, the forms it accepts, and how the set +//! decides requests on both network actions. + +use openshell_policy_cedar::{ + CedarEngine, CedarEngineError, HttpRequestInput, NetworkConnectRequest, +}; + +fn load(policy: &str) -> Result { + CedarEngine::from_policy_str(policy) +} + +fn connect(binary: &str, globs: &[&str]) -> NetworkConnectRequest { + NetworkConnectRequest { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + host: "api.openai.com".to_string(), + port: 443, + binary_path: binary.to_string(), + ancestors: Vec::new(), + binary_aliases: Vec::new(), + binary_globs: globs.iter().map(ToString::to_string).collect(), + destination_ip: None, + } +} + +/// The Cedar form of the YAML binary glob `/usr/lib/node_modules/@openai/**`. +const CODEX: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.openai.com:443") +when { context.binary_globs.contains("/usr/lib/node_modules/@openai/**") }; +"#; + +#[test] +fn glob_patterns_are_collected_from_every_accepted_form() { + let policy = format!( + r#"{CODEX} +forbid (principal, action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"api.openai.com:443") +when {{ + context.binary_globs.containsAny(["/opt/*/bin/tool", "/usr/lib/node_modules/@openai/**"]) + || context.binary_globs.containsAll(["/tmp/*"]) +}}; +"# + ); + let engine = load(&policy).expect("policy loads"); + assert_eq!( + engine.binary_glob_patterns().collect::>(), + [ + "/opt/*/bin/tool", + "/tmp/*", + "/usr/lib/node_modules/@openai/**" + ] + ); + assert_eq!( + load( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { context.binary_path == "/x" };"# + ) + .expect("policy loads") + .binary_glob_patterns() + .count(), + 0 + ); +} + +#[test] +fn unsupported_glob_forms_are_rejected() { + let policy = |condition: &str| { + format!( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ {condition} }};"# + ) + }; + for condition in [ + // Not a glob: an exact path belongs in binary_path and binary_aliases. + r#"context.binary_globs.contains("/usr/bin/curl")"#, + // Not absolute. + r#"context.binary_globs.contains("node_modules/**")"#, + // Not a valid glob. + r#"context.binary_globs.contains("/opt/[*/tool")"#, + // Computed and non-literal elements. + r"context.binary_globs.contains(context.binary_path)", + r"context.binary_globs.containsAny([context.binary_path])", + r"context.binary_globs.containsAll(context.ancestors)", + // Other uses of the set. + r"context.binary_globs.isEmpty()", + r#"context.binary_globs == ["/opt/*"]"#, + ] { + let error = load(&policy(condition)).expect_err(condition); + assert!( + matches!(error, CedarEngineError::InvalidBinaryGlob { .. }), + "{condition}: {error}" + ); + } + // The whole context record includes both binary sets; the alias check + // reports it first. + let error = load(&policy("context == context")).expect_err("whole context"); + assert!( + matches!(error, CedarEngineError::InvalidBinaryAlias { .. }), + "{error}" + ); +} + +#[test] +fn a_glob_matches_a_connection_like_the_yaml_binary_glob() { + // The supervisor lists the patterns matching the binary or an ancestor; + // Cedar only tests membership, so a matched pattern allows whichever + // process it matched through. + let engine = load(CODEX).expect("policy loads"); + let allows = |request| engine.evaluate_network(&request).unwrap().is_allow(); + assert!(allows(connect( + "/usr/lib/node_modules/@openai/codex/bin/codex.js", + &["/usr/lib/node_modules/@openai/**"] + ))); + assert!(allows(connect( + "/usr/bin/node", + &["/usr/lib/node_modules/@openai/**"] + ))); + assert!(!allows(connect("/usr/bin/node", &[]))); + assert!(!allows(connect( + "/usr/bin/node", + &["/usr/lib/node_modules/@other/**"] + ))); +} + +#[test] +fn a_glob_reaches_a_forbid_on_requests() { + let policy = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.openai.com:443"); +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.openai.com:443"); +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.openai.com:443") +when { context.binary_globs.contains("/tmp/**") && context.method == "DELETE" }; +"#; + let engine = load(policy).expect("policy loads"); + let allows = |globs: &[&str]| { + engine + .evaluate_l7(&HttpRequestInput { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + binary_path: "/usr/bin/curl".to_string(), + ancestors: vec!["/tmp/run.sh".to_string()], + binary_globs: globs.iter().map(ToString::to_string).collect(), + host: "api.openai.com".to_string(), + port: 443, + method: "DELETE".to_string(), + path: "/v1/files/1".to_string(), + ..Default::default() + }) + .unwrap() + .is_allow() + }; + assert!(allows(&[])); + assert!(!allows(&["/tmp/**"])); +} diff --git a/crates/openshell-policy-cedar/tests/delimited_like.rs b/crates/openshell-policy-cedar/tests/delimited_like.rs new file mode 100644 index 0000000000..9f09bc118c --- /dev/null +++ b/crates/openshell-policy-cedar/tests/delimited_like.rs @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks for delimited `like` (`expr like("pattern", "delim")`): Cedar +//! enforces segment-aware globs on hosts and paths, and host globs written +//! with a `.` delimiter make hosts eligible for policy DNS when they mean +//! the same thing as a YAML wildcard host. + +use openshell_policy_cedar::{ + AuthorizedNetworkEndpoint, CedarEngine, CedarEngineError, HttpRequestInput, + NetworkConnectRequest, NetworkTransport, +}; + +fn engine(policy: &str) -> CedarEngine { + CedarEngine::from_policy_str(policy).expect("policy must load") +} + +/// A `NetworkConnect` permit whose `when` clause is `condition`. +fn connect_policy(condition: &str) -> String { + format!( + r#"permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource is Sandbox::NetworkEndpoint +) +when {{ {condition} }};"# + ) +} + +fn dns_hosts(condition: &str) -> Vec { + engine(&connect_policy(condition)).dns_endpoints().to_vec() +} + +fn eligible(host: &str, ports: &[u16]) -> Vec { + vec![AuthorizedNetworkEndpoint { + host: host.to_string(), + ports: ports.to_vec(), + transport: NetworkTransport::Default, + allowed_ips: Vec::new(), + }] +} + +fn connect(host: &str) -> NetworkConnectRequest { + NetworkConnectRequest { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + host: host.to_string(), + port: 443, + binary_path: "/usr/bin/curl".to_string(), + ancestors: Vec::new(), + binary_aliases: Vec::new(), + binary_globs: Vec::new(), + destination_ip: None, + } +} + +#[test] +fn delimited_single_wildcard_matches_one_host_label() { + let engine = engine(&connect_policy( + r#"resource.host like("*.example.com", ".") && resource.port == 443"#, + )); + let allows = |host: &str| engine.evaluate_network(&connect(host)).unwrap().is_allow(); + assert!(allows("api.example.com")); + assert!(!allows("a.b.example.com")); + assert!(!allows("example.com")); +} + +#[test] +fn delimited_double_wildcard_matches_several_host_labels() { + let engine = engine(&connect_policy( + r#"resource.host like("**.example.com", ".") && resource.port == 443"#, + )); + let allows = |host: &str| engine.evaluate_network(&connect(host)).unwrap().is_allow(); + assert!(allows("api.example.com")); + assert!(allows("a.b.example.com")); + assert!(!allows("example.org")); +} + +#[test] +fn delimited_path_glob_stays_within_one_segment() { + let policy = r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +); +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.method == "GET" && context.path like("/repos/*/*", "/") }; +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.method == "GET" && context.path like("/orgs/**", "/") }; +"#; + let engine = engine(policy); + let allows = |path: &str| { + engine + .evaluate_l7(&HttpRequestInput { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + binary_path: "/usr/bin/curl".to_string(), + ancestors: Vec::new(), + host: "api.github.com".to_string(), + port: 443, + method: "GET".to_string(), + path: path.to_string(), + command: String::new(), + jsonrpc_method: String::new(), + ..Default::default() + }) + .unwrap() + .is_allow() + }; + assert!(allows("/repos/nvidia/openshell")); + assert!(!allows("/repos/nvidia/openshell/hooks")); + assert!(allows("/orgs/nvidia/members/someone")); +} + +#[test] +fn host_globs_with_a_port_are_eligible_for_policy_dns() { + for (pattern, glob) in [ + ("*.example.com", "*.example.com"), + ("**.example.com", "**.example.com"), + ("api-*.example.com", "api-*.example.com"), + ("api.*.example.com", "api.*.example.com"), + ] { + assert_eq!( + dns_hosts(&format!( + r#"resource.host like("{pattern}", ".") && resource.port == 443"# + )), + eligible(glob, &[443]), + "{pattern}" + ); + } +} + +#[test] +fn exact_hosts_in_conditions_are_eligible_for_policy_dns() { + assert_eq!( + dns_hosts(r#"443 == resource.port && resource.host == "pypi.org""#), + eligible("pypi.org", &[443]) + ); +} + +#[test] +fn eligibility_ignores_other_required_conditions() { + assert_eq!( + dns_hosts( + r#"resource.host like("*.example.com", ".") && resource.port == 443 + && context.binary_path == "/usr/bin/curl""# + ), + eligible("*.example.com", &[443]) + ); +} + +#[test] +fn host_constraints_that_policy_dns_cannot_match_exactly_are_not_eligible() { + for condition in [ + // Undelimited `*` crosses labels; policy DNS would match fewer hosts. + r#"resource.host like "*.example.com" && resource.port == 443"#, + // Another delimiter is not label-aware. + r#"resource.host like("*.example.com", "-") && resource.port == 443"#, + // A wildcard top-level domain, rejected for YAML hosts too. + r#"resource.host like("*.com", ".") && resource.port == 443"#, + r#"resource.host like("**.com", ".") && resource.port == 443"#, + // `**` spans labels anywhere but the whole first label. + r#"resource.host like("api.**.example.com", ".") && resource.port == 443"#, + // A partial wildcard outside the first label. + r#"resource.host like("api.v*.example.com", ".") && resource.port == 443"#, + // An escaped `*` is a literal star, which no host contains. + r#"resource.host like("\*.example.com", ".") && resource.port == 443"#, + // Requests carry lowercase hosts, so this never matches. + r#"resource.host like("*.Example.com", ".") && resource.port == 443"#, + r#"resource.host like("*.example.com.", ".") && resource.port == 443"#, + ] { + assert!(dns_hosts(condition).is_empty(), "{condition}"); + } +} + +#[test] +fn conditions_without_a_single_required_host_and_port_are_not_eligible() { + for condition in [ + r#"resource.host like("*.example.com", ".")"#, + "resource.port == 443", + r#"resource.host like("*.example.com", ".") || resource.port == 443"#, + r#"(resource.host == "a.example.com" || resource.host == "b.example.com") + && resource.port == 443"#, + r#"resource.host like("*.example.com", ".") && resource.host == "api.example.com" + && resource.port == 443"#, + r#"resource.host like("*.example.com", ".") && resource.port == 443 + && resource.port == 8443"#, + ] { + assert!(dns_hosts(condition).is_empty(), "{condition}"); + } +} + +#[test] +fn unless_and_forbid_grant_no_eligibility() { + let unless = r#"permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource is Sandbox::NetworkEndpoint +) +unless { resource.host like("*.example.com", ".") && resource.port == 443 };"#; + assert!(engine(unless).dns_endpoints().is_empty()); + + let forbid = + connect_policy(r#"resource.host like("*.example.com", ".") && resource.port == 443"#) + .replacen("permit", "forbid", 1); + assert!(engine(&forbid).dns_endpoints().is_empty()); +} + +#[test] +fn scope_endpoints_reject_wildcard_hosts() { + let policy = r#"permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"*.example.com:443" +);"#; + let error = + CedarEngine::from_policy_str(policy).expect_err("a wildcard scope host is rejected"); + assert!( + matches!(error, CedarEngineError::InvalidEndpoint { .. }), + "{error}" + ); +} + +#[test] +fn scope_and_condition_endpoints_merge_by_host() { + let policy = format!( + r#"permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:8443" +); +{}"#, + connect_policy(r#"resource.host == "api.example.com" && resource.port == 443"#) + ); + assert_eq!( + engine(&policy).dns_endpoints(), + eligible("api.example.com", &[443, 8443]).as_slice() + ); +} + +#[test] +fn rejects_a_recursive_wildcard_next_to_text_in_a_delimited_pattern() { + // YAML's glob matcher reads such a `**` as `*`, within one segment; + // Cedar's `**` crosses segments, so the two would not agree. + let http = |pattern: &str| { + format!( + r#"permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when {{ context.path like("{pattern}", "/") }};"# + ) + }; + for (policy, pattern) in [ + (http("/files/report**"), "/files/report**"), + (http("/files/**report"), "/files/**report"), + (http("/a/***"), "/a/***"), + (http("/a/**\\*"), "/a/**\\*"), + ( + connect_policy(r#"resource.host like("a**.example.com", ".") && resource.port == 443"#), + "a**.example.com", + ), + ( + connect_policy(r#"resource.host like("**example.com", ".")"#), + "**example.com", + ), + ] { + let error = CedarEngine::from_policy_str(&policy).expect_err(pattern); + assert!( + matches!(&error, CedarEngineError::AmbiguousRecursiveWildcard { pattern: found, delimiter, .. } + if found == pattern && (delimiter == "/" || delimiter == ".")), + "{pattern}: {error}" + ); + } + // `**` as a whole segment, at either end or inside, and undelimited + // patterns are fine. + for policy in [ + http("/files/**"), + http("**"), + http("/a/**/b"), + http("/a/*b*/**"), + connect_policy(r#"resource.host like("**.example.com", ".") && resource.port == 443"#), + connect_policy(r#"resource.host like "a**.example.com" && resource.port == 443"#), + ] { + engine(&policy); + } +} diff --git a/crates/openshell-policy-cedar/tests/destination_ip.rs b/crates/openshell-policy-cedar/tests/destination_ip.rs new file mode 100644 index 0000000000..3318f105fb --- /dev/null +++ b/crates/openshell-policy-cedar/tests/destination_ip.rs @@ -0,0 +1,390 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks `context.destination_ip` conditions, the Cedar form of YAML +//! `allowed_ips`: the accepted shapes, the decision made before and after +//! the host resolves, and the ranges reported for each allow. + +use std::net::IpAddr; + +use openshell_policy_cedar::{ + AuthorizedNetworkEndpoint, CedarEngine, CedarEngineError, Decision, IpNet, + NetworkConnectRequest, NetworkTransport, +}; + +fn load(policy: &str) -> Result { + CedarEngine::from_policy_str(policy) +} + +fn request(host: &str, port: u16, binary: &str, ip: Option<&str>) -> NetworkConnectRequest { + NetworkConnectRequest { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + host: host.to_string(), + port, + binary_path: binary.to_string(), + ancestors: Vec::new(), + binary_aliases: Vec::new(), + binary_globs: Vec::new(), + destination_ip: ip.map(|ip| ip.parse::().expect("test address parses")), + } +} + +fn nets(ranges: &[&str]) -> Vec { + ranges + .iter() + .map(|range| range.parse().expect("test range parses")) + .collect() +} + +fn invalid_destination_ip(policy: &str) -> String { + match load(policy) { + Err(CedarEngineError::InvalidDestinationIp { reason, .. }) => reason, + other => panic!("expected InvalidDestinationIp, got {other:?}"), + } +} + +const POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"my-service.corp.net:8080") +when { + context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.0.5.0/24")) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.port == 9443 + && context.binary_path == "/usr/bin/curl" + && context has destination_ip + && (context.destination_ip.isInRange(ip("172.16.0.0/12")) + || context.destination_ip.isInRange(ip("192.168.1.1"))) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context.binary_path == "/usr/bin/curl" }; +"#; + +#[test] +fn host_with_ranges_allows_before_resolution_and_checks_each_address() { + let engine = load(POLICY).expect("policy loads"); + let unresolved = engine + .authorize_network(&request("my-service.corp.net", 8080, "/usr/bin/curl", None)) + .expect("evaluates"); + assert!(unresolved.decision.is_allow()); + assert_eq!(unresolved.address_conditions, [nets(&["10.0.5.0/24"])]); + assert!(!unresolved.unconstrained_permit); + assert!(unresolved.names_endpoint); + + let inside = request( + "my-service.corp.net", + 8080, + "/usr/bin/curl", + Some("10.0.5.9"), + ); + assert!(engine.evaluate_network(&inside).unwrap().is_allow()); + let outside = request( + "my-service.corp.net", + 8080, + "/usr/bin/curl", + Some("10.0.6.1"), + ); + assert!(!engine.evaluate_network(&outside).unwrap().is_allow()); + let other_binary = request("my-service.corp.net", 8080, "/usr/bin/wget", None); + assert!(!engine.evaluate_network(&other_binary).unwrap().is_allow()); +} + +#[test] +fn hostless_ranges_allow_any_host_on_their_port() { + let engine = load(POLICY).expect("policy loads"); + let unresolved = engine + .authorize_network(&request( + "anything.example.com", + 9443, + "/usr/bin/curl", + None, + )) + .expect("evaluates"); + assert!(unresolved.decision.is_allow()); + assert_eq!( + unresolved.address_conditions, + [nets(&["172.16.0.0/12", "192.168.1.1/32"])] + ); + assert!(!unresolved.names_endpoint); + let wrong_port = request("anything.example.com", 9444, "/usr/bin/curl", None); + assert!(!engine.evaluate_network(&wrong_port).unwrap().is_allow()); + let resolved = request( + "anything.example.com", + 9443, + "/usr/bin/curl", + Some("192.168.1.1"), + ); + assert!(engine.evaluate_network(&resolved).unwrap().is_allow()); + let resolved = request( + "anything.example.com", + 9443, + "/usr/bin/curl", + Some("192.168.1.2"), + ); + assert!(!engine.evaluate_network(&resolved).unwrap().is_allow()); +} + +#[test] +fn permits_without_ranges_are_unconstrained() { + let engine = load(POLICY).expect("policy loads"); + let evaluation = engine + .authorize_network(&request("api.github.com", 443, "/usr/bin/curl", None)) + .expect("evaluates"); + assert!(evaluation.decision.is_allow()); + assert!(evaluation.address_conditions.is_empty()); + assert!(evaluation.unconstrained_permit); + assert!(evaluation.names_endpoint); +} + +#[test] +fn ranges_are_dns_records_of_their_own_and_hostless_permits_are_not() { + let engine = load(&format!( + r#"{POLICY} +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.corp.net:5432") +when {{ context has destination_ip && context.destination_ip.isInRange(ip("10.2.0.0/16")) }}; +"# + )) + .expect("policy loads"); + assert_eq!( + engine.dns_endpoints(), + [ + AuthorizedNetworkEndpoint { + host: "api.github.com".to_string(), + ports: vec![443], + transport: NetworkTransport::Default, + allowed_ips: Vec::new(), + }, + AuthorizedNetworkEndpoint { + host: "db.corp.net".to_string(), + ports: vec![5432], + transport: NetworkTransport::Tcp, + allowed_ips: nets(&["10.2.0.0/16"]), + }, + AuthorizedNetworkEndpoint { + host: "my-service.corp.net".to_string(), + ports: vec![8080], + transport: NetworkTransport::Default, + allowed_ips: nets(&["10.0.5.0/24"]), + }, + ] + ); +} + +#[test] +fn conjuncts_intersect_their_ranges() { + let engine = load( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"svc.corp.net:443") +when { + context has destination_ip + && (context.destination_ip.isInRange(ip("10.0.0.0/8")) + || context.destination_ip.isInRange(ip("192.168.0.0/16"))) + && context.destination_ip.isInRange(ip("10.1.0.0/16")) +}; +"#, + ) + .expect("policy loads"); + let evaluation = engine + .authorize_network(&request("svc.corp.net", 443, "/usr/bin/curl", None)) + .expect("evaluates"); + assert_eq!(evaluation.address_conditions, [nets(&["10.1.0.0/16"])]); +} + +#[test] +fn forbids_reading_only_the_address_apply_once_resolved() { + // Before resolution the `context has destination_ip` guard is false, so + // a forbid whose only condition reads the address has no effect; after + // resolution it applies to each address. + let engine = load( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"svc.corp.net:443"); +forbid (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { context has destination_ip && !context.destination_ip.isInRange(ip("10.0.0.0/8")) }; +"#, + ) + .expect("policy loads"); + let unresolved = request("svc.corp.net", 443, "/usr/bin/curl", None); + assert!(engine.evaluate_network(&unresolved).unwrap().is_allow()); + let inside = request("svc.corp.net", 443, "/usr/bin/curl", Some("10.1.2.3")); + assert!(engine.evaluate_network(&inside).unwrap().is_allow()); + let outside = request("svc.corp.net", 443, "/usr/bin/curl", Some("8.8.8.8")); + assert!(!engine.evaluate_network(&outside).unwrap().is_allow()); +} + +#[test] +fn forbids_reading_the_address_still_deny_on_their_other_conditions_before_resolution() { + // The binary disjunct must fire at CONNECT time: with + // `proxy_connect_by_hostname` no per-address decision follows, so a + // forbid left out of the pre-resolution decision would never deny curl. + let engine = load( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"svc.corp.net:443"); +forbid (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + context.binary_path == "/usr/bin/curl" + || (context has destination_ip && context.destination_ip.isInRange(ip("10.0.0.0/8"))) +}; +"#, + ) + .expect("policy loads"); + let curl = request("svc.corp.net", 443, "/usr/bin/curl", None); + let denied = engine.evaluate_network(&curl).unwrap(); + assert!(!denied.is_allow(), "{denied:?}"); + assert!( + matches!(&denied, Decision::Deny { matched_policies } if matched_policies == &["policy1"]), + "{denied:?}" + ); + let wget = request("svc.corp.net", 443, "/usr/bin/wget", None); + assert!(engine.evaluate_network(&wget).unwrap().is_allow()); + let wget_private = request("svc.corp.net", 443, "/usr/bin/wget", Some("10.1.2.3")); + assert!(!engine.evaluate_network(&wget_private).unwrap().is_allow()); + let wget_public = request("svc.corp.net", 443, "/usr/bin/wget", Some("8.8.8.8")); + assert!(engine.evaluate_network(&wget_public).unwrap().is_allow()); +} + +#[test] +fn an_unless_forbid_reading_the_address_denies_before_resolution() { + // The guard is false before resolution, so an `unless` body that reads + // the address is false and the forbid applies; write address-only + // forbids with `when`. + let engine = load( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"svc.corp.net:443"); +forbid (principal, action == Sandbox::Action::"NetworkConnect", resource) +unless { context has destination_ip && context.destination_ip.isInRange(ip("10.0.0.0/8")) }; +"#, + ) + .expect("policy loads"); + let unresolved = request("svc.corp.net", 443, "/usr/bin/curl", None); + assert!(!engine.evaluate_network(&unresolved).unwrap().is_allow()); + let inside = request("svc.corp.net", 443, "/usr/bin/curl", Some("10.1.2.3")); + assert!(engine.evaluate_network(&inside).unwrap().is_allow()); +} + +#[test] +fn rejects_an_unguarded_address_read() { + // The pre-resolution decision relies on the guard: without it, strict + // validation rejects the optional attribute access, in a forbid as in a + // permit. + for effect in ["permit", "forbid"] { + let error = load(&format!( + r#"{effect} (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ context.destination_ip.isInRange(ip("10.0.0.0/8")) }};"# + )) + .expect_err("unguarded read must be rejected"); + assert!( + matches!(error, CedarEngineError::PolicyValidation { .. }), + "{effect}: {error}" + ); + } +} + +#[test] +fn names_endpoint_counts_only_allowing_permits() { + let engine = load( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.corp.net:443") +when { context.binary_path == "/usr/bin/other" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.corp.net", ".") && resource.port == 443 }; +"#, + ) + .expect("policy loads"); + let evaluation = engine + .authorize_network(&request("api.corp.net", 443, "/usr/bin/curl", None)) + .expect("evaluates"); + assert!(evaluation.decision.is_allow()); + assert!(!evaluation.names_endpoint); +} + +#[test] +fn rejects_ranges_the_proxy_always_blocks() { + for range in ["127.0.0.0/8", "169.254.169.254", "0.0.0.0/0", "::1"] { + let reason = invalid_destination_ip(&format!( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ context has destination_ip && context.destination_ip.isInRange(ip("{range}")) }};"# + )); + assert!( + reason.contains("blocked regardless of policy"), + "{range}: {reason}" + ); + } +} + +#[test] +fn rejects_address_conditions_it_cannot_derive_ranges_from() { + for (condition, expected) in [ + ( + r#"when { context has destination_ip && context.destination_ip == ip("10.0.0.1") }"#, + "isInRange", + ), + ( + r#"when { context has destination_ip && !context.destination_ip.isInRange(ip("10.0.0.0/8")) }"#, + "isInRange", + ), + ( + r#"when { resource.port == 443 || (context has destination_ip && context.destination_ip.isInRange(ip("10.0.0.0/8"))) }"#, + "isInRange", + ), + ( + r#"unless { context has destination_ip && context.destination_ip.isInRange(ip("10.0.0.0/8")) }"#, + "only in `when`", + ), + ( + "when { context has destination_ip }", + "must require it to be in a range", + ), + ( + r#"when { context has destination_ip && context.destination_ip.isInRange(ip("10.0.0.0/8")) && context.destination_ip.isInRange(ip("192.168.0.0/16")) }"#, + "admit no address", + ), + ] { + let reason = invalid_destination_ip(&format!( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) {condition};"# + )); + assert!(reason.contains(expected), "{condition}: {reason}"); + } +} + +/// A record literal with a key named `Var` still has its value inspected, +/// so an address read inside one is not mistaken for a host condition. +#[test] +fn reads_inside_a_record_keyed_var_are_detected() { + let reason = invalid_destination_ip( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + context has destination_ip + && context.destination_ip.isInRange(ip("10.0.0.0/8")) + && {Var: context.destination_ip} == {Var: ip("10.1.2.3")} +};"#, + ); + assert!(reason.contains("each `when` conjunct"), "{reason}"); +} + +#[test] +fn rejects_address_conditions_outside_network_connect() { + // Only NetworkConnect declares `destination_ip`, so the guard can never + // hold for the other action in scope. + let error = load( + r#"permit (principal, action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"ReadFile"], resource) +when { context has destination_ip && context.destination_ip.isInRange(ip("10.0.0.0/8")) };"#, + ) + .expect_err("rejected"); + assert!( + matches!(&error, CedarEngineError::PolicyValidation { reason } + if reason.contains("`context has destination_ip`") && reason.contains("action ReadFile")), + "{error}" + ); +} diff --git a/crates/openshell-policy-cedar/tests/dns_eligibility.rs b/crates/openshell-policy-cedar/tests/dns_eligibility.rs new file mode 100644 index 0000000000..5fa09065b3 --- /dev/null +++ b/crates/openshell-policy-cedar/tests/dns_eligibility.rs @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks that [`CedarEngine::dns_endpoints`] lists exactly the +//! `NetworkEndpoint`s named in `NetworkConnect` permit scopes, for DNS +//! eligibility. Uses the same fixture `tests/network.rs` uses for +//! CONNECT-time evaluation. + +use openshell_policy_cedar::{ + AuthorizedNetworkEndpoint, CedarEngine, CedarEngineError, NetworkTransport, +}; + +const POLICIES: &str = include_str!("fixtures/policies.cedar"); + +fn dns_endpoints(policy: &str) -> Vec { + CedarEngine::from_policy_str(policy) + .expect("policy must load") + .dns_endpoints() + .to_vec() +} + +#[test] +fn lists_every_permitted_endpoint_grouped_by_host() { + assert_eq!( + dns_endpoints(POLICIES), + vec![ + AuthorizedNetworkEndpoint { + host: "files.pythonhosted.org".to_string(), + ports: vec![443], + transport: NetworkTransport::Default, + allowed_ips: Vec::new(), + }, + AuthorizedNetworkEndpoint { + host: "integrate.api.nvidia.com".to_string(), + ports: vec![443], + transport: NetworkTransport::Default, + allowed_ips: Vec::new(), + }, + AuthorizedNetworkEndpoint { + host: "pypi.org".to_string(), + ports: vec![443], + transport: NetworkTransport::Default, + allowed_ips: Vec::new(), + }, + ] + ); +} + +#[test] +fn ignores_filesystem_only_policies() { + let endpoints = dns_endpoints( + r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/usr" +); +"#, + ); + assert!(endpoints.is_empty()); +} + +#[test] +fn forbid_grants_no_eligibility() { + let endpoints = dns_endpoints( + r#" +forbid ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"evil.example.com:443" +); +"#, + ); + assert!(endpoints.is_empty()); +} + +#[test] +fn endpoints_named_only_in_conditions_are_not_eligible() { + // An `unless` clause naming an endpoint excludes it; reading literals + // out of conditions would have made it eligible. + let endpoints = dns_endpoints( + r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource is Sandbox::NetworkEndpoint +) +unless { resource == Sandbox::NetworkEndpoint::"evil.example.com:443" }; +"#, + ); + assert!(endpoints.is_empty()); +} + +#[test] +fn groups_multiple_ports_for_the_same_host() { + let endpoints = dns_endpoints( + r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"example.com:443" +); +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"example.com:8443" +); +"#, + ); + assert_eq!( + endpoints, + vec![AuthorizedNetworkEndpoint { + host: "example.com".to_string(), + ports: vec![443, 8443], + transport: NetworkTransport::Default, + allowed_ips: Vec::new(), + }] + ); +} + +#[test] +fn host_port_conditions_name_the_endpoint() { + for condition in [ + r#"resource.host_port == "pypi.org:443""#, + r#""pypi.org:443" == resource.host_port"#, + r#"["pypi.org:443"].contains(resource.host_port)"#, + // The endpoint operand may sit among other conjuncts. + r#"context.binary_path == "/usr/bin/pip" && resource.host_port == "pypi.org:443""#, + ] { + let endpoints = dns_endpoints(&format!( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ {condition} }};"# + )); + assert_eq!( + endpoints, + vec![AuthorizedNetworkEndpoint { + host: "pypi.org".to_string(), + ports: vec![443], + transport: NetworkTransport::Default, + allowed_ips: Vec::new(), + }], + "{condition}" + ); + } +} + +#[test] +fn host_port_forms_that_name_no_single_endpoint_are_not_eligible() { + for condition in [ + // Several endpoints: the exactly-one rule applies to the set too. + r#"["pypi.org:443", "files.pythonhosted.org:443"].contains(resource.host_port)"#, + // A glob over "host:port". + r#"resource.host_port like("*.example.com:443", ".")"#, + // Not a necessary condition. + r#"resource.host_port == "pypi.org:443" || resource.host_port == "example.com:443""#, + // A second host or port operand. + r#"resource.host_port == "pypi.org:443" && resource.port == 443"#, + r#"resource.host_port == "pypi.org:443" && resource.host == "pypi.org""#, + // Outside `when`. + r#"true } unless { resource.host_port != "pypi.org:443""#, + ] { + let endpoints = dns_endpoints(&format!( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ {condition} }};"# + )); + assert!(endpoints.is_empty(), "{condition}: {endpoints:?}"); + } +} + +#[test] +fn rejects_invalid_host_port_literals() { + // A `host_port` literal is checked as a scope literal is: requests carry + // the lowercase host and a nonzero port, so these could never match. + for (form, endpoint) in [ + ("==", "API.example.com:443"), + ("==", "example.com.:443"), + ("==", "example.com"), + ("==", "example.com:0"), + ("==", "*.example.com:443"), + ("contains", "example.com:70000"), + ] { + let condition = if form == "==" { + format!(r#"resource.host_port == "{endpoint}""#) + } else { + format!(r#"["{endpoint}"].contains(resource.host_port)"#) + }; + let policy = format!( + r#"permit(principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ {condition} }};"# + ); + let error = CedarEngine::from_policy_str(&policy) + .expect_err(&format!("{condition} must be rejected")); + assert!( + matches!(&error, CedarEngineError::InvalidEndpoint { endpoint: written, .. } if written == endpoint), + "{condition}: {error}" + ); + } +} + +#[test] +fn rejects_invalid_endpoint_literals() { + for endpoint in [ + "API.example.com:443", + "example.com.:443", + "example.com", + "example.com:0", + ] { + let policy = format!( + r#"permit(principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{endpoint}");"# + ); + let error = CedarEngine::from_policy_str(&policy) + .expect_err(&format!("{endpoint} must be rejected")); + assert!( + matches!(error, CedarEngineError::InvalidEndpoint { .. }), + "{endpoint}: {error}" + ); + } +} diff --git a/crates/openshell-policy-cedar/tests/filesystem.rs b/crates/openshell-policy-cedar/tests/filesystem.rs new file mode 100644 index 0000000000..1cd718ebe2 --- /dev/null +++ b/crates/openshell-policy-cedar/tests/filesystem.rs @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks Landlock grant derivation from authored Cedar policies, and that +//! filesystem policies whose meaning Landlock cannot enforce are rejected. + +use openshell_policy_cedar::{CedarEngine, CedarEngineError, FilesystemGrants}; + +fn grants(policy: &str) -> FilesystemGrants { + CedarEngine::from_policy_str(policy) + .expect("policy must load") + .filesystem_grants() + .clone() +} + +fn rejection(policy: &str) -> CedarEngineError { + CedarEngine::from_policy_str(policy).expect_err("policy must be rejected") +} + +#[test] +fn derives_read_only_and_read_write_from_when_clause_permits() { + let extracted = grants( + r#" + permit( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath + ) + when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/etc" + }; + + permit( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath + ) + when { resource in Sandbox::FilesystemPath::"/sandbox" }; + "#, + ); + assert_eq!(extracted.read_only, vec!["/etc", "/usr"]); + assert_eq!(extracted.read_write, vec!["/sandbox"]); +} + +#[test] +fn derives_grants_from_scope_paths() { + let extracted = grants( + r#" + permit( + principal, + action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/opt" + ); + "#, + ); + assert_eq!(extracted.read_only, vec!["/opt"]); + assert!(extracted.read_write.is_empty()); +} + +#[test] +fn rejects_write_without_read() { + // A Landlock read-write grant includes read, so a permit listing only + // WriteFile would grant reads it does not say. + for scope in [ + r#"action == Sandbox::Action::"WriteFile""#, + r#"action in [Sandbox::Action::"WriteFile"]"#, + ] { + let error = rejection(&format!( + r#" + permit( + principal is Sandbox::Process, + {scope}, + resource in Sandbox::FilesystemPath::"/tmp" + ); + "# + )); + match &error { + CedarEngineError::UnsupportedPolicy { reason, .. } => assert_eq!( + reason, + "Landlock read-write grants include read; list both ReadFile and WriteFile" + ), + other => panic!("{scope}: expected UnsupportedPolicy, got {other}"), + } + } +} + +#[test] +fn network_policies_grant_no_paths() { + let extracted = grants( + r#" + permit( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource is Sandbox::NetworkEndpoint + ) + when { resource.host_port == "pypi.org:443" }; + "#, + ); + assert!(extracted.read_only.is_empty()); + assert!(extracted.read_write.is_empty()); +} + +#[test] +fn rejects_forbid_on_a_filesystem_path() { + let error = rejection( + r#" + permit( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/usr" + ); + + forbid( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/usr/secret" + ); + "#, + ); + assert!( + matches!(error, CedarEngineError::FilesystemForbidUnsupported { .. }), + "{error}" + ); +} + +#[test] +fn rejects_forbid_without_a_path_literal() { + // Previously undetected: no FilesystemPath literal, yet it forbids all + // writes, which Landlock would not enforce. + let error = rejection( + r#" + permit( + principal, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource in Sandbox::FilesystemPath::"/data" + ); + forbid(principal, action == Sandbox::Action::"WriteFile", resource); + "#, + ); + assert!( + matches!(error, CedarEngineError::FilesystemForbidUnsupported { .. }), + "{error}" + ); +} + +#[test] +fn rejects_unsupported_filesystem_policy_shapes() { + let cases = [ + ( + "unless clause naming the path", + r#"permit(principal, action == Sandbox::Action::"ReadFile", resource) + unless { resource in Sandbox::FilesystemPath::"/secret" };"#, + ), + ( + "condition that never holds", + r#"permit(principal, action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/etc") + when { false };"#, + ), + ( + "write literal only inside a condition", + r#"permit(principal, action, resource in Sandbox::FilesystemPath::"/data") + when { action != Sandbox::Action::"WriteFile" };"#, + ), + ( + "principal constraint", + r#"permit(principal == Sandbox::Process::"nobody", + action == Sandbox::Action::"WriteFile", + resource in Sandbox::FilesystemPath::"/");"#, + ), + ( + "exact path match", + r#"permit(principal, action == Sandbox::Action::"ReadFile", + resource == Sandbox::FilesystemPath::"/");"#, + ), + ( + "unconstrained action", + r#"permit(principal, action, resource in Sandbox::FilesystemPath::"/data");"#, + ), + ( + "binary-scoped condition", + r#"permit(principal, action == Sandbox::Action::"WriteFile", resource) + when { resource in Sandbox::FilesystemPath::"/etc" + && principal.user == Sandbox::User::"root" };"#, + ), + ( + "unbounded resource", + r#"permit(principal, action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath);"#, + ), + ]; + for (name, policy) in cases { + let error = + CedarEngine::from_policy_str(policy).expect_err(&format!("{name} must be rejected")); + assert!( + matches!(error, CedarEngineError::UnsupportedPolicy { .. }), + "{name}: {error}" + ); + } +} + +#[test] +fn rejects_misspelled_action() { + let error = rejection( + r#"permit(principal, action == Sandbox::Action::"Writefile", + resource in Sandbox::FilesystemPath::"/data");"#, + ); + assert!( + matches!(error, CedarEngineError::PolicyValidation { .. }), + "{error}" + ); +} diff --git a/crates/openshell-policy-cedar/tests/fixtures/policies.cedar b/crates/openshell-policy-cedar/tests/fixtures/policies.cedar new file mode 100644 index 0000000000..72584063e2 --- /dev/null +++ b/crates/openshell-policy-cedar/tests/fixtures/policies.cedar @@ -0,0 +1,101 @@ +// Identity guard +// run_as_user: sandbox / run_as_group: sandbox +// Connections are denied unless the process runs as sandbox:sandbox. +// Placed as a top-level forbid so it cannot be bypassed by any permit. +// Scoped to NetworkConnect: every HttpRequest happens on a connection +// whose CONNECT was already checked, and Landlock grants cannot be +// conditioned on identity. + +forbid (principal is Sandbox::Process, action == Sandbox::Action::"NetworkConnect", resource) +unless { + principal.user == Sandbox::User::"sandbox" + && principal.group == Sandbox::Group::"sandbox" +}; + +// Filesystem: read-only paths +// /usr /lib /proc /dev/urandom /app /etc /var/log + +@landlock("best_effort") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/var/log" + || resource in Sandbox::FilesystemPath::"/proc/self" + || resource in Sandbox::FilesystemPath::"/dev/urandom" // leaf, not dir +}; + +// Filesystem: read-write paths +// /sandbox /tmp /dev/null + +@landlock("best_effort") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" // leaf, not dir +}; + +// Network: Allow PyPI access so pip can install dependencies +// inside the sandbox +// endpoint: pypi.org:443 +// binary: /sandbox/.venv/bin/python3. + +@enforcement("enforce") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443" +) +when { context.binary_path == "/sandbox/.venv/bin/python3" }; + +// Network: Allow PyPI access so pip can install dependencies +// inside the sandbox +// endpoint: files.pythonhosted.org:443 +// binary: /sandbox/.venv/bin/python3. + +@enforcement("enforce") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"files.pythonhosted.org:443" +) +when { context.binary_path == "/sandbox/.venv/bin/python3" }; + +// Network: NVIDIA API (L7 intercept) +// endpoint: integrate.api.nvidia.com:443 +// binary: /sandbox/.venv/bin/python3. + +@enforcement("enforce") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"integrate.api.nvidia.com:443" +) +when { context.binary_path == "/sandbox/.venv/bin/python3" }; + +// Network: NVIDIA API (L7 per-request enforcement) +// Only the chat completions endpoint may be POSTed to; anything else on +// this already-permitted tunnel is denied at the request level. + +@enforcement("enforce") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"integrate.api.nvidia.com:443" +) +when { + context.binary_path == "/sandbox/.venv/bin/python3" + && context.method == "POST" + && context.path == "/v1/chat/completions" +}; diff --git a/crates/openshell-policy-cedar/tests/l7.rs b/crates/openshell-policy-cedar/tests/l7.rs new file mode 100644 index 0000000000..d47e980b8f --- /dev/null +++ b/crates/openshell-policy-cedar/tests/l7.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end checks that [`CedarEngine::evaluate_l7`] evaluates +//! `HttpRequest` requests against hand-authored `HttpRequest` policies in +//! `tests/fixtures/policies.cedar` — the authoritative Cedar-sourced-sandbox +//! path, where L7 policies are authored directly (no compilation step). + +use openshell_policy_cedar::{CedarEngine, HttpRequestInput}; + +const POLICIES: &str = include_str!("fixtures/policies.cedar"); + +fn engine() -> CedarEngine { + CedarEngine::from_policy_str(POLICIES).expect("fixture policy set must parse") +} + +fn request(method: &str, path: &str) -> HttpRequestInput { + HttpRequestInput { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + binary_path: "/sandbox/.venv/bin/python3".to_string(), + ancestors: Vec::new(), + host: "integrate.api.nvidia.com".to_string(), + port: 443, + method: method.to_string(), + path: path.to_string(), + command: String::new(), + jsonrpc_method: String::new(), + ..Default::default() + } +} + +#[test] +fn allows_the_exact_permitted_request() { + let allowed = engine() + .evaluate_l7(&request("POST", "/v1/chat/completions")) + .expect("request must be representable in the schema") + .is_allow(); + assert!(allowed); +} + +#[test] +fn denies_a_different_path_on_the_same_tunnel() { + let allowed = engine() + .evaluate_l7(&request("POST", "/v1/admin/delete-everything")) + .expect("request must be representable in the schema") + .is_allow(); + assert!(!allowed); +} + +#[test] +fn denies_wrong_method() { + let allowed = engine() + .evaluate_l7(&request("GET", "/v1/chat/completions")) + .expect("request must be representable in the schema") + .is_allow(); + assert!(!allowed); +} + +#[test] +fn denies_an_endpoint_with_no_http_request_policy() { + let mut req = request("GET", "/simple/"); + req.host = "pypi.org".to_string(); + let allowed = engine() + .evaluate_l7(&req) + .expect("request must be representable in the schema") + .is_allow(); + assert!(!allowed); +} + +#[test] +fn allows_a_dns_resolved_host_with_a_trailing_dot() { + let mut req = request("POST", "/v1/chat/completions"); + req.host = "integrate.api.nvidia.com.".to_string(); + let allowed = engine() + .evaluate_l7(&req) + .expect("request must be representable in the schema") + .is_allow(); + assert!(allowed); +} diff --git a/crates/openshell-policy-cedar/tests/l7_endpoints.rs b/crates/openshell-policy-cedar/tests/l7_endpoints.rs new file mode 100644 index 0000000000..44dcb500d8 --- /dev/null +++ b/crates/openshell-policy-cedar/tests/l7_endpoints.rs @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks which `NetworkEndpoint`s [`CedarEngine::l7_protocol`] +//! routes into L7 inspection, how the `@protocol(...)` annotation is read, +//! and that `HttpRequest` policies the proxy could not route are rejected. + +use openshell_policy_cedar::{CedarEngine, CedarEngineError, L7Protocol}; + +fn engine(policy: &str) -> CedarEngine { + CedarEngine::from_policy_str(policy).expect("policy must load") +} + +fn rejection(policy: &str) -> CedarEngineError { + CedarEngine::from_policy_str(policy).expect_err("policy must be rejected") +} + +#[test] +fn http_request_permit_without_annotation_defaults_to_rest() { + let engine = engine( + r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { context.method == "GET" }; +"#, + ); + assert_eq!( + engine.l7_protocol("api.example.com", 443), + Some(L7Protocol::Rest) + ); + assert_eq!( + engine.l7_protocol("API.example.com.", 443), + Some(L7Protocol::Rest), + "lookup normalizes the requested host" + ); + assert_eq!(engine.l7_protocol("api.example.com", 8443), None); +} + +#[test] +fn protocol_annotation_is_read() { + let engine = engine( + r#" +@protocol("json-rpc") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"rpc.example.com:443" +) +when { context.jsonrpc_method == "eth_blockNumber" }; +"#, + ); + assert_eq!( + engine.l7_protocol("rpc.example.com", 443), + Some(L7Protocol::JsonRpc) + ); +} + +#[test] +fn connect_only_policies_are_not_l7_endpoints() { + let engine = engine( + r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { context.binary_path == "/usr/bin/curl" }; +"#, + ); + assert_eq!(engine.l7_protocol("api.example.com", 443), None); +} + +#[test] +fn forbid_alone_still_routes_the_endpoint_into_inspection() { + // Cedar denies by default, so a forbid-only endpoint denies every + // request. Skipping inspection would instead allow every request. + let engine = engine( + r#" +forbid ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { context.method == "DELETE" }; +"#, + ); + assert_eq!( + engine.l7_protocol("api.example.com", 443), + Some(L7Protocol::Rest) + ); +} + +#[test] +fn unannotated_policies_take_the_declared_protocol() { + let engine = engine( + r#" +@protocol("json-rpc") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"rpc.example.com:443" +); + +forbid ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"rpc.example.com:443" +) +when { context.jsonrpc_method == "admin_shutdown" }; +"#, + ); + assert_eq!( + engine.l7_protocol("rpc.example.com", 443), + Some(L7Protocol::JsonRpc) + ); +} + +#[test] +fn rejects_http_request_policies_without_a_scope_endpoint() { + let cases = [ + r#"permit(principal, action == Sandbox::Action::"HttpRequest", resource) + when { resource.host == "api.example.com" && context.method == "GET" };"#, + r#"permit(principal, action == Sandbox::Action::"HttpRequest", resource) + when { resource.host like "*.example.com" };"#, + r#"forbid(principal, action == Sandbox::Action::"HttpRequest", resource) + when { context.method == "DELETE" };"#, + ]; + for policy in cases { + let error = rejection(policy); + assert!( + matches!(error, CedarEngineError::UnsupportedPolicy { .. }), + "{policy}: {error}" + ); + } +} + +#[test] +fn rejects_unsupported_protocols() { + // SQL has no request inspection in the proxy, so it is never accepted. + for protocol in ["sql", "http", "REST", "bogus"] { + let policy = format!( + r#"@protocol("{protocol}") + permit(principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"db.example.com:5432");"# + ); + let error = rejection(&policy); + assert!( + matches!(error, CedarEngineError::UnsupportedL7Protocol { .. }), + "{protocol}: {error}" + ); + } +} + +#[test] +fn accepts_mcp_and_graphql_protocols() { + for (annotation, protocol) in [("mcp", L7Protocol::Mcp), ("graphql", L7Protocol::Graphql)] { + let engine = engine(&format!( + r#"@protocol("{annotation}") + permit(principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443");"# + )); + assert_eq!( + engine.l7_protocol("api.example.com", 443), + Some(protocol), + "{annotation}" + ); + } +} + +#[test] +fn rejects_conflicting_protocols_for_one_endpoint() { + let error = rejection( + r#" +@protocol("json-rpc") +permit(principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +@protocol("rest") +permit(principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +"#, + ); + assert!( + matches!(error, CedarEngineError::ConflictingL7Protocol { .. }), + "{error}" + ); +} + +#[test] +fn rejects_protocol_annotation_on_a_connect_policy() { + let error = rejection( + r#" +@protocol("rest") +permit(principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +"#, + ); + assert!( + matches!(error, CedarEngineError::UnsupportedPolicy { .. }), + "{error}" + ); +} diff --git a/crates/openshell-policy-cedar/tests/l7_host_globs.rs b/crates/openshell-policy-cedar/tests/l7_host_globs.rs new file mode 100644 index 0000000000..2dd7bf85ed --- /dev/null +++ b/crates/openshell-policy-cedar/tests/l7_host_globs.rs @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks for `HttpRequest` policies that name their endpoint as a host glob +//! in their conditions, which inspects every host the glob matches, as a +//! YAML wildcard endpoint with request rules does. + +use openshell_policy_cedar::{ + CedarEngine, CedarEngineError, Decision, HttpRequestInput, L7Enforcement, L7Protocol, +}; + +fn engine(policy: &str) -> CedarEngine { + CedarEngine::from_policy_str(policy).expect("policy must load") +} + +fn rejection(policy: &str) -> CedarEngineError { + CedarEngine::from_policy_str(policy).expect_err("policy must be rejected") +} + +/// An `HttpRequest` policy whose endpoint is `condition`, a host and port +/// condition, joined with `extra`. +fn glob_policy(annotations: &str, effect: &str, condition: &str, extra: &str) -> String { + format!( + r#"{annotations} +{effect} (principal, action == Sandbox::Action::"HttpRequest", + resource is Sandbox::NetworkEndpoint) +when {{ {condition} && {extra} }}; +"# + ) +} + +const WILDCARD_443: &str = r#"resource.host like("*.example.com", ".") && resource.port == 443"#; + +/// A request from curl to `host:443`, routed to the Cedar path `endpoint_path`. +fn request(host: &str, method: &str, path: &str, endpoint_path: &str) -> HttpRequestInput { + HttpRequestInput { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + binary_path: "/usr/bin/curl".to_string(), + host: host.to_string(), + port: 443, + method: method.to_string(), + path: path.to_string(), + endpoint_path: endpoint_path.to_string(), + ..Default::default() + } +} + +/// `(path, protocol, enforcement)` for every inspected path of `host:443`. +fn inspected(engine: &CedarEngine, host: &str) -> Vec<(String, L7Protocol, L7Enforcement)> { + engine + .l7_endpoints(host, 443) + .map(|(path, endpoint)| (path, endpoint.protocol, endpoint.enforcement)) + .collect() +} + +#[test] +fn a_host_glob_inspects_every_matching_host() { + let engine = engine(&glob_policy( + "", + "permit", + WILDCARD_443, + r#"context.method == "GET""#, + )); + for host in ["api.example.com", "other.example.com", "API.example.com."] { + assert_eq!( + engine.l7_protocol(host, 443), + Some(L7Protocol::Rest), + "{host} matches the glob" + ); + } + for host in ["example.com", "deep.api.example.com", "api.example.org"] { + assert_eq!( + engine.l7_protocol(host, 443), + None, + "{host} is outside the glob" + ); + } + assert_eq!( + engine.l7_protocol("api.example.com", 8443), + None, + "another port is not inspected" + ); + + // The entity Cedar sees is the actual host, so the condition decides. + let allowed = engine + .evaluate_l7(&request("other.example.com", "GET", "/x", "")) + .expect("evaluates"); + assert!(allowed.decision.is_allow(), "{allowed:?}"); + let denied = engine + .evaluate_l7(&request("other.example.com", "POST", "/x", "")) + .expect("evaluates"); + assert!( + matches!(denied.decision, Decision::Deny { .. }), + "{denied:?}" + ); +} + +#[test] +fn a_forbid_with_a_host_glob_inspects_too() { + let engine = engine(&glob_policy( + "", + "forbid", + WILDCARD_443, + r#"context.method == "DELETE""#, + )); + assert_eq!( + engine.l7_protocol("api.example.com", 443), + Some(L7Protocol::Rest) + ); +} + +#[test] +fn rejects_host_conditions_the_proxy_cannot_route() { + let cases = [ + // Undelimited `like`: `*` would cross labels. + r#"resource.host like "*.example.com" && resource.port == 443"#, + // A wildcard top-level domain, rejected for YAML hosts too. + r#"resource.host like("*.com", ".") && resource.port == 443"#, + // No port. + r#"resource.host like("*.example.com", ".")"#, + // Hosts joined with `||` are not one glob. + r#"(resource.host like("*.example.com", ".") || resource.host like("*.example.org", ".")) + && resource.port == 443"#, + // Two host conjuncts. + r#"resource.host like("*.example.com", ".") && resource.host like("**.example.com", ".") + && resource.port == 443"#, + // A port set is not one port. + r#"resource.host like("*.example.com", ".") && [443, 8443].contains(resource.port)"#, + // An exact host belongs in the scope. + r#"resource.host == "api.example.com" && resource.port == 443"#, + // A `*` outside the first label. + r#"resource.host like("api.*-prod.example.com", ".") && resource.port == 443"#, + ]; + for condition in cases { + let policy = glob_policy("", "permit", condition, r#"context.method == "GET""#); + let error = rejection(&policy); + assert!( + matches!(&error, CedarEngineError::UnsupportedPolicy { reason, .. } + if reason.contains("must name its endpoint")), + "{condition}: {error}" + ); + } +} + +#[test] +fn glob_endpoints_take_paths_protocols_and_enforcement() { + let policy = format!( + "{}{}{}", + glob_policy("", "permit", WILDCARD_443, r#"context.method == "GET""#), + glob_policy( + r#"@path("/graphql") @protocol("graphql")"#, + "permit", + WILDCARD_443, + r#"context.path == "/graphql" && context.graphql_operation_type == "query""# + ), + glob_policy( + r#"@path("/legacy/**") @enforcement("audit")"#, + "permit", + WILDCARD_443, + r#"context.path like("/legacy/**", "/")"# + ), + ); + let engine = engine(&policy); + assert_eq!( + inspected(&engine, "other.example.com"), + vec![ + (String::new(), L7Protocol::Rest, L7Enforcement::Enforce), + ( + "/graphql".to_string(), + L7Protocol::Graphql, + L7Enforcement::Enforce + ), + ( + "/legacy/**".to_string(), + L7Protocol::Rest, + L7Enforcement::Audit + ), + ] + ); + assert_eq!( + engine + .l7_endpoint_at("api.example.com", 443, "/graphql") + .map(|endpoint| endpoint.protocol), + Some(L7Protocol::Graphql) + ); + assert!(inspected(&engine, "example.com").is_empty()); +} + +#[test] +fn an_exact_endpoint_and_a_matching_glob_inspect_a_host_together() { + let policy = format!( + r#"{} +@path("/api/**") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when {{ context.method == "GET" && context.path like("/api/**", "/") }}; +"#, + glob_policy("", "permit", WILDCARD_443, r#"context.method == "GET""#), + ); + let engine = engine(&policy); + assert_eq!( + inspected(&engine, "api.example.com"), + vec![ + (String::new(), L7Protocol::Rest, L7Enforcement::Enforce), + ( + "/api/**".to_string(), + L7Protocol::Rest, + L7Enforcement::Enforce + ), + ], + "the exact host gets the glob's paths and its own" + ); + assert_eq!( + inspected(&engine, "other.example.com"), + vec![(String::new(), L7Protocol::Rest, L7Enforcement::Enforce)], + "another matching host gets only the glob's paths" + ); +} + +#[test] +fn rejects_overlapping_globs_that_inspect_a_path_differently() { + let policy = format!( + "{}{}", + glob_policy("", "permit", WILDCARD_443, "true"), + glob_policy( + r#"@protocol("json-rpc")"#, + "permit", + r#"resource.host like("**.example.com", ".") && resource.port == 443"#, + "true" + ), + ); + let error = rejection(&policy); + assert!( + matches!(&error, CedarEngineError::AmbiguousL7Hosts { first, second, first_path, second_path } + if [first.as_str(), second.as_str()].contains(&"*.example.com:443") + && [first.as_str(), second.as_str()].contains(&"**.example.com:443") + && first_path.is_empty() && second_path.is_empty()), + "{error}" + ); + + // Equally specific overlapping paths conflict too. + let policy = format!( + "{}{}", + glob_policy( + r#"@path("/v1/**") @protocol("json-rpc")"#, + "permit", + WILDCARD_443, + "true" + ), + glob_policy( + r#"@path("/v1/**") @enforcement("audit")"#, + "permit", + r#"resource.host like("**.example.com", ".") && resource.port == 443"#, + "true" + ), + ); + assert!( + matches!( + rejection(&policy), + CedarEngineError::AmbiguousL7Hosts { .. } + ), + "{policy}" + ); +} + +#[test] +fn rejects_an_exact_endpoint_that_inspects_a_glob_path_differently() { + let policy = format!( + r#"{} +@protocol("json-rpc") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +"#, + glob_policy("", "permit", WILDCARD_443, "true"), + ); + assert!( + matches!( + rejection(&policy), + CedarEngineError::AmbiguousL7Hosts { .. } + ), + "{policy}" + ); +} + +#[test] +fn accepts_entries_that_cannot_select_one_host_or_agree() { + // Disjoint globs with different protocols. + let policy = format!( + "{}{}", + glob_policy("", "permit", WILDCARD_443, "true"), + glob_policy( + r#"@protocol("json-rpc")"#, + "permit", + r#"resource.host like("*.example.org", ".") && resource.port == 443"#, + "true" + ), + ); + let disjoint = engine(&policy); + assert_eq!( + disjoint.l7_protocol("rpc.example.org", 443), + Some(L7Protocol::JsonRpc) + ); + assert_eq!( + disjoint.l7_protocol("api.example.com", 443), + Some(L7Protocol::Rest) + ); + + // Overlapping globs on different ports. + let policy = format!( + "{}{}", + glob_policy("", "permit", WILDCARD_443, "true"), + glob_policy( + r#"@protocol("json-rpc")"#, + "permit", + r#"resource.host like("**.example.com", ".") && resource.port == 8443"#, + "true" + ), + ); + engine(&policy); + + // Overlapping globs that inspect the same way, or paths of different + // specificity. + let policy = format!( + "{}{}{}", + glob_policy("", "permit", WILDCARD_443, "true"), + glob_policy( + "", + "forbid", + r#"resource.host like("**.example.com", ".") && resource.port == 443"#, + r#"context.method == "DELETE""# + ), + glob_policy( + r#"@path("/rpc") @protocol("json-rpc")"#, + "permit", + r#"resource.host like("**.example.com", ".") && resource.port == 443"#, + r#"context.path == "/rpc""# + ), + ); + let agreeing = engine(&policy); + assert_eq!( + inspected(&agreeing, "api.example.com"), + vec![ + (String::new(), L7Protocol::Rest, L7Enforcement::Enforce), + ( + "/rpc".to_string(), + L7Protocol::JsonRpc, + L7Enforcement::Enforce + ), + ] + ); +} + +#[test] +fn a_glob_inspected_endpoint_cannot_also_be_native_tcp() { + let policy = format!( + r#"{} +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example.com:443"); +"#, + glob_policy("", "permit", WILDCARD_443, "true"), + ); + assert!( + matches!( + rejection(&policy), + CedarEngineError::ConflictingTransport { .. } + ), + "{policy}" + ); +} diff --git a/crates/openshell-policy-cedar/tests/l7_paths.rs b/crates/openshell-policy-cedar/tests/l7_paths.rs new file mode 100644 index 0000000000..a3eabb66d8 --- /dev/null +++ b/crates/openshell-policy-cedar/tests/l7_paths.rs @@ -0,0 +1,784 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks for `@path` on `HttpRequest` policies, which gives one +//! `host:port` a protocol and enforcement per path, and for the WebSocket +//! protocols. + +use openshell_policy_cedar::{ + AnalysisWarning, CedarEngine, CedarEngineError, Decision, HttpRequestInput, L7Enforcement, + L7Protocol, +}; + +const HOST: &str = "api.example.com"; + +fn engine(policy: &str) -> CedarEngine { + CedarEngine::from_policy_str(policy).expect("policy must load") +} + +fn rejection(policy: &str) -> CedarEngineError { + CedarEngine::from_policy_str(policy).expect_err("policy must be rejected") +} + +/// An `HttpRequest` policy on the test endpoint. +fn http_policy(annotations: &str, effect: &str, condition: &str) -> String { + format!( + r#"{annotations} +{effect} (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{HOST}:443") +when {{ {condition} }}; +"# + ) +} + +/// A request routed to the Cedar path `endpoint_path`. +fn request(method: &str, path: &str, endpoint_path: &str) -> HttpRequestInput { + HttpRequestInput { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + binary_path: "/usr/bin/curl".to_string(), + host: HOST.to_string(), + port: 443, + method: method.to_string(), + path: path.to_string(), + endpoint_path: endpoint_path.to_string(), + ..Default::default() + } +} + +/// `(path, protocol, enforcement)` for every inspected path of the endpoint. +fn inspected(engine: &CedarEngine) -> Vec<(String, L7Protocol, L7Enforcement)> { + engine + .l7_endpoints(HOST, 443) + .map(|(path, endpoint)| (path, endpoint.protocol, endpoint.enforcement)) + .collect() +} + +#[test] +fn each_path_is_inspected_with_its_own_protocol() { + let policy = format!( + "{}{}", + http_policy( + r#"@path("/repos/**")"#, + "permit", + r#"context.path like("/repos/**", "/")"# + ), + http_policy( + r#"@path("/graphql") @protocol("graphql")"#, + "permit", + r#"context.graphql_operation_type == "query""# + ), + ); + let engine = engine(&policy); + assert_eq!( + inspected(&engine), + vec![ + ( + "/graphql".to_string(), + L7Protocol::Graphql, + L7Enforcement::Enforce + ), + ( + "/repos/**".to_string(), + L7Protocol::Rest, + L7Enforcement::Enforce + ), + ] + ); + assert_eq!( + engine.l7_endpoint(HOST, 443), + None, + "no policy without @path" + ); + assert_eq!( + engine + .l7_endpoint_at("API.example.com.", 443, "/graphql") + .map(|endpoint| endpoint.protocol), + Some(L7Protocol::Graphql), + "lookup normalizes the requested host" + ); +} + +#[test] +fn policies_without_a_path_declare_the_path_less_endpoint() { + let policy = format!( + "{}{}", + http_policy(r#"@protocol("json-rpc")"#, "permit", "true"), + http_policy( + r#"@path("/v1/**")"#, + "forbid", + r#"context.method == "DELETE""# + ), + ); + let engine = engine(&policy); + assert_eq!( + inspected(&engine), + vec![ + (String::new(), L7Protocol::JsonRpc, L7Enforcement::Enforce), + ( + "/v1/**".to_string(), + L7Protocol::Rest, + L7Enforcement::Enforce + ), + ] + ); +} + +#[test] +fn path_selects_inspection_but_not_which_policies_apply() { + // The /admin forbid applies to a request routed to any path; only its + // condition limits it. + let policy = format!( + "{}{}", + http_policy(r#"@path("/api/**")"#, "permit", "true"), + http_policy( + r#"@path("/admin/**")"#, + "forbid", + r#"context.method == "DELETE""# + ), + ); + let engine = engine(&policy); + let evaluate = |method, path, endpoint_path| { + engine + .evaluate_l7(&request(method, path, endpoint_path)) + .unwrap() + .is_allow() + }; + assert!(!evaluate("DELETE", "/admin/x", "/admin/**")); + assert!(!evaluate("DELETE", "/api/x", "/api/**")); + assert!(evaluate("GET", "/api/x", "/api/**")); +} + +#[test] +fn enforcement_is_computed_per_path() { + // An audit path beside an enforced path: as with YAML endpoints sharing + // a host:port, the routed path's mode decides whether a denial blocks. + // The audit path is reported as a warning. A staged forbid on the + // enforced path never decides. + let policy = format!( + "{}{}{}", + http_policy( + r#"@path("/graphql") @protocol("graphql") @enforcement("audit")"#, + "permit", + r#"context.path == "/graphql" && context.graphql_operation_type == "query""# + ), + http_policy( + r#"@path("/api/**")"#, + "permit", + r#"context.method == "GET" && context.path like("/api/**", "/")"# + ), + http_policy( + r#"@path("/api/**") @enforcement("staged") @id("no-delete")"#, + "forbid", + r#"context.path like("/api/private/**", "/")"# + ), + ); + let engine = engine(&policy); + assert_eq!( + inspected(&engine), + vec![ + ( + "/api/**".to_string(), + L7Protocol::Rest, + L7Enforcement::Enforce + ), + ( + "/graphql".to_string(), + L7Protocol::Graphql, + L7Enforcement::Audit + ), + ] + ); + assert_eq!( + engine.warnings(), + [AnalysisWarning::AuditPathOnEnforcedEndpoint { + endpoint: format!("{HOST}:443"), + path: "/graphql".to_string(), + }] + ); + let warning = engine.warnings()[0].to_string(); + assert!( + warning.starts_with(&format!("path `/graphql` of {HOST}:443 is audit-only")), + "{warning}" + ); + + // Routed to the audit path, every deciding policy decides, so the relay + // can log the denial it then forwards. + let mut mutation = request("POST", "/graphql", "/graphql"); + mutation.graphql_operation_type = "mutation".to_string(); + mutation.graphql_request = true; + let evaluation = engine.evaluate_l7(&mutation).unwrap(); + assert!(!evaluation.is_allow()); + assert_eq!(evaluation.staged, None); + + // Routed to the enforced path, its staged forbid is only compared. + let private = engine + .evaluate_l7(&request("GET", "/api/private/x", "/api/**")) + .unwrap(); + assert!(private.is_allow()); + assert!(private.staged.is_some_and(|staged| !staged.is_allow())); +} + +#[test] +fn enforced_path_less_forbid_does_not_block_on_an_audit_only_path() { + // `/admin/**` is an audit path: the enforced forbid's denial is + // returned, and the relay logs and forwards the request, as YAML does for + // a request routed to an `enforcement: audit` endpoint. The load-time + // warning tells the author. The audit path's own forbid decides requests + // routed to the enforced path-less endpoint. + let policy = format!( + "{}{}{}", + http_policy("", "permit", r#"context.method == "GET""#), + http_policy( + r#"@id("no-admin")"#, + "forbid", + r#"context.path like("/admin/**", "/")"# + ), + http_policy( + r#"@path("/admin/**") @enforcement("audit")"#, + "forbid", + r#"context.method == "DELETE""# + ), + ); + let engine = engine(&policy); + assert_eq!( + inspected(&engine), + vec![ + (String::new(), L7Protocol::Rest, L7Enforcement::Enforce), + ( + "/admin/**".to_string(), + L7Protocol::Rest, + L7Enforcement::Audit + ), + ] + ); + assert_eq!( + engine.warnings(), + [AnalysisWarning::AuditPathOnEnforcedEndpoint { + endpoint: format!("{HOST}:443"), + path: "/admin/**".to_string(), + }] + ); + let evaluation = engine + .evaluate_l7(&request("GET", "/admin/users", "/admin/**")) + .unwrap(); + assert!( + !evaluation.is_allow(), + "the enforced forbid denies; the audit path forwards the denial" + ); + assert_eq!(evaluation.staged, None, "nothing is staged"); + let evaluation = engine + .evaluate_l7(&request("DELETE", "/users/1", "")) + .unwrap(); + assert!( + !evaluation.is_allow(), + "the audit path's forbid blocks a request routed to the enforced path-less endpoint" + ); + assert!( + engine.warnings()[0] + .to_string() + .contains("@enforcement(\"staged\")"), + "the warning names the staging annotation" + ); + + // The path-less endpoint can be the audit-only one too. + let policy = format!( + "{}{}", + http_policy(r#"@enforcement("audit")"#, "permit", "true"), + http_policy( + r#"@path("/api/**")"#, + "permit", + r#"context.method == "GET""# + ), + ); + let path_less = CedarEngine::from_policy_str(&policy).expect("policy must load"); + assert_eq!( + path_less.warnings(), + [AnalysisWarning::AuditPathOnEnforcedEndpoint { + endpoint: format!("{HOST}:443"), + path: String::new(), + }] + ); + assert!( + path_less.warnings()[0].to_string().starts_with(&format!( + "the path-less endpoint of {HOST}:443 is audit-only" + )), + "{}", + path_less.warnings()[0] + ); +} + +#[test] +fn no_warning_for_an_all_enforced_or_all_audit_endpoint() { + // Enforced paths, with a staged policy on one of them. + let enforced = format!( + "{}{}{}", + http_policy( + r#"@path("/api/**")"#, + "permit", + r#"context.method == "GET""# + ), + http_policy( + r#"@path("/api/**") @enforcement("staged")"#, + "forbid", + r#"context.method == "DELETE""# + ), + http_policy( + r#"@path("/graphql") @protocol("graphql")"#, + "permit", + "true" + ), + ); + assert_eq!(engine(&enforced).warnings(), []); + + // Audit-only paths with no enforced policy anywhere on the endpoint. + let audited = format!( + "{}{}", + http_policy( + r#"@enforcement("audit")"#, + "permit", + r#"context.method == "GET""# + ), + http_policy( + r#"@path("/api/**") @enforcement("audit")"#, + "forbid", + r#"context.method == "DELETE""# + ), + ); + assert_eq!(engine(&audited).warnings(), []); +} + +#[test] +fn an_endpoint_of_only_audit_policies_audits_every_path() { + let policy = format!( + "{}{}{}", + http_policy( + r#"@enforcement("audit")"#, + "permit", + r#"context.method == "GET""# + ), + http_policy( + r#"@path("/graphql") @protocol("graphql") @enforcement("audit")"#, + "permit", + r#"context.graphql_operation_type == "query""# + ), + http_policy( + r#"@path("/api/**") @enforcement("audit")"#, + "forbid", + r#"context.method == "DELETE""# + ), + ); + let engine = engine(&policy); + assert_eq!( + inspected(&engine), + vec![ + (String::new(), L7Protocol::Rest, L7Enforcement::Audit), + ( + "/api/**".to_string(), + L7Protocol::Rest, + L7Enforcement::Audit + ), + ( + "/graphql".to_string(), + L7Protocol::Graphql, + L7Enforcement::Audit + ), + ] + ); + // On an audit endpoint every deciding policy decides, so the relay can + // log the denial it then forwards, and nothing is staged. + let evaluation = engine + .evaluate_l7(&request("DELETE", "/api/x", "/api/**")) + .unwrap(); + assert!(!evaluation.is_allow()); + assert_eq!(evaluation.staged, None); +} + +#[test] +fn resource_protocol_is_the_routed_paths_protocol() { + let policy = format!( + "{}{}", + http_policy( + r#"@path("/graphql") @protocol("graphql")"#, + "permit", + r#"resource.protocol == "graphql""# + ), + http_policy( + r#"@path("/ws") @protocol("websocket-graphql")"#, + "permit", + "false" + ), + ); + let engine = engine(&policy); + let allowed = |endpoint_path| { + engine + .evaluate_l7(&request("POST", "/graphql", endpoint_path)) + .unwrap() + .is_allow() + }; + assert!(allowed("/graphql")); + assert!(!allowed("/ws")); +} + +#[test] +fn websocket_protocols_are_accepted() { + for (annotation, protocol, config_label, graphql_messages) in [ + ("websocket", L7Protocol::Websocket, "websocket", false), + ( + "websocket-graphql", + L7Protocol::WebsocketGraphql, + "websocket", + true, + ), + ] { + let engine = engine(&http_policy( + &format!(r#"@protocol("{annotation}")"#), + "permit", + r#"context.method == "GET" + || (context.method == "WEBSOCKET_TEXT" && context.graphql_operation_type == "query")"#, + )); + let declared = engine.l7_protocol(HOST, 443).expect("inspected"); + assert_eq!(declared, protocol, "{annotation}"); + assert_eq!(declared.as_str(), annotation); + assert_eq!(declared.config_label(), config_label); + assert_eq!(declared.websocket_graphql_messages(), graphql_messages); + } +} + +#[test] +fn rejects_conflicting_protocols_for_one_path_only() { + let error = rejection(&format!( + "{}{}", + http_policy(r#"@path("/rpc") @protocol("json-rpc")"#, "permit", "true"), + http_policy(r#"@path("/rpc") @protocol("mcp")"#, "permit", "true"), + )); + assert!( + matches!(&error, CedarEngineError::ConflictingL7Protocol { endpoint, .. } + if endpoint == "api.example.com:443/rpc"), + "{error}" + ); +} + +#[test] +fn rejects_invalid_paths() { + for path in ["", "repos/**", "*"] { + let error = rejection(&http_policy( + &format!(r#"@path("{path}")"#), + "permit", + "true", + )); + assert!( + matches!(error, CedarEngineError::InvalidL7Path { .. }), + "{path:?}: {error}" + ); + } + for path in ["**", "/**", "/v1/*/items", "/graphql"] { + engine(&http_policy( + &format!(r#"@path("{path}")"#), + "permit", + "true", + )); + } +} + +#[test] +fn rejects_path_on_other_actions() { + let error = rejection( + r#" +@path("/v1/**") +permit(principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +"#, + ); + assert!( + matches!(error, CedarEngineError::UnsupportedPolicy { .. }), + "{error}" + ); +} + +#[test] +fn rejects_equally_specific_overlapping_paths_that_inspect_differently() { + let policy = |first: &str, second: &str, second_protocol: &str| { + format!( + "{}{}", + http_policy(&format!(r#"@path("{first}")"#), "permit", "true"), + http_policy( + &format!(r#"@path("{second}") @protocol("{second_protocol}")"#), + "permit", + "true" + ), + ) + }; + // `""` and `**` both match every path with no specificity. + for (first, second) in [("/a/*", "/*/b"), ("/ab/**", "/ab/*")] { + let error = rejection(&policy(first, second, "graphql")); + assert!( + matches!(error, CedarEngineError::AmbiguousL7Paths { .. }), + "{first} {second}: {error}" + ); + } + let error = rejection(&format!( + "{}{}", + http_policy("", "permit", "true"), + http_policy(r#"@path("**") @protocol("mcp")"#, "permit", "true"), + )); + assert!( + matches!(error, CedarEngineError::AmbiguousL7Paths { .. }), + "{error}" + ); + + // Literal paths that differ never match one request, and paths with the + // same inspection agree whichever the relay picks. + engine(&policy("/rest", "/rpcs", "json-rpc")); + engine(&policy("/a/*", "/*/b", "rest")); + engine(&policy("/x/**", "/y/**", "graphql")); +} + +#[test] +fn rejects_a_generic_message_permit_on_a_websocket_graphql_path() { + // YAML rejects a `WEBSOCKET_TEXT` rule on a WebSocket endpoint with + // GraphQL operation policy, so a permit that could allow a message + // without a GraphQL condition is rejected. + let ws = r#"@path("/ws") @protocol("websocket-graphql") @id("ws")"#; + for condition in [ + "true", + r#"context.path == "/ws""#, + r#"context.method == "WEBSOCKET_TEXT""#, + r#"context.method == "GET" || context.method == "WEBSOCKET_TEXT""#, + r#"["GET", "WEBSOCKET_TEXT"].contains(context.method)"#, + r#"context.method != "POST""#, + // A message disjunct without a GraphQL condition beside one with. + r#"context.method == "GET" || context.method == "WEBSOCKET_TEXT" + || (context.graphql_operation_type == "query" && context.graphql_fields.contains("viewer"))"#, + r#"context.graphql_operation_type == "query" || context.method == "WEBSOCKET_TEXT""#, + r#"context.graphql_operation_type == "query" || context.path == "/ws""#, + r#"!(context.method == "GET") && context.path == "/ws""#, + r#"if context.method == "GET" then true else context.path == "/ws""#, + ] { + let error = rejection(&http_policy(ws, "permit", condition)); + assert!( + matches!(&error, CedarEngineError::GenericWebsocketGraphqlPermit { policy_id, path } + if policy_id == "ws" && path == "/ws"), + "{condition}: {error}" + ); + } + // The protocol may come from another policy of the path. + let error = rejection(&format!( + "{}{}", + http_policy( + r#"@path("/ws") @protocol("websocket-graphql")"#, + "forbid", + "false" + ), + http_policy( + r#"@path("/ws") @id("upgrade")"#, + "permit", + r#"context.path == "/ws""# + ), + )); + assert!( + matches!(&error, CedarEngineError::GenericWebsocketGraphqlPermit { policy_id, .. } + if policy_id == "upgrade"), + "{error}" + ); + // Permits that condition on a GraphQL field, pin the method away from + // WEBSOCKET_TEXT in a top-level conjunct, or sit on another protocol + // load; so do forbids. + for (annotations, effect, condition) in [ + ( + ws, + "permit", + r#"context.graphql_operation_type == "subscription""#, + ), + // Every disjunct reads a GraphQL field. + ( + ws, + "permit", + r#"context.graphql_operation_type == "query" || context.graphql_fields.contains("messageAdded")"#, + ), + ( + ws, + "permit", + r#"(context.method == "WEBSOCKET_TEXT" && context.graphql_operation_type == "query") + || (context.path == "/ws" && context.graphql_operation_name == "Feed")"#, + ), + ( + ws, + "permit", + r#"context.method == "GET" || (context.method == "WEBSOCKET_TEXT" && context.graphql_fields.contains("x"))"#, + ), + ( + ws, + "permit", + r#"context.method == "GET" && context.path == "/ws""#, + ), + (ws, "permit", r#""GET" == context.method"#), + (ws, "permit", r#"["GET", "HEAD"].contains(context.method)"#), + (ws, "permit", "false"), + ( + ws, + "permit", + r#"context.path == "/ws" && !(context.method != "GET")"#, + ), + ( + ws, + "permit", + r#"if context.method == "GET" then context.path == "/ws" else context.graphql_operation_type == "query""#, + ), + ( + ws, + "permit", + r#"context.path == "/ws" && (context.method == "GET" || !(context.graphql_operation_type == "mutation"))"#, + ), + (ws, "forbid", "true"), + (r#"@path("/ws") @protocol("websocket")"#, "permit", "true"), + ( + r#"@path("/graphql") @protocol("graphql")"#, + "permit", + "true", + ), + ] { + engine(&http_policy(annotations, effect, condition)); + } +} + +/// A broad REST path beside a GraphQL path, with `rest_scope` appended to +/// the REST permit's condition. +fn rest_and_graphql(rest_scope: &str) -> String { + format!( + "{}{}{}", + http_policy( + r#"@path("/**") @id("rest")"#, + "permit", + &format!(r#"context.method == "GET" && context.path like("/**", "/"){rest_scope}"#) + ), + http_policy( + r#"@path("/graphql") @protocol("graphql") @id("graphql")"#, + "permit", + r#"context.path == "/graphql" && context.graphql_operation_type == "query""# + ), + http_policy( + r#"@path("/**") @id("no-delete")"#, + "forbid", + r#"context.graphql_fields.contains("deleteRepository")"# + ), + ) +} + +/// A GraphQL `operation` with `fields`, sent as `GET /graphql`. +fn graphql(operation: &str, fields: &[&str]) -> HttpRequestInput { + HttpRequestInput { + graphql_operation_type: operation.to_string(), + graphql_fields: fields.iter().map(ToString::to_string).collect(), + graphql_request: true, + ..request("GET", "/graphql", "/graphql") + } +} + +#[test] +fn a_graphql_path_is_authoritative_for_the_requests_it_parses() { + // A permit of another path that is not aware of GraphQL never allows a + // GraphQL operation, as a YAML GraphQL endpoint is authoritative for the + // requests it parses. Forbids still apply. + let engine = engine(&rest_and_graphql("")); + let mutation = engine + .evaluate_l7(&graphql("mutation", &["createIssue"])) + .unwrap(); + assert_eq!( + mutation.decision, + Decision::Deny { + matched_policies: Vec::new() + }, + "the REST permit is left out" + ); + let query = engine.evaluate_l7(&graphql("query", &["viewer"])).unwrap(); + assert!( + matches!(&query.decision, Decision::Allow { matched_policies } if matched_policies == &["graphql"]), + "{query:?}" + ); + let delete = engine + .evaluate_l7(&graphql("query", &["deleteRepository"])) + .unwrap(); + assert!( + matches!(&delete.decision, Decision::Deny { matched_policies } if matched_policies == &["no-delete"]), + "{delete:?}" + ); + // A request on the GraphQL path the proxy did not parse as GraphQL, and + // requests on the REST path, see every permit. + let plain = HttpRequestInput { + graphql_request: false, + ..request("GET", "/graphql", "/graphql") + }; + assert!(engine.evaluate_l7(&plain).unwrap().is_allow()); + assert!( + engine + .evaluate_l7(&request("GET", "/repos", "/**")) + .unwrap() + .is_allow() + ); +} + +#[test] +fn graphql_aware_permits_of_other_paths_still_apply() { + for rest_scope in [ + r#" && resource.protocol != "sql""#, + r#" && context.graphql_operation_type != "subscription""#, + ] { + let engine = engine(&rest_and_graphql(rest_scope)); + let mutation = engine + .evaluate_l7(&graphql("mutation", &["createIssue"])) + .unwrap(); + assert!( + matches!(&mutation.decision, Decision::Allow { matched_policies } if matched_policies == &["rest"]), + "{rest_scope}: {mutation:?}" + ); + } + // A permit attached to the GraphQL path itself always applies. + let own = format!( + "{}{}", + http_policy( + r#"@path("/graphql") @protocol("graphql") @id("all")"#, + "permit", + r#"context.path == "/graphql""# + ), + http_policy(r#"@path("/**")"#, "permit", "false"), + ); + let evaluation = engine(&own) + .evaluate_l7(&graphql("mutation", &["x"])) + .unwrap(); + assert!(evaluation.is_allow(), "{evaluation:?}"); +} + +#[test] +fn websocket_graphql_messages_are_authoritative_but_the_upgrade_is_not() { + let policy = format!( + "{}{}", + http_policy( + r#"@path("/**") @id("rest")"#, + "permit", + r#"["GET", "WEBSOCKET_TEXT"].contains(context.method)"# + ), + http_policy( + r#"@path("/ws") @protocol("websocket-graphql") @id("ws")"#, + "permit", + r#"context.path == "/ws" && context.graphql_operation_type == "subscription""# + ), + ); + let engine = engine(&policy); + // The upgrade is a GET the proxy does not parse as GraphQL. + let upgrade = engine.evaluate_l7(&request("GET", "/ws", "/ws")).unwrap(); + assert!( + matches!(&upgrade.decision, Decision::Allow { matched_policies } if matched_policies == &["rest"]), + "{upgrade:?}" + ); + let message = |operation: &str| HttpRequestInput { + graphql_operation_type: operation.to_string(), + graphql_request: true, + ..request("WEBSOCKET_TEXT", "/ws", "/ws") + }; + assert!( + matches!(&engine.evaluate_l7(&message("subscription")).unwrap().decision, + Decision::Allow { matched_policies } if matched_policies == &["ws"]) + ); + assert!(!engine.evaluate_l7(&message("mutation")).unwrap().is_allow()); +} diff --git a/crates/openshell-policy-cedar/tests/network.rs b/crates/openshell-policy-cedar/tests/network.rs new file mode 100644 index 0000000000..7702486b29 --- /dev/null +++ b/crates/openshell-policy-cedar/tests/network.rs @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end checks that [`CedarEngine`] evaluates `NetworkConnect` +//! requests against the canonical schema +//! (`openshell_policy_cedar_schema::SANDBOX_SCHEMA_SRC`) and +//! `tests/fixtures/policies.cedar` the way the policy authoring intends: +//! identity-guarded, binary-restricted per-endpoint allowlisting. + +use openshell_policy_cedar::{CedarEngine, CedarEngineError, Decision, NetworkConnectRequest}; + +const POLICIES: &str = include_str!("fixtures/policies.cedar"); + +fn engine() -> CedarEngine { + CedarEngine::from_policy_str(POLICIES).expect("fixture policy set must parse") +} + +fn sandbox_request(host: &str, port: u16) -> NetworkConnectRequest { + NetworkConnectRequest { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + host: host.to_string(), + port, + binary_path: "/sandbox/.venv/bin/python3".to_string(), + ancestors: Vec::new(), + binary_aliases: Vec::new(), + binary_globs: Vec::new(), + destination_ip: None, + } +} + +#[test] +fn allows_pypi_get_from_sandbox_identity() { + let decision = engine() + .evaluate_network(&sandbox_request("pypi.org", 443)) + .expect("request must be representable in the schema"); + assert!(matches!(decision, Decision::Allow { .. }), "{decision:?}"); +} + +#[test] +fn allows_nvidia_api_post_from_sandbox_identity() { + let decision = engine() + .evaluate_network(&sandbox_request("integrate.api.nvidia.com", 443)) + .expect("request must be representable in the schema"); + assert!(matches!(decision, Decision::Allow { .. }), "{decision:?}"); +} + +#[test] +fn denies_any_connect_without_the_sandbox_identity() { + let mut request = sandbox_request("pypi.org", 443); + request.user = "root".to_string(); + request.group = "root".to_string(); + let decision = engine() + .evaluate_network(&request) + .expect("request must be representable in the schema"); + assert!(matches!(decision, Decision::Deny { .. }), "{decision:?}"); +} + +#[test] +fn denies_connect_to_an_endpoint_with_no_matching_policy() { + let decision = engine() + .evaluate_network(&sandbox_request("example.com", 443)) + .expect("request must be representable in the schema"); + assert!(matches!(decision, Decision::Deny { .. }), "{decision:?}"); +} + +#[test] +fn allows_a_dns_resolved_host_with_a_trailing_dot() { + // DNS-resolved hostnames (as published by policy_dns and read back via + // ResolvedEndpointStore::lookup) are absolute FQDNs with a trailing + // dot; the authored policy host literal never has one. + let decision = engine() + .evaluate_network(&sandbox_request("pypi.org.", 443)) + .expect("request must be representable in the schema"); + assert!(matches!(decision, Decision::Allow { .. }), "{decision:?}"); +} + +#[test] +fn rejects_a_policy_that_fails_schema_validation() { + // A misspelled context field would otherwise make Cedar skip this + // forbid at request time, letting the permit allow `nc`. + let error = CedarEngine::from_policy_str( + r#" +permit(principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"example.com:443"); +forbid(principal, action == Sandbox::Action::"NetworkConnect", resource) +when { context.binray_path == "/usr/bin/nc" }; +"#, + ) + .expect_err("policy must be rejected"); + assert!( + matches!(error, CedarEngineError::PolicyValidation { .. }), + "{error}" + ); +} + +#[test] +fn fails_the_request_when_a_policy_errors_during_evaluation() { + // Integer overflow passes validation but errors at request time. Cedar + // would skip the erroring forbid and allow. + let engine = CedarEngine::from_policy_str( + r#" +permit(principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443"); +forbid(principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.port * 9223372036854775807 > 0 }; +"#, + ) + .expect("policy must load"); + let error = engine + .evaluate_network(&sandbox_request("pypi.org", 443)) + .expect_err("evaluation error must fail the request"); + assert!( + matches!(error, CedarEngineError::Evaluation { .. }), + "{error}" + ); +} diff --git a/crates/openshell-policy-cedar/tests/query.rs b/crates/openshell-policy-cedar/tests/query.rs new file mode 100644 index 0000000000..d990d44a5a --- /dev/null +++ b/crates/openshell-policy-cedar/tests/query.rs @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks for `HttpRequest` query parameters: `context.query` tags, one +//! evaluation per combination of repeated values, the cap on combinations, +//! and how the combined decision interacts with audit and staged policies. + +use std::collections::BTreeMap; + +use openshell_policy_cedar::{ + CedarEngine, CedarEngineError, Decision, HttpRequestInput, MAX_QUERY_COMBINATIONS, +}; + +const ENDPOINT: &str = r#"Sandbox::NetworkEndpoint::"api.example.com:443""#; + +fn engine(policy: &str) -> CedarEngine { + CedarEngine::from_policy_str(policy).expect("policy must load") +} + +/// An `HttpRequest` policy on the test endpoint. +fn http_policy(annotations: &str, effect: &str, condition: &str) -> String { + format!( + r#"{annotations} +{effect} ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == {ENDPOINT} +) +when {{ {condition} }}; +"# + ) +} + +/// The condition that `key` is present and its value matches `pattern`, a +/// glob whose `*` stays within one `.`-separated segment, as YAML query +/// globs do. +fn tag_like(key: &str, pattern: &str) -> String { + format!( + r#"context.query.hasTag("{key}") && context.query.getTag("{key}") like("{pattern}", ".")"# + ) +} + +/// A `GET /` request with `query`. +fn request(query: &[(&str, &[&str])]) -> HttpRequestInput { + HttpRequestInput { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + binary_path: "/usr/bin/curl".to_string(), + host: "api.example.com".to_string(), + port: 443, + method: "GET".to_string(), + path: "/".to_string(), + query: query + .iter() + .map(|(key, values)| { + ( + (*key).to_string(), + values.iter().map(ToString::to_string).collect(), + ) + }) + .collect::>(), + ..Default::default() + } +} + +fn allows(engine: &CedarEngine, query: &[(&str, &[&str])]) -> bool { + engine + .evaluate_l7(&request(query)) + .expect("request evaluates") + .is_allow() +} + +#[test] +fn query_values_are_tags_of_context_query() { + let engine = engine(&http_policy("", "permit", &tag_like("tag", "foo-*"))); + assert!(allows(&engine, &[("tag", &["foo-a"])])); + assert!(!allows(&engine, &[("tag", &["bar"])])); + assert!(!allows(&engine, &[]), "a missing key has no tag"); + assert!( + allows(&engine, &[("tag", &["foo-a"]), ("extra", &["x"])]), + "other keys do not matter" + ); +} + +#[test] +fn a_permit_must_allow_every_repeated_value() { + let engine = engine(&http_policy("", "permit", &tag_like("tag", "foo-*"))); + assert!(allows(&engine, &[("tag", &["foo-a", "foo-b"])])); + assert!(!allows(&engine, &[("tag", &["foo-a", "evil"])])); +} + +#[test] +fn one_permit_must_allow_every_combination() { + // As with two YAML allow rules, each value matching some rule is not + // enough: one rule must match them all. + let split = format!( + "{}{}", + http_policy("", "permit", &tag_like("tag", "foo-*")), + http_policy("", "permit", &tag_like("tag", "bar-*")), + ); + assert!(!allows(&engine(&split), &[("tag", &["foo-a", "bar-b"])])); + assert!(allows(&engine(&split), &[("tag", &["foo-a", "foo-b"])])); + + let any = http_policy( + "", + "permit", + r#"context.query.hasTag("tag") + && (context.query.getTag("tag") like("foo-*", ".") + || context.query.getTag("tag") like("bar-*", "."))"#, + ); + assert!(allows(&engine(&any), &[("tag", &["foo-a", "bar-b"])])); +} + +#[test] +fn a_forbid_denies_when_any_value_matches() { + let policy = format!( + "{}{}", + http_policy("", "permit", "true"), + http_policy("", "forbid", &tag_like("force", "true")), + ); + let engine = engine(&policy); + assert!(!allows(&engine, &[("force", &["true", "false"])])); + assert!(!allows(&engine, &[("force", &["false", "true"])])); + assert!(allows(&engine, &[("force", &["false"])])); + assert!( + allows(&engine, &[]), + "a forbid on a missing key does not apply" + ); +} + +#[test] +fn keys_combine_independently() { + // A forbid fires when each of its keys has one matching value. + let forbid = format!( + "{}{}", + http_policy("", "permit", "true"), + http_policy( + "", + "forbid", + &format!("{} && {}", tag_like("a", "x"), tag_like("b", "y")), + ), + ); + let forbid = engine(&forbid); + assert!(!allows(&forbid, &[("a", &["x", "z"]), ("b", &["w", "y"])])); + assert!(allows(&forbid, &[("a", &["x", "z"]), ("b", &["w"])])); + + // A permit must allow every value of each key. + let permit = engine(&http_policy( + "", + "permit", + &format!("{} && {}", tag_like("a", "x*"), tag_like("b", "y*")), + )); + assert!(allows(&permit, &[("a", &["x1", "x2"]), ("b", &["y1"])])); + assert!(!allows( + &permit, + &[("a", &["x1", "x2"]), ("b", &["y1", "n"])] + )); +} + +#[test] +fn empty_values_are_matched_as_written() { + let policy = format!( + "{}{}", + http_policy("", "permit", "true"), + http_policy("", "forbid", &tag_like("name", "")), + ); + let engine = engine(&policy); + assert!(!allows(&engine, &[("name", &[""])])); + assert!(allows(&engine, &[("name", &["present"])])); +} + +#[test] +fn a_key_without_values_denies() { + let engine = engine(&http_policy("", "permit", "!context.query.hasTag(\"tag\")")); + assert!(allows(&engine, &[])); + assert!( + !allows(&engine, &[("tag", &[])]), + "no combination to evaluate" + ); +} + +#[test] +fn an_allow_names_the_permits_that_allowed_every_combination() { + let policy = format!( + "{}{}", + http_policy(r#"@id("foo")"#, "permit", &tag_like("tag", "foo-*")), + http_policy(r#"@id("any")"#, "permit", "true"), + ); + let evaluation = engine(&policy) + .evaluate_l7(&request(&[("tag", &["foo-a", "bar"])])) + .unwrap(); + assert_eq!( + evaluation.decision, + Decision::Allow { + matched_policies: vec!["any".to_string()] + } + ); +} + +#[test] +fn combinations_over_the_cap_are_not_evaluated() { + let engine = engine(&http_policy( + "", + "permit", + &format!("{} && {}", tag_like("a", "*"), tag_like("b", "*")), + )); + // 64 * 64 combinations are evaluated; 65 * 64 are over the cap. + let values: Vec = (0..65).map(|value| value.to_string()).collect(); + let values: Vec<&str> = values.iter().map(String::as_str).collect(); + assert_eq!(MAX_QUERY_COMBINATIONS, 4096); + assert!(allows( + &engine, + &[("a", &values[..64]), ("b", &values[..64])] + )); + let error = engine + .evaluate_l7(&request(&[("a", &values), ("b", &values[..64])])) + .unwrap_err(); + assert!( + matches!( + error, + CedarEngineError::TooManyQueryCombinations { + combinations: 4160, + limit: MAX_QUERY_COMBINATIONS, + } + ), + "{error}" + ); + // One key with more values than the cap. + let many: Vec = (0..=MAX_QUERY_COMBINATIONS) + .map(|value| value.to_string()) + .collect(); + let many: Vec<&str> = many.iter().map(String::as_str).collect(); + assert!(allows( + &engine, + &[("a", &many[..MAX_QUERY_COMBINATIONS]), ("b", &["x"])] + )); + assert!(matches!( + engine.evaluate_l7(&request(&[("a", &many), ("b", &["x"])])), + Err(CedarEngineError::TooManyQueryCombinations { + combinations, + limit: MAX_QUERY_COMBINATIONS, + }) if combinations == MAX_QUERY_COMBINATIONS + 1 + )); +} + +#[test] +fn keys_no_policy_reads_do_not_multiply_evaluations() { + let values: Vec = (0..=MAX_QUERY_COMBINATIONS) + .map(|value| value.to_string()) + .collect(); + let values: Vec<&str> = values.iter().map(String::as_str).collect(); + let engine_reading = |condition: &str| engine(&http_policy("", "permit", condition)); + + assert!(allows( + &engine_reading(&tag_like("tag", "*")), + &[("tag", &["a"]), ("ids", &values)] + )); + // A computed key may be any key, so every key counts. + let computed = engine_reading("!context.query.hasTag(context.method)"); + assert!(allows(&computed, &[("ids", &values[..2])])); + assert!(matches!( + computed.evaluate_l7(&request(&[("ids", &values)])), + Err(CedarEngineError::TooManyQueryCombinations { .. }) + )); +} + +#[test] +fn audit_endpoints_decide_over_every_combination() { + let engine = engine(&http_policy( + r#"@enforcement("audit")"#, + "permit", + &tag_like("tag", "foo-*"), + )); + let evaluation = engine + .evaluate_l7(&request(&[("tag", &["foo-a", "evil"])])) + .unwrap(); + assert!(!evaluation.is_allow(), "the relay logs this denial"); + assert_eq!(evaluation.staged, None); +} + +#[test] +fn staged_policies_combine_like_deciding_ones() { + // A staged forbid that matches one value is reported. + let staged_forbid = format!( + "{}{}", + http_policy("", "permit", "true"), + http_policy( + r#"@enforcement("staged") @id("no-force")"#, + "forbid", + &tag_like("force", "true"), + ), + ); + let evaluation = engine(&staged_forbid) + .evaluate_l7(&request(&[("force", &["false", "true"])])) + .unwrap(); + assert!(evaluation.is_allow(), "a staged forbid never denies"); + assert_eq!( + evaluation.staged, + Some(Decision::Deny { + matched_policies: vec!["no-force".to_string()] + }) + ); + + // A staged permit that, with the enforced one, allows each value but not + // every value would not allow the request, so nothing is staged. + let staged_permit = format!( + "{}{}", + http_policy("", "permit", &tag_like("tag", "foo-*")), + http_policy( + r#"@enforcement("staged")"#, + "permit", + &tag_like("tag", "bar-*") + ), + ); + let engine = engine(&staged_permit); + let evaluation = engine + .evaluate_l7(&request(&[("tag", &["foo-a", "bar-b"])])) + .unwrap(); + assert!(!evaluation.is_allow()); + assert_eq!(evaluation.staged, None); + let evaluation = engine + .evaluate_l7(&request(&[("tag", &["bar-a", "bar-b"])])) + .unwrap(); + assert!(!evaluation.is_allow()); + assert!( + matches!(evaluation.staged, Some(Decision::Allow { .. })), + "{evaluation:?}" + ); +} + +#[test] +fn query_is_not_part_of_network_connect() { + let error = CedarEngine::from_policy_str( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { context.query.hasTag("tag") };"#, + ) + .unwrap_err(); + assert!( + matches!(error, CedarEngineError::PolicyValidation { .. }), + "{error}" + ); +} + +#[test] +fn get_tag_requires_has_tag() { + let error = CedarEngine::from_policy_str(&http_policy( + "", + "permit", + r#"context.query.getTag("tag") like "foo-*""#, + )) + .unwrap_err(); + assert!( + matches!(error, CedarEngineError::PolicyValidation { .. }), + "{error}" + ); +} diff --git a/crates/openshell-policy-cedar/tests/transport.rs b/crates/openshell-policy-cedar/tests/transport.rs new file mode 100644 index 0000000000..5902a84092 --- /dev/null +++ b/crates/openshell-policy-cedar/tests/transport.rs @@ -0,0 +1,308 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks `@transport("tcp")`, which marks a `NetworkConnect` permit's +//! endpoint for native TCP as YAML `protocol: tcp` does, and the load-time +//! rules YAML applies to such endpoints. + +use openshell_policy_cedar::{ + AuthorizedNetworkEndpoint, CedarEngine, CedarEngineError, NetworkConnectRequest, + NetworkTransport, +}; + +fn load(policy: &str) -> Result { + CedarEngine::from_policy_str(policy) +} + +fn endpoint(host: &str, ports: &[u16], transport: NetworkTransport) -> AuthorizedNetworkEndpoint { + AuthorizedNetworkEndpoint { + host: host.to_string(), + ports: ports.to_vec(), + transport, + allowed_ips: Vec::new(), + } +} + +const TCP_DATABASE: &str = r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example.com:5432") +when { context.binary_path == "/usr/bin/psql" }; +"#; + +#[test] +fn tcp_endpoints_are_dns_records_marked_tcp() { + let policy = format!( + r#"{TCP_DATABASE} +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ resource.host like("*.cache.example.com", ".") && resource.port == 6379 }}; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example.com:443"); +"# + ); + let engine = load(&policy).expect("policy loads"); + assert_eq!( + engine.dns_endpoints(), + [ + endpoint("*.cache.example.com", &[6379], NetworkTransport::Tcp), + endpoint("db.example.com", &[443], NetworkTransport::Default), + endpoint("db.example.com", &[5432], NetworkTransport::Tcp), + ] + ); + assert_eq!(NetworkTransport::Tcp.protocol(), Some("tcp")); + assert_eq!(NetworkTransport::Default.protocol(), None); +} + +#[test] +fn tcp_endpoints_authorize_as_plain_connections() { + let engine = load(TCP_DATABASE).expect("policy loads"); + let request = |binary: &str| NetworkConnectRequest { + user: "sandbox".to_string(), + group: "sandbox".to_string(), + host: "db.example.com".to_string(), + port: 5432, + binary_path: binary.to_string(), + ancestors: Vec::new(), + binary_aliases: Vec::new(), + binary_globs: Vec::new(), + destination_ip: None, + }; + assert!( + engine + .evaluate_network(&request("/usr/bin/psql")) + .unwrap() + .is_allow() + ); + assert!( + !engine + .evaluate_network(&request("/usr/bin/curl")) + .unwrap() + .is_allow() + ); + assert!(engine.l7_endpoint("db.example.com", 5432).is_none()); +} + +#[test] +fn rejects_unknown_transports() { + let error = load(&TCP_DATABASE.replace("\"tcp\"", "\"udp\"")).unwrap_err(); + assert!( + matches!(error, CedarEngineError::UnsupportedTransport { ref transport, .. } if transport == "udp"), + "{error}" + ); +} + +#[test] +fn rejects_transport_outside_network_connect_permits() { + for policy in [ + // A forbid. + r#"@transport("tcp") +forbid (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example.com:5432");"#, + // Another action alongside NetworkConnect. + r#"@transport("tcp") +permit (principal, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"db.example.com:5432");"#, + // A filesystem action. + r#"@transport("tcp") +permit (principal, action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/usr");"#, + ] { + let error = load(policy).unwrap_err(); + assert!( + matches!(error, CedarEngineError::UnsupportedPolicy { .. }), + "{policy}: {error}" + ); + } +} + +#[test] +fn rejects_hostless_tcp_permits() { + for condition in [ + // No host at all. + "resource.port == 5432", + // A host without a port. + r#"resource.host == "db.example.com""#, + // A host form policy DNS cannot match exactly. + r#"resource.host like "*.example.com" && resource.port == 5432"#, + ] { + let policy = format!( + r#"@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ {condition} }};"# + ); + let error = load(&policy).unwrap_err(); + assert!( + matches!(error, CedarEngineError::UnsupportedPolicy { ref reason, .. } if reason.contains("hostless")), + "{condition}: {error}" + ); + } +} + +#[test] +fn rejects_ip_literals_and_invalid_dns_hosts() { + for host in [ + "10.0.0.5", + "[::1]", + "::1", + "under score!.example.com", + &format!("{}.example.com", "a".repeat(64)), + ] { + let policy = TCP_DATABASE.replace("db.example.com", host); + let error = load(&policy).unwrap_err(); + assert!( + matches!(error, CedarEngineError::InvalidTcpHost { .. }), + "{host}: {error}" + ); + } + // The same IP literal without @transport is an ordinary endpoint. + load( + &TCP_DATABASE + .replace("@transport(\"tcp\")", "") + .replace("db.example.com", "10.0.0.5"), + ) + .expect("plain IP endpoints load"); +} + +#[test] +fn rejects_request_policies_on_tcp_endpoints() { + for (host, port) in [ + ("db.example.com", 5432), + // An inspected host a native TCP glob covers. + ("api.cache.example.com", 6379), + ] { + let policy = format!( + r#"{TCP_DATABASE} +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ resource.host like("*.cache.example.com", ".") && resource.port == 6379 }}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:{port}"); +"# + ); + let error = load(&policy).unwrap_err(); + assert!( + matches!(error, CedarEngineError::ConflictingTransport { ref reason, .. } if reason.contains("HttpRequest")), + "{host}:{port}: {error}" + ); + } + // Inspection on another port of the same host is independent. + let policy = format!( + r#"{TCP_DATABASE} +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"db.example.com:443"); +"# + ); + load(&policy).expect("other ports may be inspected"); +} + +#[test] +fn rejects_overlapping_endpoints_without_tcp() { + for other in [ + // The same endpoint, for another binary. + r#"resource == Sandbox::NetworkEndpoint::"db.example.com:5432")"#, + // A glob covering it. + r#"resource) when { resource.host like("*.example.com", ".") && resource.port == 5432 }"#, + r#"resource) when { resource.host like("**.example.com", ".") && resource.port == 5432 }"#, + ] { + let policy = format!( + r#"{TCP_DATABASE} +permit (principal, action == Sandbox::Action::"NetworkConnect", {other};"# + ); + let error = load(&policy).unwrap_err(); + assert!( + matches!(error, CedarEngineError::ConflictingTransport { .. }), + "{other}: {error}" + ); + } + for other in [ + // Another port. + r#"resource == Sandbox::NetworkEndpoint::"db.example.com:5433")"#, + // A glob that cannot match the host. + r#"resource) when { resource.host like("*.example.org", ".") && resource.port == 5432 }"#, + // A hostless permit on another port. + r"resource) when { resource.port == 5433 }", + r#"resource) when { resource.host like("*.com", ".") && resource.port == 5433 }"#, + ] { + let policy = format!( + r#"{TCP_DATABASE} +permit (principal, action == Sandbox::Action::"NetworkConnect", {other};"# + ); + load(&policy).unwrap_or_else(|error| panic!("{other}: {error}")); + } +} + +#[test] +fn rejects_permits_whose_host_cannot_be_derived_beside_tcp() { + // Each permit allows `db.example.com:5432` at CONNECT without + // `@transport("tcp")`, but names no endpoint policy DNS could derive, so + // it would otherwise be invisible to the overlap check. + for (condition, other_endpoint) in [ + // No host at all. + ("resource.port == 5432", "*:5432"), + // A wildcard top-level domain, which policy DNS cannot match. + ( + r#"resource.host like("**.com", ".") && resource.port == 5432"#, + "*:5432", + ), + ( + r#"resource.host like("*.com", ".") && resource.port == 5432"#, + "*:5432", + ), + // An undelimited `like`. + ( + r#"resource.host like "*.example.com" && resource.port == 5432"#, + "*:5432", + ), + // Hosts joined with `||`, which is not a host conjunct. + ( + r#"(resource.host == "db.example.com" || resource.host == "x.example.com") + && resource.port == 5432"#, + "*:5432", + ), + // Neither a host nor a port: it may allow any endpoint. + (r#"context.binary_path == "/usr/bin/psql""#, "*:*"), + // Two ports, so none is the port; it may allow any port. + (r"resource.port == 5432 && resource.port == 5432", "*:*"), + ] { + let policy = format!( + r#"{TCP_DATABASE} +@id("broad") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ {condition} }};"# + ); + let error = load(&policy).unwrap_err(); + assert!( + matches!( + &error, + CedarEngineError::ConflictingTransport { tcp_endpoint, other_endpoint: other, reason } + if tcp_endpoint == "db.example.com:5432" + && other == other_endpoint + && reason.contains("\"broad\"") + ), + "{condition}: {error}" + ); + // The same permit loads without a native TCP permit. + let plain = policy.replace(r#"@transport("tcp")"#, ""); + load(&plain).unwrap_or_else(|error| panic!("{condition}: {error}")); + } +} + +#[test] +fn overlapping_tcp_globs_are_one_transport() { + let policy = r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.example.com", ".") && resource.port == 5432 }; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("db-*.example.com", ".") && resource.port == 5432 }; +"#; + let error = load(policy).unwrap_err(); + assert!( + matches!(error, CedarEngineError::ConflictingTransport { .. }), + "{error}" + ); + let disjoint = policy.replace("db-*.example.com", "db-*.example.org"); + load(&disjoint).expect("disjoint globs load"); +} diff --git a/crates/openshell-policy-cedar/tests/validation.rs b/crates/openshell-policy-cedar/tests/validation.rs new file mode 100644 index 0000000000..4a87982e00 --- /dev/null +++ b/crates/openshell-policy-cedar/tests/validation.rs @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks that strict schema validation rejects policies reading attributes +//! the schema does not declare for their action, instead of loading a +//! policy that could never match. + +use openshell_policy_cedar::{CedarEngine, CedarEngineError}; + +fn validation_failure(policy: &str) -> String { + match CedarEngine::from_policy_str(policy) { + Err(CedarEngineError::PolicyValidation { reason }) => reason, + other => panic!("expected PolicyValidation, got {other:?}"), + } +} + +#[test] +fn network_connect_has_no_request_fields() { + // No request has been read at CONNECT time. A connection permit that + // read one of these used to compare against "" and silently deny every + // connection. + for (expected, condition) in [ + ("method", r#"context.method == "GET""#), + ("path", r#"context.path like("/repos/**", "/")"#), + ("command", r#"context.command == "SELECT""#), + // Cedar accepts a `has` test of an undeclared attribute, as always + // false; OpenShell rejects it, since the policy could never match. + ("`context has method`", "context has method"), + ("`context has path`", "!(context has path)"), + ] { + let reason = validation_failure(&format!( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when {{ {condition} }};"# + )); + assert!(reason.contains(expected), "{condition}: {reason}"); + } +} + +#[test] +fn has_tests_of_declared_attributes_load() { + // A forbid, since a permit may read `destination_ip` only in range tests. + let engine = CedarEngine::from_policy_str( + r#"forbid (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context has destination_ip && context has binary_path && principal has user + && resource has host_port && {workdir: "/sandbox"} has workdir }; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context has method && context has query };"#, + ); + assert!(engine.is_ok(), "{engine:?}"); +} + +#[test] +fn has_tests_are_checked_for_every_action_in_scope() { + let reason = validation_failure( + r#"permit ( + principal, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context has method };"#, + ); + assert!(reason.contains("action NetworkConnect"), "{reason}"); + + // `resource has host` is false for every file, and the unconstrained + // scope covers the filesystem actions. + let reason = + validation_failure("forbid (principal, action, resource) when { resource has host };"); + assert!(reason.contains("Sandbox::FilesystemPath"), "{reason}"); + let network_only = CedarEngine::from_policy_str( + r#"forbid (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource has host };"#, + ); + assert!(network_only.is_ok(), "{network_only:?}"); +} + +#[test] +fn a_policy_on_both_network_actions_cannot_read_request_fields() { + // The scope covers NetworkConnect, which has no `method`, so the policy + // fails validation; split it into one policy per action. + let reason = validation_failure( + r#"permit ( + principal, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.method == "GET" };"#, + ); + assert!(reason.contains("method"), "{reason}"); + + let split = CedarEngine::from_policy_str( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443"); +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context.method == "GET" };"#, + ); + assert!(split.is_ok(), "{split:?}"); +} + +#[test] +fn http_request_keeps_the_request_fields() { + let engine = CedarEngine::from_policy_str( + r#"permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context.method == "GET" && context.path == "/" && context.command == "" };"#, + ); + assert!(engine.is_ok(), "{engine:?}"); +} + +#[test] +fn process_has_no_workdir() { + // `workdir` was declared optional and never populated, so a condition + // on it could only ever be false. + for (expected, condition) in [ + ("`principal has workdir`", "principal has workdir"), + ( + "workdir", + r#"principal.workdir in Sandbox::FilesystemPath::"/sandbox""#, + ), + ] { + let reason = validation_failure(&format!( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when {{ {condition} }};"# + )); + assert!(reason.contains(expected), "{condition}: {reason}"); + } +} diff --git a/crates/openshell-policy-schema/src/lib.rs b/crates/openshell-policy-schema/src/lib.rs index 656433b794..a5a3e59931 100644 --- a/crates/openshell-policy-schema/src/lib.rs +++ b/crates/openshell-policy-schema/src/lib.rs @@ -199,6 +199,12 @@ pub struct PolicyDocument { pub network_policies: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub network_middlewares: BTreeMap, + /// Endpoint settings for a Cedar policy, authored only in a middleware + /// file. Each entry selects endpoints by host, port, and optional path, + /// and sets configuration (TLS, parsing, and protocol options) rather + /// than access. Policy files reject this section. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoint_settings: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -621,6 +627,11 @@ pub fn parse_policy_with_limits(source: &str, limits: ParseLimits) -> Result Result<()> { } } } + // Endpoint settings carry no protocol: the Cedar policy selects it, and + // MCP options apply only where it selects MCP. + for (index, endpoint) in document.endpoint_settings.iter().enumerate() { + if let Some(config) = &endpoint.mcp { + validate_mcp_config(config, &format!("endpoint_settings[{index}]"))?; + } + } Ok(()) } @@ -742,6 +760,7 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { "process", "network_policies", "network_middlewares", + "endpoint_settings", ], )? else { @@ -798,6 +817,10 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { // `config` is deliberately an open user-data map. } } + + for (index, endpoint) in sequence(root.get("endpoint_settings")).iter().enumerate() { + inspect_endpoint(endpoint, &format!("endpoint_settings[{index}]"))?; + } Ok(()) } diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index 0515808144..4b5d7e80f9 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -13,11 +13,19 @@ repository.workspace = true [dependencies] hickory-proto = { workspace = true } openshell-core = { path = "../openshell-core", default-features = false } +openshell-policy-cedar = { path = "../openshell-policy-cedar", optional = true } openshell-policy-schema = { path = "../openshell-policy-schema" } serde = { workspace = true } serde_json = { workspace = true } miette = { workspace = true } prost-types = { workspace = true } +[features] +default = ["cedar"] +# Validates Cedar-authored policies (`cedar_policy_source`). Without it, any +# policy with Cedar source fails validation as unsupported. Crates that only +# use this crate's constants and defaults disable it to avoid linking Cedar. +cedar = ["dep:openshell-policy-cedar"] + [lints] workspace = true diff --git a/crates/openshell-policy/src/compose.rs b/crates/openshell-policy/src/compose.rs index 905ab37707..e9f4093f5c 100644 --- a/crates/openshell-policy/src/compose.rs +++ b/crates/openshell-policy/src/compose.rs @@ -40,12 +40,19 @@ pub fn provider_rule_name(provider_name: &str) -> String { } } +/// Removes provider-derived entries, leaving the user-authored base policy. +/// +/// Clears both provider-composed `network_policies` entries and the +/// gateway-managed `provider_credential_rules` of a Cedar policy. Returns +/// whether anything was removed. pub fn strip_provider_rule_names(policy: &mut SandboxPolicy) -> bool { let original_len = policy.network_policies.len(); policy .network_policies .retain(|key, _| !is_provider_rule_name(key)); - policy.network_policies.len() != original_len + let had_credential_rules = !policy.provider_credential_rules.is_empty(); + policy.provider_credential_rules.clear(); + policy.network_policies.len() != original_len || had_credential_rules } /// Compose a normal sandbox policy from user-authored policy plus provider @@ -55,33 +62,45 @@ pub fn strip_provider_rule_names(policy: &mut SandboxPolicy) -> bool { /// static fields and user-authored network policies, then concatenates each /// provider rule under a reserved `_provider_*` key. Existing keys are not /// overwritten; a numeric suffix is added if provider rule names collide. +/// +/// For a Cedar policy, provider rules go into `provider_credential_rules` +/// instead of `network_policies`: the Cedar policy alone decides access, and +/// provider rules only supply endpoint settings for credential injection. #[must_use] pub fn compose_effective_policy( source_policy: &SandboxPolicy, provider_layers: &[ProviderPolicyLayer], ) -> SandboxPolicy { let mut effective = source_policy.clone(); + let rules = if effective.cedar_policy_source.is_empty() { + &mut effective.network_policies + } else { + &mut effective.provider_credential_rules + }; for layer in provider_layers { - let key = unique_provider_rule_key(&effective, &layer.rule_name); + let key = unique_provider_rule_key(rules, &layer.rule_name); let mut rule = layer.rule.clone(); if rule.name.is_empty() { rule.name.clone_from(&key); } - effective.network_policies.insert(key, rule); + rules.insert(key, rule); } effective } -fn unique_provider_rule_key(policy: &SandboxPolicy, preferred: &str) -> String { - if !policy.network_policies.contains_key(preferred) { +fn unique_provider_rule_key( + rules: &std::collections::HashMap, + preferred: &str, +) -> String { + if !rules.contains_key(preferred) { return preferred.to_string(); } for suffix in 2_u32.. { let candidate = format!("{preferred}_{suffix}"); - if !policy.network_policies.contains_key(&candidate) { + if !rules.contains_key(&candidate) { return candidate; } } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index bad8532cdf..a0df9dd137 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -472,6 +472,85 @@ fn yaml_mcp_method( method.to_string() } +/// Converts an authored endpoint to its protobuf form. +/// +/// The caller has validated the endpoint's `tls`, `enforcement`, and `access` +/// values with [`validate_endpoint_modes`]. +fn endpoint_def_to_proto(e: NetworkEndpointDef) -> NetworkEndpoint { + 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![] + }; + NetworkEndpoint { + host: e.host, + path: e.path, + port: normalized_ports.first().copied().unwrap_or(0), + ports: normalized_ports, + protocol: protocol.clone(), + tls: network_tls_mode_from_str(&e.tls).expect("endpoint modes validated above") as i32, + enforcement: network_enforcement_mode_from_str(&e.enforcement) + .expect("endpoint modes validated above") as i32, + access: network_access_preset_from_str(&e.access).expect("endpoint modes validated above") + as i32, + rules: allow_rules + .into_iter() + .map(|r| L7Rule { + allow: Some(allow_def_to_proto(&protocol, r.allow)), + }) + .collect(), + allowed_ips: e.allowed_ips, + deny_rules: deny_rules + .into_iter() + .map(|deny| deny_def_to_proto(&protocol, deny)) + .collect(), + allow_encoded_slash: e.allow_encoded_slash, + websocket_credential_rewrite: e.websocket_credential_rewrite, + request_body_credential_rewrite: e.request_body_credential_rewrite, + allow_uninspected_credentials: e.allow_uninspected_credentials, + // Provider credential provenance is derived by the + // gateway and cannot be authored in policy YAML. + provider_credentialed: false, + token_grant_owner: String::new(), + // Advisor provenance is internal runtime state, not + // a user-authored policy schema field. + advisor_proposed: false, + persisted_queries: e.persisted_queries, + graphql_persisted_queries: e + .graphql_persisted_queries + .into_iter() + .map(|(key, op)| { + ( + key, + GraphqlOperation { + operation_type: op.operation_type, + operation_name: op.operation_name, + fields: op.fields, + }, + ) + }) + .collect(), + graphql_max_body_bytes: e.graphql_max_body_bytes, + credential_signing: e.credential_signing, + signing_service: e.signing_service, + signing_region: e.signing_region, + credential_binding: e.credential_binding.map(|binding| { + openshell_core::proto::NetworkCredentialBinding { + provider: binding.provider, + } + }), + json_rpc_max_body_bytes: json_rpc_max_body_bytes(&e.json_rpc, &e.mcp), + mcp: mcp_options(&protocol, &e.mcp), + } +} + fn to_proto(raw: PolicyFile) -> Result { for (policy_name, rule) in &raw.network_policies { for (endpoint_index, endpoint) in rule.endpoints.iter().enumerate() { @@ -490,6 +569,13 @@ fn to_proto(raw: PolicyFile) -> Result { .into_diagnostic() .wrap_err("failed to convert network middleware config")?; + let endpoint_settings = raw + .endpoint_settings + .into_iter() + .enumerate() + .map(|(index, setting)| endpoint_setting_def_to_proto(index, setting)) + .collect::>>()?; + let network_policies = raw .network_policies .into_iter() @@ -503,84 +589,7 @@ fn to_proto(raw: PolicyFile) -> Result { endpoints: rule .endpoints .into_iter() - .map(|e| { - 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![] - }; - NetworkEndpoint { - host: e.host, - path: e.path, - port: normalized_ports.first().copied().unwrap_or(0), - ports: normalized_ports, - protocol: protocol.clone(), - tls: network_tls_mode_from_str(&e.tls) - .expect("endpoint modes validated above") - as i32, - enforcement: network_enforcement_mode_from_str(&e.enforcement) - .expect("endpoint modes validated above") - as i32, - access: network_access_preset_from_str(&e.access) - .expect("endpoint modes validated above") - as i32, - rules: allow_rules - .into_iter() - .map(|r| L7Rule { - allow: Some(allow_def_to_proto(&protocol, r.allow)), - }) - .collect(), - allowed_ips: e.allowed_ips, - deny_rules: deny_rules - .into_iter() - .map(|deny| deny_def_to_proto(&protocol, deny)) - .collect(), - allow_encoded_slash: e.allow_encoded_slash, - websocket_credential_rewrite: e.websocket_credential_rewrite, - request_body_credential_rewrite: e.request_body_credential_rewrite, - allow_uninspected_credentials: e.allow_uninspected_credentials, - // Provider credential provenance is derived by the - // gateway and cannot be authored in policy YAML. - provider_credentialed: false, - token_grant_owner: String::new(), - // Advisor provenance is internal runtime state, not - // a user-authored policy schema field. - advisor_proposed: false, - persisted_queries: e.persisted_queries, - graphql_persisted_queries: e - .graphql_persisted_queries - .into_iter() - .map(|(key, op)| { - ( - key, - GraphqlOperation { - operation_type: op.operation_type, - operation_name: op.operation_name, - fields: op.fields, - }, - ) - }) - .collect(), - graphql_max_body_bytes: e.graphql_max_body_bytes, - credential_signing: e.credential_signing, - signing_service: e.signing_service, - signing_region: e.signing_region, - credential_binding: e.credential_binding.map(|binding| { - openshell_core::proto::NetworkCredentialBinding { - provider: binding.provider, - } - }), - json_rpc_max_body_bytes: json_rpc_max_body_bytes(&e.json_rpc, &e.mcp), - mcp: mcp_options(&protocol, &e.mcp), - } - }) + .map(endpoint_def_to_proto) .collect(), binaries: rule .binaries @@ -611,13 +620,191 @@ fn to_proto(raw: PolicyFile) -> Result { }), network_policies, network_middlewares, + // YAML-authored policies never set this; Cedar policies are authored + // as `.cedar` files (see `parse_cedar_sandbox_policy_file`). + cedar_policy_source: String::new(), + provider_credential_rules: HashMap::default(), + endpoint_settings, }) } +/// Converts one authored `endpoint_settings` entry to its protobuf form. +/// +/// Entries carry no protocol, so MCP options are kept whatever the protocol +/// field says; validation then rejects fields that are not settings (see +/// [`validate_sandbox_policy`]). The JSON-RPC body limit may be set in +/// `json_rpc` or `mcp`, but not both. +fn endpoint_setting_def_to_proto( + index: usize, + setting: NetworkEndpointDef, +) -> Result { + let errors = validate_endpoint_modes(&setting.tls, &setting.enforcement, &setting.access); + if !errors.is_empty() { + return Err(miette::miette!( + "endpoint_settings[{index}]: {}", + errors.join("; ") + )); + } + let json_rpc_limit = setting + .json_rpc + .as_ref() + .map_or(0, |config| config.max_body_bytes); + let mcp_limit = setting + .mcp + .as_ref() + .map_or(0, |config| config.max_body_bytes); + if json_rpc_limit > 0 && mcp_limit > 0 { + return Err(miette::miette!( + "endpoint_settings[{index}]: set the JSON-RPC body limit in json_rpc or mcp, not both" + )); + } + let mcp = setting.mcp.as_ref().map(|config| { + let mut versions = config.versions.clone().unwrap_or_default(); + canonicalize_mcp_versions(&mut versions); + McpOptions { + versions, + strict_tool_names: config.strict_tool_names, + allow_all_known_mcp_methods: config.allow_all_known_mcp_methods, + } + }); + let mut endpoint = endpoint_def_to_proto(setting); + endpoint.json_rpc_max_body_bytes = json_rpc_limit.max(mcp_limit); + endpoint.mcp = mcp; + Ok(endpoint) +} + +/// Converts one protobuf `endpoint_settings` entry to its authored form. +/// +/// MCP options render in an `mcp` stanza, which then also carries the +/// JSON-RPC body limit. +fn endpoint_setting_proto_to_def(setting: &NetworkEndpoint) -> Result { + let mut def = endpoint_proto_to_def(setting)?; + if let Some(options) = &setting.mcp { + let mut versions = options.versions.clone(); + canonicalize_mcp_versions(&mut versions); + def.json_rpc = None; + def.mcp = Some(McpConfigDef { + versions: (!versions.is_empty()).then_some(versions), + max_body_bytes: setting.json_rpc_max_body_bytes, + strict_tool_names: options.strict_tool_names, + allow_all_known_mcp_methods: options.allow_all_known_mcp_methods, + }); + } + Ok(def) +} + // --------------------------------------------------------------------------- // Proto → YAML conversion // --------------------------------------------------------------------------- +/// Converts a protobuf endpoint to its authored form. +fn endpoint_proto_to_def(e: &NetworkEndpoint) -> 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. + let checked = |value: u32| { + u16::try_from(value).map_err(|_| { + miette::miette!( + "cannot serialize endpoint '{}': port {value} exceeds {}", + e.host, + u16::MAX + ) + }) + }; + let (port, ports) = if e.ports.len() > 1 { + ( + 0, + e.ports + .iter() + .copied() + .map(checked) + .collect::>>()?, + ) + } else { + (checked(e.ports.first().copied().unwrap_or(e.port))?, vec![]) + }; + let protocol = e.protocol.clone(); + let mcp_allow_all_known_mcp_methods = !is_mcp_protocol(&protocol) + || e.mcp + .as_ref() + .and_then(|options| options.allow_all_known_mcp_methods) + .unwrap_or(false); + let rules = e + .rules + .iter() + .map(|r| L7RuleDef { + allow: allow_proto_to_def( + &protocol, + r.allow.clone().unwrap_or_default(), + mcp_allow_all_known_mcp_methods, + ), + }) + .collect(); + let deny_rules: Vec = e + .deny_rules + .iter() + .map(|d| deny_proto_to_def(&protocol, d, mcp_allow_all_known_mcp_methods)) + .collect(); + let (json_rpc, mcp) = if is_mcp_protocol(&protocol) { + ( + None, + mcp_config_from_proto(e.json_rpc_max_body_bytes, e.mcp.as_ref()), + ) + } else { + (json_rpc_config_from_proto(e.json_rpc_max_body_bytes), None) + }; + Ok(NetworkEndpointDef { + host: e.host.clone(), + path: e.path.clone(), + port, + ports, + protocol, + tls: network_tls_mode_to_str(e.tls) + .expect("policy enum values validated before serialization") + .to_string(), + enforcement: network_enforcement_mode_to_str(e.enforcement) + .expect("policy enum values validated before serialization") + .to_string(), + access: network_access_preset_to_str(e.access) + .expect("policy enum values validated before serialization") + .to_string(), + rules, + allowed_ips: e.allowed_ips.clone(), + deny_rules, + allow_encoded_slash: e.allow_encoded_slash, + websocket_credential_rewrite: e.websocket_credential_rewrite, + request_body_credential_rewrite: e.request_body_credential_rewrite, + allow_uninspected_credentials: e.allow_uninspected_credentials, + persisted_queries: e.persisted_queries.clone(), + graphql_persisted_queries: e + .graphql_persisted_queries + .iter() + .map(|(key, op)| { + ( + key.clone(), + GraphqlOperationDef { + operation_type: op.operation_type.clone(), + operation_name: op.operation_name.clone(), + fields: op.fields.clone(), + }, + ) + }) + .collect(), + graphql_max_body_bytes: e.graphql_max_body_bytes, + credential_signing: e.credential_signing.clone(), + signing_service: e.signing_service.clone(), + signing_region: e.signing_region.clone(), + credential_binding: e.credential_binding.as_ref().map(|binding| { + NetworkCredentialBindingDef { + provider: binding.provider.clone(), + } + }), + json_rpc, + mcp, + }) +} + fn from_proto(policy: &SandboxPolicy) -> Result { let filesystem_policy = policy.filesystem.as_ref().map(|fs| FilesystemDef { include_workdir: fs.include_workdir, @@ -661,114 +848,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { endpoints: rule .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. - let checked = |value: u32| { - u16::try_from(value).map_err(|_| { - miette::miette!( - "cannot serialize endpoint '{}': port {value} exceeds {}", - e.host, - u16::MAX - ) - }) - }; - let (port, ports) = if e.ports.len() > 1 { - ( - 0, - e.ports - .iter() - .copied() - .map(checked) - .collect::>>()?, - ) - } else { - (checked(e.ports.first().copied().unwrap_or(e.port))?, vec![]) - }; - let protocol = e.protocol.clone(); - let mcp_allow_all_known_mcp_methods = !is_mcp_protocol(&protocol) - || e.mcp - .as_ref() - .and_then(|options| options.allow_all_known_mcp_methods) - .unwrap_or(false); - let rules = e - .rules - .iter() - .map(|r| L7RuleDef { - allow: allow_proto_to_def( - &protocol, - r.allow.clone().unwrap_or_default(), - mcp_allow_all_known_mcp_methods, - ), - }) - .collect(); - let deny_rules: Vec = e - .deny_rules - .iter() - .map(|d| { - deny_proto_to_def(&protocol, d, mcp_allow_all_known_mcp_methods) - }) - .collect(); - let (json_rpc, mcp) = if is_mcp_protocol(&protocol) { - ( - None, - mcp_config_from_proto(e.json_rpc_max_body_bytes, e.mcp.as_ref()), - ) - } else { - (json_rpc_config_from_proto(e.json_rpc_max_body_bytes), None) - }; - Ok(NetworkEndpointDef { - host: e.host.clone(), - path: e.path.clone(), - port, - ports, - protocol, - tls: network_tls_mode_to_str(e.tls) - .expect("policy enum values validated before serialization") - .to_string(), - enforcement: network_enforcement_mode_to_str(e.enforcement) - .expect("policy enum values validated before serialization") - .to_string(), - access: network_access_preset_to_str(e.access) - .expect("policy enum values validated before serialization") - .to_string(), - rules, - allowed_ips: e.allowed_ips.clone(), - deny_rules, - allow_encoded_slash: e.allow_encoded_slash, - websocket_credential_rewrite: e.websocket_credential_rewrite, - request_body_credential_rewrite: e.request_body_credential_rewrite, - allow_uninspected_credentials: e.allow_uninspected_credentials, - persisted_queries: e.persisted_queries.clone(), - graphql_persisted_queries: e - .graphql_persisted_queries - .iter() - .map(|(key, op)| { - ( - key.clone(), - GraphqlOperationDef { - operation_type: op.operation_type.clone(), - operation_name: op.operation_name.clone(), - fields: op.fields.clone(), - }, - ) - }) - .collect(), - graphql_max_body_bytes: e.graphql_max_body_bytes, - credential_signing: e.credential_signing.clone(), - signing_service: e.signing_service.clone(), - signing_region: e.signing_region.clone(), - credential_binding: e.credential_binding.as_ref().map(|binding| { - NetworkCredentialBindingDef { - provider: binding.provider.clone(), - } - }), - json_rpc, - mcp, - }) - }) + .map(endpoint_proto_to_def) .collect::>>()?, binaries: rule .binaries @@ -783,6 +863,11 @@ fn from_proto(policy: &SandboxPolicy) -> Result { .collect::>>()?; let network_middlewares = middleware::from_proto(&policy.network_middlewares); + let endpoint_settings = policy + .endpoint_settings + .iter() + .map(endpoint_setting_proto_to_def) + .collect::>>()?; Ok(PolicyFile { // Proto3 scalar fields do not preserve presence. Treat zero as an @@ -798,6 +883,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { process, network_policies, network_middlewares, + endpoint_settings, }) } @@ -856,6 +942,7 @@ pub fn is_valid_sandbox_identity(value: &str) -> bool { /// Parse a sandbox policy from a YAML string. pub fn parse_sandbox_policy(yaml: &str) -> Result { let raw = openshell_policy_schema::parse_policy(yaml)?; + reject_endpoint_settings_in_policy_file(&raw)?; to_proto(raw) } @@ -865,17 +952,106 @@ pub fn parse_sandbox_policy_file(path: &Path) -> Result { path, openshell_policy_schema::ParseLimits::default(), )?; + reject_endpoint_settings_in_policy_file(&raw)?; to_proto(raw) } +/// Rejects `endpoint_settings` in a YAML policy file. +/// +/// The section belongs to a middleware file used with a Cedar policy; a YAML +/// policy sets the same fields on its endpoints. +fn reject_endpoint_settings_in_policy_file(raw: &PolicyFile) -> Result<()> { + if raw.endpoint_settings.is_empty() { + return Ok(()); + } + Err(miette::miette!( + "endpoint_settings is not a policy section; it belongs in a middleware file \ + (--middleware) used with a Cedar policy, and a YAML policy sets these fields on its \ + network_policies endpoints" + )) +} + +/// Parses a sandbox policy file, dispatching on extension. +/// +/// `.cedar` (any case) builds a Cedar-sourced policy via +/// [`parse_cedar_sandbox_policy_file`]; anything else (`.yaml`, `.yml`, +/// extensionless) uses the existing YAML path via +/// [`parse_sandbox_policy_file`], byte-for-byte unchanged. +/// +/// # Errors +/// +/// Returns an error if the selected parser fails. +pub fn parse_sandbox_policy_file_auto(path: &Path) -> Result { + let is_cedar = path + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("cedar")); + if is_cedar { + parse_cedar_sandbox_policy_file(path) + } else { + parse_sandbox_policy_file(path) + } +} + +/// Reads and validates a `.cedar` policy file, building a [`SandboxPolicy`] +/// with `cedar_policy_source` set and every other field at default. +/// +/// Runs the same checks the gateway runs at submission time so a malformed +/// Cedar policy is rejected locally with a fast, actionable error instead of +/// a round trip to the gateway. +/// +/// # Errors +/// +/// Returns an error if the file cannot be read, contains no policy text, or +/// fails validation (see [`validate_sandbox_policy`]). +pub fn parse_cedar_sandbox_policy_file(path: &Path) -> Result { + let cedar_policy_source = std::fs::read_to_string(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read Cedar policy file {}", path.display()))?; + // An empty `cedar_policy_source` selects the YAML path, so an empty file + // would silently become an empty YAML policy. + if cedar_policy_source.trim().is_empty() { + return Err(miette::miette!( + "Cedar policy file {} is empty", + path.display() + )); + } + let policy = SandboxPolicy { + version: 1, + cedar_policy_source, + provider_credential_rules: HashMap::default(), + ..Default::default() + }; + validate_sandbox_policy(&policy) + .map_err(|violations| { + let messages: Vec = violations.iter().map(ToString::to_string).collect(); + miette::miette!("policy contains unsafe content: {}", messages.join("; ")) + }) + .wrap_err_with(|| format!("invalid Cedar policy at {}", path.display()))?; + Ok(policy) +} + /// Serialize a proto sandbox policy to a YAML string. /// /// This is the inverse of [`parse_sandbox_policy`] — the output uses the /// canonical YAML field names (e.g. `filesystem_policy`, not `filesystem`) /// and is round-trippable through `parse_sandbox_policy`. +/// +/// A Cedar-authored policy serializes to its Cedar text, which round-trips +/// through [`parse_cedar_sandbox_policy_file`]. Its middleware and endpoint +/// settings are not part of the policy text; see +/// [`serialize_network_middlewares`]. +/// +/// # Errors +/// +/// Returns an error if the policy version is unsupported or the policy is +/// invalid. pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result { validate_proto_version_for_authored_serialization(policy)?; validate_policy_enum_values(policy)?; + if !policy.cedar_policy_source.is_empty() { + return Ok(policy.cedar_policy_source.clone()); + } 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)?; @@ -886,13 +1062,285 @@ 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. +/// +/// A Cedar-authored policy renders as its Cedar text in +/// `cedar_policy_source`, plus `network_middlewares`, `endpoint_settings`, +/// `process`, and the gateway-managed `provider_credential_rules` (with +/// `provider_credentialed_endpoints`) when it has any. +/// +/// # Errors +/// +/// Returns an error if the policy version is unsupported or the policy is +/// invalid. 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) + let json = openshell_policy_schema::policy_to_json_value(&json_repr)?; + if canonical.cedar_policy_source.is_empty() { + return Ok(json); + } + let mut cedar = serde_json::json!({ + "version": 1, + "cedar_policy_source": canonical.cedar_policy_source, + }); + if let Some(middlewares) = json.get("network_middlewares") { + cedar["network_middlewares"] = middlewares.clone(); + } + if let Some(settings) = json.get("endpoint_settings") { + cedar["endpoint_settings"] = settings.clone(); + } + if let Some(process) = json.get("process") { + cedar["process"] = process.clone(); + } + if !canonical.provider_credential_rules.is_empty() { + let rules_only = SandboxPolicy { + version: 1, + network_policies: canonical.provider_credential_rules.clone(), + ..Default::default() + }; + let rules_json = openshell_policy_schema::policy_to_json_value(&from_proto(&rules_only)?)?; + if let Some(rules) = rules_json.get("network_policies") { + cedar["provider_credential_rules"] = rules.clone(); + } + cedar["provider_credentialed_endpoints"] = + provider_credentialed_endpoints(&canonical).into(); + } + Ok(cedar) +} + +/// Serializes a policy's middleware file sections. +/// +/// The output has the policy's `network_middlewares`, `endpoint_settings`, +/// and `process` sections and round-trips through [`parse_middleware_file`]. +/// Returns `None` when the policy has none of them. +/// +/// # Errors +/// +/// Returns an error if the middleware configuration or endpoint settings are +/// invalid. +pub fn serialize_network_middlewares(policy: &SandboxPolicy) -> Result> { + let process = middleware_file_process(policy.process.as_ref()); + if policy.network_middlewares.is_empty() + && policy.endpoint_settings.is_empty() + && process.is_none() + { + return Ok(None); + } + let middleware_only = SandboxPolicy { + version: 1, + process, + network_middlewares: policy.network_middlewares.clone(), + endpoint_settings: policy.endpoint_settings.clone(), + ..Default::default() + }; + let yaml = serialize_sandbox_policy(&middleware_only)?; + // The policy serializer always leads with the version line, which a + // middleware file does not carry. + Ok(Some( + yaml.strip_prefix("version: 1\n") + .unwrap_or(&yaml) + .to_string(), + )) +} + +/// Lists the provider endpoints that carry credentials, as `host:port[path]`. +/// +/// These are the endpoints the gateway marked `provider_credentialed` in a +/// Cedar policy's `provider_credential_rules`, sorted. On a Cedar sandbox a +/// connection to one is refused unless the Cedar policy inspects it with an +/// `HttpRequest` policy or the provider allows uninspected credentials. +#[must_use] +pub fn provider_credentialed_endpoints(policy: &SandboxPolicy) -> Vec { + let mut endpoints: Vec = policy + .provider_credential_rules + .values() + .flat_map(|rule| &rule.endpoints) + .filter(|endpoint| endpoint.provider_credentialed) + .flat_map(|endpoint| { + let ports = if endpoint.ports.is_empty() { + vec![endpoint.port] + } else { + endpoint.ports.clone() + }; + ports + .into_iter() + .map(move |port| format!("{}:{port}{}", endpoint.host, endpoint.path)) + }) + .collect(); + endpoints.sort(); + endpoints.dedup(); + endpoints +} + +/// Serializes the gateway-managed provider rules of a Cedar policy. +/// +/// The output is a `provider_credential_rules` section in the same shape as +/// `network_policies`, for display only: these rules supply credential +/// settings for attached providers and grant no access. A comment lists the +/// endpoints that carry credentials (see +/// [`provider_credentialed_endpoints`]), which the authored YAML schema does +/// not represent. Returns `None` when the policy has none. +/// +/// # Errors +/// +/// Returns an error if the rules are invalid. +pub fn serialize_provider_credential_rules(policy: &SandboxPolicy) -> Result> { + if policy.provider_credential_rules.is_empty() { + return Ok(None); + } + let rules_only = SandboxPolicy { + version: 1, + network_policies: policy.provider_credential_rules.clone(), + ..Default::default() + }; + let yaml = serialize_sandbox_policy(&rules_only)?; + let rules = yaml + .strip_prefix("version: 1\nnetwork_policies:\n") + .ok_or_else(|| miette::miette!("unexpected provider rule serialization"))?; + let credentialed = provider_credentialed_endpoints(policy); + let comment = if credentialed.is_empty() { + String::new() + } else { + format!("# Credentialed endpoints: {}\n", credentialed.join(", ")) + }; + Ok(Some(format!( + "{comment}provider_credential_rules:\n{rules}" + ))) +} + +/// The sections of a middleware file. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MiddlewareFile { + /// Supervisor middleware configs, as in a YAML policy's + /// `network_middlewares` section. + pub network_middlewares: HashMap, + /// Endpoint settings for a Cedar policy (see + /// `SandboxPolicy.endpoint_settings`). + pub endpoint_settings: Vec, + /// Process identity for a Cedar policy, as in a YAML policy's `process` + /// section. `None` when the file omits it or sets neither field. + pub process: Option, +} + +/// A `process` section as a middleware file carries it. +/// +/// Omitted fields never round-trip, so an identity with neither field set is +/// the same as no section. +fn middleware_file_process(process: Option<&ProcessPolicy>) -> Option { + process + .filter(|process| !process.run_as_user.is_empty() || !process.run_as_group.is_empty()) + .cloned() +} + +/// Parses middleware file text: YAML with only top-level +/// `network_middlewares`, `endpoint_settings`, and `process` sections. +/// +/// Middleware, endpoint settings, and the process identity are configuration, +/// not access control, so they are supplied separately from the policy file. +/// `network_middlewares` uses the same format and validation as in a YAML +/// policy and applies to YAML and Cedar policies alike. `endpoint_settings` +/// applies only to Cedar policies; each entry uses YAML endpoint field names +/// (see [`validate_sandbox_policy`] for the fields it accepts). `process` +/// also applies only to Cedar policies, since a YAML policy carries it +/// itself, and its identities must pass the same validation (see +/// [`is_valid_sandbox_identity`]). +/// +/// # Errors +/// +/// Returns an error if the text is not valid middleware configuration, +/// endpoint settings, or process identity, or contains any other section. +pub fn parse_middleware_yaml(content: &str) -> Result { + // Parse with the policy parser so the format and validation match the + // corresponding sections of a YAML policy exactly. + let raw = openshell_policy_schema::parse_policy(&format!("version: 1\n{content}"))?; + let parsed = to_proto(raw)?; + let file = MiddlewareFile { + network_middlewares: parsed.network_middlewares.clone(), + endpoint_settings: parsed.endpoint_settings.clone(), + process: middleware_file_process(parsed.process.as_ref()), + }; + let only_file_sections = SandboxPolicy { + version: 1, + process: parsed.process.clone(), + network_middlewares: file.network_middlewares.clone(), + endpoint_settings: file.endpoint_settings.clone(), + ..Default::default() + }; + if parsed != only_file_sections { + return Err(miette::miette!( + "expected YAML with only top-level network_middlewares, endpoint_settings, and \ + process sections" + )); + } + let mut violations = validate_endpoint_settings(&file.endpoint_settings); + if let Some(process) = &file.process { + violations.extend(validate_process_identity(process)); + } + if !violations.is_empty() { + let messages: Vec = violations.iter().map(ToString::to_string).collect(); + return Err(miette::miette!("{}", messages.join("; "))); + } + Ok(file) +} + +/// Reads a middleware file (see [`parse_middleware_yaml`]). +/// +/// # Errors +/// +/// Returns an error if the file cannot be read or is invalid. +pub fn parse_middleware_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read middleware file {}", path.display()))?; + parse_middleware_yaml(&content) + .wrap_err_with(|| format!("invalid middleware file {}", path.display())) +} + +/// Sets `policy`'s middleware, endpoint settings, and process identity from +/// a middleware file. +/// +/// # Errors +/// +/// Returns an error if the file is invalid (see [`parse_middleware_file`]), +/// the policy already defines `network_middlewares` or `endpoint_settings`, +/// or the file has `endpoint_settings` or `process` and the policy is not a +/// Cedar policy. +pub fn apply_middleware_file(policy: &mut SandboxPolicy, path: &Path) -> Result<()> { + if !policy.network_middlewares.is_empty() { + return Err(miette::miette!( + "the policy already defines network_middlewares; remove them from the policy or \ + omit --middleware" + )); + } + if !policy.endpoint_settings.is_empty() { + return Err(miette::miette!( + "the policy already defines endpoint_settings; omit --middleware" + )); + } + let file = parse_middleware_file(path)?; + if !file.endpoint_settings.is_empty() && policy.cedar_policy_source.is_empty() { + return Err(miette::miette!( + "invalid middleware file {}: {}", + path.display(), + PolicyViolation::EndpointSettingsRequireCedar + )); + } + if file.process.is_some() && policy.cedar_policy_source.is_empty() { + return Err(miette::miette!( + "invalid middleware file {}: process applies only to Cedar policies; a YAML policy \ + sets its process section itself", + path.display(), + )); + } + policy.network_middlewares = file.network_middlewares; + policy.endpoint_settings = file.endpoint_settings; + if let Some(process) = file.process { + policy.process = Some(process); + } + Ok(()) } fn validate_proto_version_for_authored_serialization(policy: &SandboxPolicy) -> Result<()> { @@ -918,6 +1366,16 @@ fn validate_policy_enum_values(policy: &SandboxPolicy) -> Result<()> { } } } + for (index, setting) in policy.endpoint_settings.iter().enumerate() { + let errors = + validate_endpoint_mode_values(setting.tls, setting.enforcement, setting.access); + if !errors.is_empty() { + return Err(miette::miette!( + "endpoint_settings[{index}]: {}", + errors.join("; ") + )); + } + } Ok(()) } @@ -940,9 +1398,9 @@ pub fn serialize_sandbox_policy_json(policy: &SandboxPolicy) -> Result { /// default. pub fn load_sandbox_policy(cli_path: Option<&str>) -> Result> { let policy = if let Some(p) = cli_path { - parse_sandbox_policy_file(Path::new(p))? + parse_sandbox_policy_file_auto(Path::new(p))? } else if let Ok(policy_path) = std::env::var("OPENSHELL_SANDBOX_POLICY") { - parse_sandbox_policy_file(Path::new(&policy_path))? + parse_sandbox_policy_file_auto(Path::new(&policy_path))? } else { return Ok(None); }; @@ -991,6 +1449,9 @@ pub fn restrictive_default_policy() -> SandboxPolicy { process: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), + cedar_policy_source: String::new(), + provider_credential_rules: HashMap::default(), + endpoint_settings: Vec::new(), } } @@ -1114,6 +1575,20 @@ pub enum PolicyViolation { host: String, version: String, }, + /// `cedar_policy_source` is set alongside `network_policies`. Exactly + /// one policy format may be authored per sandbox. + CedarMutuallyExclusiveWithNetworkPolicies, + /// `cedar_policy_source` failed to parse or validate, or uses a policy + /// shape Cedar cannot enforce exactly. + InvalidCedarPolicy { reason: String }, + /// `endpoint_settings` is set on a policy that is not a Cedar policy. + EndpointSettingsRequireCedar, + /// An `endpoint_settings` entry is invalid. + InvalidEndpointSetting { + index: usize, + host: String, + reason: String, + }, } impl fmt::Display for PolicyViolation { @@ -1319,6 +1794,28 @@ impl fmt::Display for PolicyViolation { "network policy '{policy_name}': MCP endpoint '{host}' repeats protocol version '{version}'" ) } + Self::CedarMutuallyExclusiveWithNetworkPolicies => { + write!( + f, + "cedar_policy_source cannot be combined with network_policies; \ + a sandbox uses exactly one policy format" + ) + } + Self::InvalidCedarPolicy { reason } => { + write!(f, "cedar_policy_source is invalid: {reason}") + } + Self::EndpointSettingsRequireCedar => { + write!( + f, + "endpoint_settings applies only to Cedar policies; a YAML policy sets \ + these fields on its network_policies endpoints" + ) + } + Self::InvalidEndpointSetting { + index, + host, + reason, + } => write!(f, "endpoint_settings[{index}] ('{host}'): {reason}"), } } } @@ -1410,28 +1907,113 @@ enum McpVersionPresence { AllowDefaultable, } +/// Validates `policy.cedar_policy_source`, when non-empty. +/// +/// Checks mutual exclusivity with `network_policies`, that the Cedar engine +/// accepts the policy (parse, strict schema validation, and supported +/// shapes), and that the derived Landlock grants pass the same path checks +/// as YAML `filesystem` paths. A no-op when `cedar_policy_source` is empty +/// (the YAML-sourced path, unaffected). +fn validate_cedar_policy_source(policy: &SandboxPolicy, violations: &mut Vec) { + if policy.cedar_policy_source.is_empty() { + return; + } + if !policy.network_policies.is_empty() { + violations.push(PolicyViolation::CedarMutuallyExclusiveWithNetworkPolicies); + } + // Landlock grants come from the Cedar text; authored filesystem paths + // would be silently ignored. + if policy + .filesystem + .as_ref() + .is_some_and(|fs| !fs.read_only.is_empty() || !fs.read_write.is_empty()) + { + violations.push(PolicyViolation::InvalidCedarPolicy { + reason: "filesystem_policy cannot be combined with cedar_policy; grant paths \ + with ReadFile/WriteFile policies instead" + .to_string(), + }); + } + match load_cedar_grants(&policy.cedar_policy_source) { + Ok((read_only, read_write)) => { + if let Err(errors) = validate_filesystem_paths(&read_only, &read_write) { + violations.extend(errors); + } + } + Err(reason) => violations.push(PolicyViolation::InvalidCedarPolicy { reason }), + } +} + +/// Returns the Landlock grants a Cedar policy source authorizes. +/// +/// Grants are returned as a proto `FilesystemPolicy` so callers can compare +/// them with the same rules that apply to YAML `filesystem` sections. +/// Returns `None` if the source does not load; validation reports why. +#[must_use] +pub fn cedar_filesystem_grants(cedar_policy_source: &str) -> Option { + let (read_only, read_write) = load_cedar_grants(cedar_policy_source).ok()?; + Some(FilesystemPolicy { + include_workdir: false, + read_only, + read_write, + }) +} + +/// Returns the load-time warnings of a Cedar policy source, one line each. +/// +/// Warnings describe shapes that load but are likely not what the author +/// meant, such as an audit-only `@path` beside enforced policies of the same +/// `host:port`; they never change enforcement. Returns nothing for a source +/// that does not load, since [`validate_sandbox_policy`] reports why, and in +/// builds without the `cedar` feature. +#[must_use] +pub fn cedar_policy_warnings(cedar_policy_source: &str) -> Vec { + #[cfg(feature = "cedar")] + { + openshell_policy_cedar::CedarEngine::from_policy_str(cedar_policy_source) + .map(|engine| engine.warnings().iter().map(ToString::to_string).collect()) + .unwrap_or_default() + } + #[cfg(not(feature = "cedar"))] + { + let _ = cedar_policy_source; + Vec::new() + } +} + +/// Loads a Cedar policy and returns its `(read_only, read_write)` grants. +#[cfg(feature = "cedar")] +fn load_cedar_grants( + cedar_policy_source: &str, +) -> std::result::Result<(Vec, Vec), String> { + let engine = openshell_policy_cedar::CedarEngine::from_policy_str(cedar_policy_source) + .map_err(|error| error.to_string())?; + let grants = engine.filesystem_grants(); + Ok((grants.read_only.clone(), grants.read_write.clone())) +} + +/// Rejects every Cedar policy in builds without the `cedar` feature. +#[cfg(not(feature = "cedar"))] +fn load_cedar_grants( + _cedar_policy_source: &str, +) -> std::result::Result<(Vec, Vec), String> { + Err("Cedar policy support is not enabled in this build".to_string()) +} + fn validate_sandbox_policy_with_mcp_presence( policy: &SandboxPolicy, mcp_version_presence: McpVersionPresence, ) -> std::result::Result<(), Vec> { let mut violations = Vec::new(); - // Omitted process identity fields are resolved by the compute runtime. - // Explicit fields must be "sandbox" or a numeric UID/GID within the - // acceptable sandbox range. + validate_cedar_policy_source(policy, &mut violations); + if !policy.endpoint_settings.is_empty() && policy.cedar_policy_source.is_empty() { + violations.push(PolicyViolation::EndpointSettingsRequireCedar); + } + violations.extend(validate_endpoint_settings(&policy.endpoint_settings)); + if let Some(ref process) = policy.process { - if !process.run_as_user.is_empty() && !is_valid_sandbox_identity(&process.run_as_user) { - violations.push(PolicyViolation::InvalidProcessIdentity { - field: "run_as_user", - value: process.run_as_user.clone(), - }); - } - if !process.run_as_group.is_empty() && !is_valid_sandbox_identity(&process.run_as_group) { - violations.push(PolicyViolation::InvalidProcessIdentity { - field: "run_as_group", - value: process.run_as_group.clone(), - }); - } + violations.extend(validate_process_identity(process)); } // Check landlock compatibility mode is a recognized value. Direct gRPC/SDK @@ -1684,34 +2266,354 @@ fn validate_sandbox_policy_with_mcp_presence( } } -fn host_wildcard_shape_invalid(host: &str) -> bool { - if host == "*" || host == "**" { - return true; - } - if !host.contains('*') { - return false; - } - let labels: Vec<&str> = host.split('.').collect(); - let first_label = labels.first().copied().unwrap_or_default(); - if first_label.contains("**") && first_label != "**" { - return true; - } - labels - .iter() - .skip(1) - .copied() - .any(|label| label.contains("**") || (label.contains('*') && label != "*")) +/// Why a `NetworkEndpoint` field is not an endpoint setting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RejectedSettingField { + /// Decides access or inspection, which the Cedar policy owns. + Access, + /// Carries a credential setting, which attached providers supply. + Credential, + /// Stamped by the gateway when it composes provider endpoints. + GatewayManaged, } -/// Validate that an explicit-TCP host selector can produce names accepted by -/// policy DNS. Wildcards are replaced with a representative DNS label before -/// parsing because the authored selector itself is not a concrete DNS name. -fn validate_tcp_dns_host_selector(host: &str) -> std::result::Result<(), String> { - if host.trim() != host { - return Err("leading or trailing whitespace is not allowed".to_string()); - } - if host.ends_with('.') { - return Err("omit the trailing DNS root dot".to_string()); +impl RejectedSettingField { + /// The violation reason for `field`. + fn reason(self, field: &str) -> String { + match self { + Self::Access => format!( + "'{field}' is not an endpoint setting; the Cedar policy decides access and \ + inspection" + ), + Self::Credential => format!( + "'{field}' is not an endpoint setting; attached providers supply credential \ + settings" + ), + Self::GatewayManaged => format!( + "'{field}' is not an endpoint setting; the gateway sets it on provider endpoints" + ), + } + } +} + +/// Classifies every `NetworkEndpoint` field for `endpoint_settings` and +/// returns the rejected fields `setting` sets, in a fixed order. +/// +/// The destructuring names every field and must not use `..`: a field added +/// to the proto message then fails this build until it is classified here +/// as a selector, an allowed setting, or a rejected one. With `..`, a new +/// field that decided access or carried credentials would be accepted in an +/// endpoint setting without anyone noticing. +fn endpoint_setting_rejected_fields( + setting: &NetworkEndpoint, +) -> Vec<(&'static str, RejectedSettingField)> { + let NetworkEndpoint { + // Selectors: which endpoints the entry applies to. + host: _, + port: _, + ports: _, + path: _, + // Allowed settings; `validate_endpoint_settings` checks their values. + tls: _, + allow_encoded_slash: _, + persisted_queries: _, + graphql_persisted_queries: _, + graphql_max_body_bytes: _, + json_rpc_max_body_bytes: _, + mcp, + // Access and inspection, which the Cedar policy decides. + protocol, + enforcement, + access, + rules, + deny_rules, + allowed_ips, + advisor_proposed, + // Credential settings, which attached providers supply. + websocket_credential_rewrite, + request_body_credential_rewrite, + allow_uninspected_credentials, + credential_signing, + signing_service, + signing_region, + credential_binding, + // Stamped by the gateway on provider endpoints. + provider_credentialed, + token_grant_owner, + } = setting; + // `mcp` mixes allowed settings with one access field; classify its + // fields the same way, so a new MCP option is classified too. + let allow_all_known_mcp_methods = mcp.as_ref().is_some_and(|mcp| { + let McpOptions { + strict_tool_names: _, + versions: _, + allow_all_known_mcp_methods, + } = mcp; + allow_all_known_mcp_methods.is_some() + }); + let set = [ + ( + "protocol", + !protocol.is_empty(), + RejectedSettingField::Access, + ), + ( + "enforcement", + *enforcement != 0, + RejectedSettingField::Access, + ), + ("access", *access != 0, RejectedSettingField::Access), + ("rules", !rules.is_empty(), RejectedSettingField::Access), + ( + "deny_rules", + !deny_rules.is_empty(), + RejectedSettingField::Access, + ), + ( + "allowed_ips", + !allowed_ips.is_empty(), + RejectedSettingField::Access, + ), + ( + "mcp.allow_all_known_mcp_methods", + allow_all_known_mcp_methods, + RejectedSettingField::Access, + ), + ( + "advisor_proposed", + *advisor_proposed, + RejectedSettingField::Access, + ), + ( + "websocket_credential_rewrite", + *websocket_credential_rewrite, + RejectedSettingField::Credential, + ), + ( + "request_body_credential_rewrite", + *request_body_credential_rewrite, + RejectedSettingField::Credential, + ), + ( + "allow_uninspected_credentials", + *allow_uninspected_credentials, + RejectedSettingField::Credential, + ), + ( + "credential_signing", + !credential_signing.is_empty(), + RejectedSettingField::Credential, + ), + ( + "signing_service", + !signing_service.is_empty(), + RejectedSettingField::Credential, + ), + ( + "signing_region", + !signing_region.is_empty(), + RejectedSettingField::Credential, + ), + ( + "credential_binding", + credential_binding.is_some(), + RejectedSettingField::Credential, + ), + ( + "provider_credentialed", + *provider_credentialed, + RejectedSettingField::GatewayManaged, + ), + ( + "token_grant_owner", + !token_grant_owner.is_empty(), + RejectedSettingField::GatewayManaged, + ), + ]; + set.into_iter() + .filter_map(|(field, set, class)| set.then_some((field, class))) + .collect() +} + +/// Returns the ports an endpoint selector covers. +fn effective_endpoint_ports(endpoint: &NetworkEndpoint) -> Vec { + if endpoint.ports.is_empty() { + (endpoint.port != 0) + .then_some(endpoint.port) + .into_iter() + .collect() + } else { + endpoint.ports.clone() + } +} + +/// Validates an explicit process identity. +/// +/// Omitted fields are resolved by the compute runtime. Explicit fields must be +/// `sandbox` or a numeric UID/GID within the acceptable sandbox range (see +/// [`is_valid_sandbox_identity`]). +fn validate_process_identity(process: &ProcessPolicy) -> Vec { + let mut violations = Vec::new(); + if !process.run_as_user.is_empty() && !is_valid_sandbox_identity(&process.run_as_user) { + violations.push(PolicyViolation::InvalidProcessIdentity { + field: "run_as_user", + value: process.run_as_user.clone(), + }); + } + if !process.run_as_group.is_empty() && !is_valid_sandbox_identity(&process.run_as_group) { + violations.push(PolicyViolation::InvalidProcessIdentity { + field: "run_as_group", + value: process.run_as_group.clone(), + }); + } + violations +} + +/// Validates `endpoint_settings` entries. +/// +/// Each entry selects endpoints with the same host, port, and path rules as +/// a YAML endpoint, and may set only configuration: `tls`, +/// `allow_encoded_slash`, body limits, MCP `versions` and +/// `strict_tool_names`, and the GraphQL persisted-query settings. Fields that +/// decide access or carry credentials are rejected, `tls` applies to a whole +/// connection so it cannot be scoped to a `path`, and no two entries may +/// select the same host, path, and port. +fn validate_endpoint_settings(settings: &[NetworkEndpoint]) -> Vec { + let mut violations = Vec::new(); + for (index, setting) in settings.iter().enumerate() { + let mut reasons = Vec::new(); + if setting.host.trim().is_empty() { + reasons.push("host must not be empty".to_string()); + } + if (setting.host.starts_with("*.") || setting.host.starts_with("**.")) + && setting.host.split('.').count() <= 2 + { + reasons.push(format!( + "TLD wildcard '{}' is not allowed; use subdomain wildcards like '*.example.com'", + setting.host + )); + } + if host_wildcard_shape_invalid(&setting.host) { + reasons.push(format!( + "invalid host wildcard '{}'; middle DNS label wildcards must be the entire \ + label '*' and recursive '**' is only allowed as the entire first label", + setting.host + )); + } + let ports = effective_endpoint_ports(setting); + if ports.is_empty() { + reasons.push("must declare at least one port".to_string()); + } + for port in &ports { + if !(1..=u16::MAX.into()).contains(port) { + reasons.push(format!("invalid port {port}; expected 1..=65535")); + } + } + if !setting.path.is_empty() && !setting.path.starts_with('/') && setting.path != "**" { + reasons.push("path must start with '/' or be '**'".to_string()); + } + reasons.extend(validate_endpoint_mode_values( + setting.tls, + setting.enforcement, + setting.access, + )); + if setting.tls != 0 && !setting.path.is_empty() { + reasons.push( + "tls applies to a whole connection and cannot be combined with path".to_string(), + ); + } + reasons.extend( + endpoint_setting_rejected_fields(setting) + .into_iter() + .map(|(field, class)| class.reason(field)), + ); + if !setting.persisted_queries.is_empty() + && !matches!( + setting.persisted_queries.as_str(), + "deny" | "allow_registered" + ) + { + reasons.push(format!( + "persisted_queries must be 'deny' or 'allow_registered', got '{}'", + setting.persisted_queries + )); + } + let mut registry: Vec<_> = setting.graphql_persisted_queries.iter().collect(); + registry.sort_by_key(|(key, _)| *key); + for (key, operation) in registry { + if !matches!( + operation.operation_type.as_str(), + "query" | "mutation" | "subscription" + ) { + reasons.push(format!( + "graphql_persisted_queries[{key}].operation_type must be query, mutation, \ + or subscription" + )); + } + } + if let Some(mcp) = &setting.mcp { + let mut seen = BTreeSet::new(); + for version in &mcp.versions { + if !seen.insert(version.as_str()) { + reasons.push(format!("repeats MCP protocol version '{version}'")); + } + if version.parse::().is_err() { + reasons.push(format!( + "unsupported MCP protocol version '{version}'; {MCP_VERSION_REMEDIATION}" + )); + } + } + } + if let Some(earlier) = settings[..index].iter().position(|other| { + other.host.eq_ignore_ascii_case(&setting.host) + && other.path == setting.path + && effective_endpoint_ports(other) + .iter() + .any(|port| ports.contains(port)) + }) { + reasons.push(format!( + "selects the same host, path, and port as endpoint_settings[{earlier}]" + )); + } + violations.extend(reasons.into_iter().map(|reason| { + PolicyViolation::InvalidEndpointSetting { + index, + host: setting.host.clone(), + reason, + } + })); + } + violations +} + +fn host_wildcard_shape_invalid(host: &str) -> bool { + if host == "*" || host == "**" { + return true; + } + if !host.contains('*') { + return false; + } + let labels: Vec<&str> = host.split('.').collect(); + let first_label = labels.first().copied().unwrap_or_default(); + if first_label.contains("**") && first_label != "**" { + return true; + } + labels + .iter() + .skip(1) + .copied() + .any(|label| label.contains("**") || (label.contains('*') && label != "*")) +} + +/// Validate that an explicit-TCP host selector can produce names accepted by +/// policy DNS. Wildcards are replaced with a representative DNS label before +/// parsing because the authored selector itself is not a concrete DNS name. +fn validate_tcp_dns_host_selector(host: &str) -> std::result::Result<(), String> { + if host.trim() != host { + return Err("leading or trailing whitespace is not allowed".to_string()); + } + if host.ends_with('.') { + return Err("omit the trailing DNS root dot".to_string()); } openshell_core::host_pattern::HostSelector::new(&[host.to_string()], &[])?; @@ -1907,11 +2809,14 @@ pub(crate) fn materialize_default_mcp_versions(policy: &mut SandboxPolicy) { /// [`validate_and_canonicalize_sandbox_policy`] and cannot safely normalize an /// unvalidated policy in place. pub(crate) fn canonicalize_mcp_version_allowlists(policy: &mut SandboxPolicy) { - for rule in policy.network_policies.values_mut() { - for endpoint in &mut rule.endpoints { - if let Some(options) = endpoint.mcp.as_mut() { - canonicalize_mcp_options(options); - } + let endpoints = policy + .network_policies + .values_mut() + .flat_map(|rule| &mut rule.endpoints) + .chain(&mut policy.endpoint_settings); + for endpoint in endpoints { + if let Some(options) = endpoint.mcp.as_mut() { + canonicalize_mcp_options(options); } } } @@ -1949,6 +2854,94 @@ pub use openshell_policy_schema::normalize_path; mod tests { use super::*; + #[cfg(feature = "cedar")] + #[test] + fn parse_sandbox_policy_file_auto_dispatches_cedar_extension() { + let dir = std::env::temp_dir().join(format!( + "openshell-policy-test-{}-{}", + std::process::id(), + "cedar_auto_dispatch", + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join("policy.cedar"); + std::fs::write( + &path, + r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443" +) +when { context.binary_path == "/usr/bin/curl" }; +"#, + ) + .expect("write policy file"); + + let policy = parse_sandbox_policy_file_auto(&path).expect("Cedar policy must parse"); + assert!(!policy.cedar_policy_source.is_empty()); + assert_eq!(policy.version, 1); + assert!(policy.network_policies.is_empty()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[cfg(feature = "cedar")] + #[test] + fn parse_sandbox_policy_file_auto_dispatches_uppercase_cedar_extension() { + let dir = std::env::temp_dir().join(format!( + "openshell-policy-test-{}-{}", + std::process::id(), + "cedar_uppercase_extension", + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join("policy.CEDAR"); + std::fs::write( + &path, + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443");"#, + ) + .expect("write policy file"); + + let policy = parse_sandbox_policy_file_auto(&path).expect("Cedar policy must parse"); + assert!(!policy.cedar_policy_source.is_empty()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn parse_cedar_sandbox_policy_file_rejects_empty_file() { + let dir = std::env::temp_dir().join(format!( + "openshell-policy-test-{}-{}", + std::process::id(), + "cedar_empty_file", + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join("policy.cedar"); + std::fs::write(&path, " \n\t\n").expect("write policy file"); + + let result = parse_cedar_sandbox_policy_file(&path); + assert!(result.is_err()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn parse_cedar_sandbox_policy_file_rejects_invalid_syntax() { + let dir = std::env::temp_dir().join(format!( + "openshell-policy-test-{}-{}", + std::process::id(), + "cedar_invalid_syntax", + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join("policy.cedar"); + std::fs::write(&path, "not valid cedar {{{").expect("write policy file"); + + let result = parse_cedar_sandbox_policy_file(&path); + assert!(result.is_err()); + + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn truncate_for_display_handles_multi_byte_utf8_without_panicking() { // Byte index 77 falls inside the multi-byte 'é'. @@ -1964,6 +2957,715 @@ mod tests { assert_eq!(truncate_for_display(s), s); } + fn cedar_sourced_policy(cedar_policy_source: &str) -> SandboxPolicy { + SandboxPolicy { + version: 1, + filesystem: None, + landlock: None, + process: None, + network_policies: HashMap::new(), + network_middlewares: HashMap::default(), + cedar_policy_source: cedar_policy_source.to_string(), + provider_credential_rules: HashMap::default(), + endpoint_settings: Vec::new(), + } + } + + const MIDDLEWARE_FILE: &str = r#"network_middlewares: + guard: + middleware: openshell/regex + order: 10 + config: + mode: redact + on_error: fail_closed + endpoints: + include: ["pypi.org"] +"#; + + fn write_temp_file(name: &str, contents: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!( + "openshell-policy-test-{}-{name}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join(name); + std::fs::write(&path, contents).expect("write file"); + (dir, path) + } + + #[test] + fn middleware_file_applies_to_a_cedar_policy_and_round_trips() { + let (dir, path) = write_temp_file("cedar-middleware.yaml", MIDDLEWARE_FILE); + let source = r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443");"#; + let mut policy = cedar_sourced_policy(source); + + apply_middleware_file(&mut policy, &path).expect("middleware file must apply"); + assert!(policy.network_middlewares.contains_key("guard")); + assert_eq!( + serialize_sandbox_policy(&policy).expect("serialize"), + source, + "a Cedar policy serializes to its Cedar text alone" + ); + + let middleware = serialize_network_middlewares(&policy) + .expect("serialize middleware") + .expect("policy has middleware"); + assert!( + middleware.starts_with("network_middlewares:"), + "{middleware}" + ); + let (roundtrip_dir, reserialized) = write_temp_file("roundtrip.yaml", &middleware); + assert_eq!( + parse_middleware_file(&reserialized) + .expect("serialized middleware must parse") + .network_middlewares, + policy.network_middlewares + ); + + let json = sandbox_policy_to_json_value(&policy).expect("json"); + assert_eq!(json["cedar_policy_source"], source); + assert!(json["network_middlewares"].get("guard").is_some()); + + std::fs::remove_dir_all(&dir).ok(); + std::fs::remove_dir_all(&roundtrip_dir).ok(); + } + + #[test] + fn provider_credential_rules_render_for_display_with_credentialed_endpoints() { + let mut policy = cedar_sourced_policy( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443");"#, + ); + policy.provider_credential_rules.insert( + "_provider_work_github".to_string(), + NetworkPolicyRule { + name: "_provider_work_github".to_string(), + endpoints: vec![ + NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + request_body_credential_rewrite: true, + provider_credentialed: true, + ..Default::default() + }, + NetworkEndpoint { + host: "github.com".to_string(), + port: 443, + ..Default::default() + }, + ], + ..Default::default() + }, + ); + + assert_eq!( + provider_credentialed_endpoints(&policy), + vec!["api.github.com:443".to_string()] + ); + let rendered = serialize_provider_credential_rules(&policy) + .expect("serialize") + .expect("policy has provider rules"); + assert!( + rendered.starts_with("# Credentialed endpoints: api.github.com:443\n"), + "{rendered}" + ); + assert!( + rendered.contains("provider_credential_rules:\n"), + "{rendered}" + ); + assert!(rendered.contains("_provider_work_github"), "{rendered}"); + assert!( + rendered.contains("request_body_credential_rewrite: true"), + "{rendered}" + ); + + let json = sandbox_policy_to_json_value(&policy).expect("json"); + assert!( + json["provider_credential_rules"] + .get("_provider_work_github") + .is_some() + ); + assert_eq!( + json["provider_credentialed_endpoints"], + serde_json::json!(["api.github.com:443"]) + ); + } + + #[test] + fn middleware_file_rejects_other_sections() { + let (dir, path) = write_temp_file( + "with-policies.yaml", + &format!("{MIDDLEWARE_FILE}network_policies:\n x: {{ endpoints: [] }}\n"), + ); + assert!(parse_middleware_file(&path).is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn middleware_file_does_not_replace_policy_middleware() { + let (dir, path) = write_temp_file("existing-middleware.yaml", MIDDLEWARE_FILE); + let mut policy = restrictive_default_policy(); + policy.network_middlewares = parse_middleware_file(&path) + .expect("middleware file must parse") + .network_middlewares; + assert!(apply_middleware_file(&mut policy, &path).is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Every message in an error's chain, joined. + fn error_chain(error: &miette::Report) -> String { + error + .chain() + .map(ToString::to_string) + .collect::>() + .join(": ") + } + + const ENDPOINT_SETTINGS: &str = r#"endpoint_settings: + - host: gitlab.example.com + port: 443 + path: /api/** + allow_encoded_slash: true + - host: "*.example.com" + ports: [443, 8443] + tls: skip + - host: mcp.example.com + port: 443 + mcp: + versions: ["2025-11-25", "2025-06-18"] + strict_tool_names: false + max_body_bytes: 131072 + - host: api.example.com + port: 443 + graphql_max_body_bytes: 131072 + persisted_queries: allow_registered + graphql_persisted_queries: + abc123: { operation_type: query, operation_name: Viewer, fields: [viewer] } +"#; + + #[test] + fn endpoint_settings_apply_to_a_cedar_policy_and_round_trip() { + let (dir, path) = write_temp_file( + "endpoint-settings.yaml", + &format!("{MIDDLEWARE_FILE}{ENDPOINT_SETTINGS}"), + ); + let mut policy = cedar_sourced_policy( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443");"#, + ); + apply_middleware_file(&mut policy, &path).expect("middleware file must apply"); + assert_eq!(policy.endpoint_settings.len(), 4); + assert!(policy.endpoint_settings[0].allow_encoded_slash); + assert_eq!(policy.endpoint_settings[1].ports, vec![443, 8443]); + let mcp = policy.endpoint_settings[2] + .mcp + .as_ref() + .expect("mcp options"); + assert_eq!(mcp.versions, vec!["2025-06-18", "2025-11-25"]); + assert_eq!(mcp.strict_tool_names, Some(false)); + assert_eq!(policy.endpoint_settings[2].json_rpc_max_body_bytes, 131_072); + assert!(validate_sandbox_policy(&policy).is_ok()); + + let rendered = serialize_network_middlewares(&policy) + .expect("serialize") + .expect("policy has middleware"); + assert!(rendered.contains("endpoint_settings:"), "{rendered}"); + let reparsed = parse_middleware_yaml(&rendered).expect("rendered file parses"); + assert_eq!(reparsed.endpoint_settings, policy.endpoint_settings); + assert_eq!(reparsed.network_middlewares, policy.network_middlewares); + + let json = sandbox_policy_to_json_value(&policy).expect("json"); + assert_eq!(json["endpoint_settings"][0]["host"], "gitlab.example.com"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn endpoint_settings_require_a_cedar_policy() { + let (dir, path) = write_temp_file("yaml-endpoint-settings.yaml", ENDPOINT_SETTINGS); + let mut policy = restrictive_default_policy(); + let error = apply_middleware_file(&mut policy, &path).expect_err("YAML policy"); + assert!( + error.to_string().contains("invalid middleware file"), + "{error}" + ); + assert!(error_chain(&error).contains("Cedar"), "{error:?}"); + + policy.endpoint_settings = parse_middleware_file(&path) + .expect("file parses") + .endpoint_settings; + let violations = validate_sandbox_policy(&policy).expect_err("YAML policy"); + assert!(violations.contains(&PolicyViolation::EndpointSettingsRequireCedar)); + + let error = parse_sandbox_policy(&format!("version: 1\n{ENDPOINT_SETTINGS}")) + .expect_err("a policy file cannot carry endpoint settings"); + assert!(error.to_string().contains("middleware file"), "{error}"); + std::fs::remove_dir_all(&dir).ok(); + } + + const PROCESS_SECTION: &str = "process:\n run_as_user: \"1000\"\n run_as_group: sandbox\n"; + + #[test] + fn process_applies_to_a_cedar_policy_and_round_trips() { + let (dir, path) = write_temp_file( + "process.yaml", + &format!("{MIDDLEWARE_FILE}{PROCESS_SECTION}"), + ); + let mut policy = cedar_sourced_policy( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443");"#, + ); + apply_middleware_file(&mut policy, &path).expect("middleware file must apply"); + let expected = ProcessPolicy { + run_as_user: "1000".into(), + run_as_group: "sandbox".into(), + }; + assert_eq!(policy.process.as_ref(), Some(&expected)); + assert_eq!(policy.network_middlewares.len(), 1); + assert!(validate_sandbox_policy(&policy).is_ok()); + + let rendered = serialize_network_middlewares(&policy) + .expect("serialize") + .expect("policy has middleware"); + assert!(rendered.contains("process:"), "{rendered}"); + let reparsed = parse_middleware_yaml(&rendered).expect("rendered file parses"); + assert_eq!(reparsed.process.as_ref(), Some(&expected)); + assert_eq!(reparsed.network_middlewares, policy.network_middlewares); + + let json = sandbox_policy_to_json_value(&policy).expect("json"); + assert_eq!(json["process"]["run_as_user"], "1000"); + assert_eq!(json["process"]["run_as_group"], "sandbox"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn process_alone_is_a_middleware_file() { + let (dir, path) = write_temp_file("process-only.yaml", PROCESS_SECTION); + let mut policy = cedar_sourced_policy("permit (principal, action, resource);"); + apply_middleware_file(&mut policy, &path).expect("middleware file must apply"); + assert_eq!( + policy.process.as_ref().map(|p| p.run_as_user.as_str()), + Some("1000") + ); + assert!(policy.network_middlewares.is_empty()); + + let rendered = serialize_network_middlewares(&policy) + .expect("serialize") + .expect("policy has a process section"); + assert!(rendered.starts_with("process:"), "{rendered}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn middleware_file_without_process_leaves_the_driver_default() { + let file = parse_middleware_yaml(MIDDLEWARE_FILE).expect("file parses"); + assert_eq!(file.process, None); + let (dir, path) = write_temp_file("no-process.yaml", MIDDLEWARE_FILE); + let mut policy = cedar_sourced_policy("permit (principal, action, resource);"); + apply_middleware_file(&mut policy, &path).expect("middleware file must apply"); + assert_eq!(policy.process, None); + assert_eq!(policy.network_middlewares.len(), 1); + assert_eq!( + serialize_network_middlewares(&cedar_sourced_policy( + "permit (principal, action, resource);" + )) + .expect("serialize"), + None + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn process_in_a_middleware_file_requires_a_cedar_policy() { + let (dir, path) = write_temp_file("yaml-process.yaml", PROCESS_SECTION); + let mut policy = restrictive_default_policy(); + let error = apply_middleware_file(&mut policy, &path).expect_err("YAML policy"); + assert!( + error.to_string().contains("invalid middleware file"), + "{error}" + ); + assert!( + error_chain(&error).contains("process applies only to Cedar policies"), + "{error:?}" + ); + assert_eq!(policy.process, None, "the policy is left unchanged"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn process_in_a_middleware_file_uses_policy_identity_validation() { + for (case, yaml, expected) in [ + ( + "named user", + "process:\n run_as_user: jovyan\n run_as_group: users\n", + "run_as_user", + ), + ("root", "process:\n run_as_user: \"0\"\n", "run_as_user"), + ( + "root group", + "process:\n run_as_group: \"0\"\n", + "run_as_group", + ), + ] { + let error = parse_middleware_yaml(yaml).expect_err(case); + assert!(error_chain(&error).contains(expected), "{case}: {error:?}"); + } + let (dir, path) = write_temp_file( + "bad-process.yaml", + "process:\n run_as_user: jovyan\n run_as_group: users\n", + ); + let mut policy = cedar_sourced_policy("permit (principal, action, resource);"); + let error = apply_middleware_file(&mut policy, &path).expect_err("invalid identity"); + assert!(error_chain(&error).contains("jovyan"), "{error:?}"); + assert!(error_chain(&error).contains("users"), "{error:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn middleware_file_rejects_policy_sections() { + for yaml in [ + "landlock:\n compatibility: best_effort\n", + "filesystem_policy:\n read_only: [/usr]\n", + "network_policies:\n pypi:\n endpoints: [{ host: pypi.org, port: 443 }]\n", + ] { + let error = parse_middleware_yaml(yaml).expect_err(yaml); + assert!( + error_chain(&error).contains("only top-level"), + "{yaml}: {error:?}" + ); + } + } + + #[test] + fn endpoint_settings_reject_access_and_credential_fields() { + for (field, yaml) in [ + ("protocol", "protocol: rest"), + ("enforcement", "enforcement: enforce"), + ("access", "access: read-only"), + ("rules", "rules: [{ allow: { method: GET } }]"), + ("deny_rules", "deny_rules: [{ method: GET }]"), + ("allowed_ips", "allowed_ips: [10.0.0.0/8]"), + ("binaries", "binaries: [{ path: /usr/bin/curl }]"), + ( + "mcp.allow_all_known_mcp_methods", + "mcp: { allow_all_known_mcp_methods: true }", + ), + ( + "request_body_credential_rewrite", + "request_body_credential_rewrite: true", + ), + ("credential_signing", "credential_signing: sigv4"), + ( + "credential_binding", + "credential_binding: { provider: work }", + ), + ] { + let error = parse_middleware_yaml(&format!( + "endpoint_settings:\n - host: api.example.com\n port: 443\n {yaml}\n" + )) + .expect_err(field); + assert!(error_chain(&error).contains(field), "{field}: {error:?}"); + } + } + + #[cfg(feature = "cedar")] + #[test] + fn endpoint_settings_reject_gateway_managed_fields() { + // These fields have no YAML spelling; the gateway stamps them on + // provider endpoints, so they arrive only on the proto form. + for (field, setting) in [ + ( + "provider_credentialed", + NetworkEndpoint { + provider_credentialed: true, + ..Default::default() + }, + ), + ( + "token_grant_owner", + NetworkEndpoint { + token_grant_owner: "work".to_string(), + ..Default::default() + }, + ), + ] { + let mut policy = cedar_sourced_policy( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443");"#, + ); + policy.endpoint_settings.push(NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + ..setting + }); + let violations = validate_sandbox_policy(&policy).expect_err(field); + let reasons: Vec = violations + .iter() + .filter_map(|violation| match violation { + PolicyViolation::InvalidEndpointSetting { reason, .. } => Some(reason.clone()), + _ => None, + }) + .collect(); + assert_eq!( + reasons, + [format!( + "'{field}' is not an endpoint setting; the gateway sets it on provider \ + endpoints" + )], + "{field}" + ); + } + } + + #[test] + fn endpoint_settings_validate_selectors_and_values() { + for (case, yaml, expected) in [ + ("no host", " - port: 443\n", "host must not be empty"), + ("no port", " - host: a.example.com\n", "at least one port"), + ( + "TLD wildcard", + " - host: \"*.com\"\n port: 443\n", + "TLD wildcard", + ), + ( + "bad path", + " - host: a.example.com\n port: 443\n path: api\n", + "path must start", + ), + ( + "tls with path", + " - host: a.example.com\n port: 443\n path: /api\n tls: skip\n", + "whole connection", + ), + ( + "bad persisted_queries", + " - host: a.example.com\n port: 443\n persisted_queries: yes-please\n", + "persisted_queries must be", + ), + ( + "bad registry operation", + " - host: a.example.com\n port: 443\n graphql_persisted_queries:\n h: { operation_type: fetch }\n", + "operation_type must be", + ), + ( + "unknown MCP version", + " - host: a.example.com\n port: 443\n mcp: { versions: [\"1999-01-01\"] }\n", + "1999-01-01", + ), + ( + "two body limits", + " - host: a.example.com\n port: 443\n json_rpc: { max_body_bytes: 10 }\n mcp: { max_body_bytes: 10 }\n", + "not both", + ), + ( + "duplicate selector", + " - host: a.example.com\n ports: [443]\n - host: A.example.com\n ports: [8443, 443]\n", + "same host, path, and port", + ), + ] { + let error = + parse_middleware_yaml(&format!("endpoint_settings:\n{yaml}")).expect_err(case); + assert!(error_chain(&error).contains(expected), "{case}: {error:?}"); + } + // Different paths on one host:port are separate selectors. + parse_middleware_yaml( + "endpoint_settings:\n - host: a.example.com\n port: 443\n - host: a.example.com\n port: 443\n path: /api/**\n allow_encoded_slash: true\n", + ) + .expect("distinct paths are valid"); + } + + #[test] + fn middleware_selecting_a_tls_skip_endpoint_setting_is_rejected() { + let mut policy = cedar_sourced_policy( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443");"#, + ); + let file = parse_middleware_yaml(&format!( + "{MIDDLEWARE_FILE}endpoint_settings:\n - host: pypi.org\n port: 443\n tls: skip\n" + )) + .expect("file parses"); + policy.network_middlewares = file.network_middlewares; + policy.endpoint_settings = file.endpoint_settings; + let violations = validate_sandbox_policy(&policy).expect_err("conflict"); + assert!( + violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MiddlewareTlsSkipConflict { .. } + )), + "{violations:?}" + ); + } + + #[cfg(feature = "cedar")] + #[test] + fn cedar_policy_accepts_middleware_and_rejects_filesystem_paths() { + let mut policy = cedar_sourced_policy( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443");"#, + ); + policy.network_middlewares.insert( + "audit".to_string(), + openshell_core::proto::NetworkMiddlewareConfig { + middleware: "openshell/regex".to_string(), + ..Default::default() + }, + ); + assert!( + !validate_sandbox_policy(&policy) + .err() + .unwrap_or_default() + .iter() + .any(|v| matches!(v, PolicyViolation::InvalidCedarPolicy { .. })), + "network_middlewares must be allowed with Cedar" + ); + + policy.filesystem = Some(FilesystemPolicy { + read_only: vec!["/usr".to_string()], + ..Default::default() + }); + let violations = validate_sandbox_policy(&policy).expect_err("must reject"); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::InvalidCedarPolicy { .. })), + "{violations:?}" + ); + } + + #[cfg(not(feature = "cedar"))] + #[test] + fn cedar_policy_source_is_rejected_without_the_cedar_feature() { + let policy = cedar_sourced_policy( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443");"#, + ); + let violations = validate_sandbox_policy(&policy).expect_err("must reject"); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::InvalidCedarPolicy { .. })), + "{violations:?}" + ); + } + + #[cfg(feature = "cedar")] + #[test] + fn cedar_policy_source_passes_validation() { + let policy = cedar_sourced_policy( + r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443" +) +when { context.binary_path == "/usr/bin/curl" }; +"#, + ); + assert!(validate_sandbox_policy(&policy).is_ok()); + } + + #[test] + fn cedar_policy_source_rejects_unparsable_policy() { + let policy = cedar_sourced_policy("this is not valid cedar syntax {{{"); + let violations = validate_sandbox_policy(&policy).expect_err("must reject"); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::InvalidCedarPolicy { .. })), + "{violations:?}" + ); + } + + #[test] + fn cedar_policy_source_rejects_forbid_on_filesystem_path() { + let policy = cedar_sourced_policy( + r#" +forbid ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { resource == Sandbox::FilesystemPath::"/etc/shadow" }; +"#, + ); + let violations = validate_sandbox_policy(&policy).expect_err("must reject"); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::InvalidCedarPolicy { .. })), + "{violations:?}" + ); + } + + #[cfg(feature = "cedar")] + #[test] + fn cedar_policy_source_applies_filesystem_path_checks_to_grants() { + let policy = cedar_sourced_policy( + r#" +permit (principal, action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource in Sandbox::FilesystemPath::"/"); +permit (principal, action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/usr/../etc"); +permit (principal, action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"relative/dir"); +"#, + ); + let violations = validate_sandbox_policy(&policy).expect_err("must reject"); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::OverlyBroadPath { .. })), + "{violations:?}" + ); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::PathTraversal { .. })), + "{violations:?}" + ); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::RelativePath { .. })), + "{violations:?}" + ); + } + + #[test] + fn cedar_policy_source_rejects_coexistence_with_network_policies() { + let mut policy = cedar_sourced_policy( + r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443" +) +when { context.binary_path == "/usr/bin/curl" }; +"#, + ); + policy + .network_policies + .insert("extra".to_string(), NetworkPolicyRule::default()); + let violations = validate_sandbox_policy(&policy).expect_err("must reject"); + assert!( + violations.iter().any(|v| matches!( + v, + PolicyViolation::CedarMutuallyExclusiveWithNetworkPolicies + )), + "{violations:?}" + ); + } + + #[test] + fn empty_cedar_policy_source_is_a_no_op() { + // The YAML-sourced path (cedar_policy_source empty) must not trip + // any Cedar-specific check, even though it has no network_policies + // either. + let policy = restrictive_default_policy(); + assert!(validate_sandbox_policy(&policy).is_ok()); + } + /// Verify that the serialized YAML uses `filesystem_policy` (not /// `filesystem`) so it can be fed back to `parse_sandbox_policy`. #[test] @@ -3699,6 +5401,9 @@ network_policies: #[test] fn validate_accepts_empty_process() { let policy = SandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: HashMap::default(), + endpoint_settings: Vec::new(), version: 1, process: None, filesystem: None, @@ -4154,6 +5859,9 @@ network_policies: #[test] fn validate_accepts_numeric_uid_in_range() { let policy = SandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: HashMap::default(), + endpoint_settings: Vec::new(), version: 1, process: Some(ProcessPolicy { run_as_user: "1000".into(), @@ -4170,6 +5878,9 @@ network_policies: #[test] fn validate_accepts_boundary_uids() { let policy = SandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: HashMap::default(), + endpoint_settings: Vec::new(), version: 1, process: Some(ProcessPolicy { run_as_user: MIN_SANDBOX_UID.to_string(), @@ -4242,6 +5953,9 @@ network_policies: fn validate_accepts_mixed_sandbox_name_and_uid() { // run_as_user as "sandbox" name, run_as_group as numeric UID let policy = SandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: HashMap::default(), + endpoint_settings: Vec::new(), version: 1, process: Some(ProcessPolicy { run_as_user: "sandbox".into(), diff --git a/crates/openshell-policy/src/middleware.rs b/crates/openshell-policy/src/middleware.rs index 748935b673..f37c905659 100644 --- a/crates/openshell-policy/src/middleware.rs +++ b/crates/openshell-policy/src/middleware.rs @@ -286,6 +286,22 @@ pub fn validate(policy: &SandboxPolicy) -> Vec { } } } + // A Cedar policy's `tls: skip` comes from its endpoint settings. + for setting in &policy.endpoint_settings { + let overlaps_tls_skip = requires_inspection + && setting.tls == NetworkTlsMode::Skip as i32 + && compiled_selector.as_ref().is_some_and(|selector| { + HostPattern::new(&setting.host) + .is_ok_and(|endpoint| selector.may_match_pattern(&endpoint)) + }); + if overlaps_tls_skip { + violations.push(PolicyViolation::MiddlewareTlsSkipConflict { + middleware_name: name.clone(), + policy_name: "endpoint_settings".to_string(), + host: setting.host.clone(), + }); + } + } } violations diff --git a/crates/openshell-prover/src/containment.rs b/crates/openshell-prover/src/containment.rs index f7a7a43c3c..9eb9bba315 100644 --- a/crates/openshell-prover/src/containment.rs +++ b/crates/openshell-prover/src/containment.rs @@ -92,7 +92,15 @@ pub fn parse_policy_str(source: &str) -> Result guard, None => runtime.lock_sandbox_for_lifecycle(&lifecycle_guard).await, }; - runtime - .compensate_failed_create_inner( - &created, - lifecycle_guard, - sandbox_guard, - original, - ) - .await + Box::pin(runtime.compensate_failed_create_inner( + &created, + lifecycle_guard, + sandbox_guard, + original, + )) + .await } .instrument(request_span), ) @@ -4187,7 +4186,8 @@ impl ComputeRuntime { async fn reconcile_loop(self: Arc, mut cancel: watch::Receiver) { loop { - if let Err(err) = self.reconcile_store_with_backend(ORPHAN_GRACE_PERIOD).await { + if let Err(err) = Box::pin(self.reconcile_store_with_backend(ORPHAN_GRACE_PERIOD)).await + { warn!(error = %err, "Store reconciliation sweep failed"); } tokio::select! { @@ -4199,7 +4199,7 @@ impl ComputeRuntime { async fn restart_loop(self: Arc, mut cancel: watch::Receiver) { loop { - if let Err(err) = self.restart_due_sandboxes().await { + if let Err(err) = Box::pin(self.restart_due_sandboxes()).await { warn!(error = %err, "Sandbox restart sweep failed"); } tokio::select! { @@ -4742,7 +4742,8 @@ impl ComputeRuntime { continue; } - self.prune_missing_sandbox(record, sweep_started_at_ms, grace_ms) + // Boxed so this sweep's future stays small for its callers. + Box::pin(self.prune_missing_sandbox(record, sweep_started_at_ms, grace_ms)) .await .inspect_err(|_| crate::otel_tracing::mark_error(&tracing::Span::current()))?; } @@ -4836,7 +4837,8 @@ impl ComputeRuntime { && (driver_snapshot_confirms_stopped(&incoming) || driver_snapshot_reports_runtime_restart(&incoming)); if existing_phase != SandboxPhase::Starting && !ready_container_exit { - return self.apply_sandbox_update_locked(incoming, existing).await; + // Boxed so this update's future stays small for its callers. + return Box::pin(self.apply_sandbox_update_locked(incoming, existing)).await; } // Any snapshot can already be queued when StartSandbox moves the @@ -4885,7 +4887,7 @@ impl ComputeRuntime { Ok(Some(_) | None) | Err(_) => {} } - self.apply_sandbox_update_locked(incoming, existing).await + Box::pin(self.apply_sandbox_update_locked(incoming, existing)).await } async fn apply_sandbox_update_locked( @@ -10274,7 +10276,7 @@ mod tests { }); runtime.store.put_message(&sandbox).await.unwrap(); - runtime.restart_due_sandboxes().await.unwrap(); + Box::pin(runtime.restart_due_sandboxes()).await.unwrap(); assert_eq!(driver.stop_calls(), 1); assert_eq!(driver.start_calls(), 1); @@ -14110,7 +14112,7 @@ mod tests { tokio::time::timeout( Duration::from_secs(1), - runtime.reconcile_store_with_backend(Duration::ZERO), + Box::pin(runtime.reconcile_store_with_backend(Duration::ZERO)), ) .await .expect("prune sweep blocked on the stuck driver delete call") diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index e9c22bef6f..2faa3a9cab 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3461,7 +3461,11 @@ fn canonical_endpoint_path(path: &str) -> String { } pub(super) fn clear_provider_credentialed_markers(policy: &mut ProtoSandboxPolicy) { - for rule in policy.network_policies.values_mut() { + for rule in policy + .network_policies + .values_mut() + .chain(policy.provider_credential_rules.values_mut()) + { for endpoint in &mut rule.endpoints { endpoint.provider_credentialed = false; endpoint.token_grant_owner.clear(); @@ -3473,7 +3477,11 @@ fn stamp_provider_credentialed_endpoints( policy: &mut ProtoSandboxPolicy, scopes: &[CredentialedEndpointScope], ) { - for rule in policy.network_policies.values_mut() { + for rule in policy + .network_policies + .values_mut() + .chain(policy.provider_credential_rules.values_mut()) + { for endpoint in &mut rule.endpoints { endpoint.provider_credentialed = scopes .iter() @@ -3959,6 +3967,14 @@ async fn handle_update_config_inner( let mut new_policy = req.policy.ok_or_else(|| { Status::invalid_argument("policy is required for global policy update") })?; + // A global policy replaces every sandbox's policy, and a sandbox + // cannot switch formats after it starts, so a Cedar global policy + // would be rejected by every YAML sandbox. + if !new_policy.cedar_policy_source.is_empty() { + return Err(Status::invalid_argument( + "global policies must be YAML; Cedar policies can only be set per sandbox", + )); + } clear_provider_credentialed_markers(&mut new_policy); validate_no_reserved_provider_policy_keys(&new_policy)?; new_policy = validate_and_canonicalize_policy(new_policy)?; @@ -5955,7 +5971,7 @@ pub(super) async fn handle_approve_all_draft_chunks( state: &Arc, request: Request, ) -> Result, Status> { - let result = handle_approve_all_draft_chunks_inner(state, request).await; + let result = Box::pin(handle_approve_all_draft_chunks_inner(state, request)).await; if result.is_err() { emit_policy_decision_failure(PolicyDecisionOperation::ApproveAll, 0); } @@ -12298,6 +12314,56 @@ mod tests { ); } + #[tokio::test] + async fn sandbox_config_delivers_provider_rules_separately_for_a_cedar_policy() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let cedar_policy = ProtoSandboxPolicy { + version: 1, + cedar_policy_source: "permit(principal, \ + action == Sandbox::Action::\"NetworkConnect\", \ + resource == Sandbox::NetworkEndpoint::\"api.github.com:443\");" + .to_string(), + ..Default::default() + }; + state + .store + .put_message(&test_sandbox( + "sb-cedar-provider", + "cedar-provider", + cedar_policy, + vec!["work-github".to_string()], + )) + .await + .unwrap(); + + let effective_policy = get_sandbox_policy(&state, "sb-cedar-provider").await; + + assert!( + effective_policy.network_policies.is_empty(), + "provider rules must not be composed into a Cedar policy" + ); + let rule = effective_policy + .provider_credential_rules + .get("_provider_work_github") + .expect("provider rules travel in provider_credential_rules"); + assert!( + rule.endpoints + .iter() + .any(|endpoint| endpoint.host == "api.github.com") + ); + assert!( + rule.endpoints + .iter() + .any(|endpoint| endpoint.provider_credentialed), + "credentialed endpoints must be stamped" + ); + } + #[tokio::test] async fn sandbox_config_materializes_default_mcp_version_after_provider_composition() { use openshell_core::proto::{ProviderProfile, ProviderProfileCategory}; @@ -13497,6 +13563,63 @@ mod tests { ); } + #[tokio::test] + async fn provider_environment_delivers_bound_credentials_to_a_cedar_sandbox() { + use openshell_core::proto::GetSandboxProviderEnvironmentRequest; + + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let cedar_policy = ProtoSandboxPolicy { + version: 1, + cedar_policy_source: "permit(principal, \ + action == Sandbox::Action::\"NetworkConnect\", \ + resource == Sandbox::NetworkEndpoint::\"api.github.com:443\");" + .to_string(), + ..Default::default() + }; + state + .store + .put_message(&test_sandbox( + "sb-cedar-env", + "cedar-env", + cedar_policy, + vec!["work-github".to_string()], + )) + .await + .unwrap(); + + let response = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-cedar-env".to_string(), + supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, + })), + ) + .await + .expect("provider environment must resolve for a Cedar sandbox") + .into_inner(); + + assert_eq!( + response.environment.get("GITHUB_TOKEN"), + Some(&"ghp-test".to_string()) + ); + let binding = response + .static_credential_bindings + .get("GITHUB_TOKEN") + .expect("credential must stay bound to the provider's endpoints"); + assert!( + binding + .endpoints + .iter() + .any(|endpoint| endpoint.host == "api.github.com") + ); + } + #[tokio::test] async fn provider_readiness_snapshot_uses_baseline_static_binding_contract() { let state = test_server_state().await; @@ -15289,7 +15412,7 @@ mod tests { chunks.push(chunk); } - let approved = handle_approve_all_draft_chunks( + let approved = Box::pin(handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { sandbox: sandbox_name.to_string(), @@ -15305,7 +15428,7 @@ mod tests { .collect(), ..Default::default() })), - ) + )) .await .unwrap() .into_inner(); @@ -15425,7 +15548,7 @@ mod tests { .map(Option::unwrap) .collect::>(); - let approved = handle_approve_all_draft_chunks( + let approved = Box::pin(handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { sandbox: sandbox_name.to_string(), @@ -15441,7 +15564,7 @@ mod tests { .collect(), ..Default::default() })), - ) + )) .await .unwrap() .into_inner(); @@ -15626,7 +15749,7 @@ mod tests { .contains("allowed_ips includes private/internal range '10.0.0.0/8'.") ); - let skipped = handle_approve_all_draft_chunks( + let skipped = Box::pin(handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { request_id: String::new(), @@ -15640,7 +15763,7 @@ mod tests { review_token: chunk.review_token.clone(), }], })), - ) + )) .await .unwrap() .into_inner(); @@ -15657,7 +15780,7 @@ mod tests { "pending" ); - let approved = handle_approve_all_draft_chunks( + let approved = Box::pin(handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { request_id: String::new(), @@ -15671,7 +15794,7 @@ mod tests { review_token: chunk.review_token.clone(), }], })), - ) + )) .await .unwrap() .into_inner(); @@ -15782,7 +15905,7 @@ mod tests { .contains("allowed_ips includes private/internal range '10.0.0.0/8'.") ); - let skipped = handle_approve_all_draft_chunks( + let skipped = Box::pin(handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { sandbox: sandbox_name.to_string(), @@ -15792,7 +15915,7 @@ mod tests { include_security_flagged: false, ..Default::default() })), - ) + )) .await .unwrap() .into_inner(); @@ -15849,7 +15972,7 @@ mod tests { .await .unwrap(); - let skipped = handle_approve_all_draft_chunks( + let skipped = Box::pin(handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { sandbox: sandbox_name.to_string(), @@ -15859,7 +15982,7 @@ mod tests { include_security_flagged: false, ..Default::default() })), - ) + )) .await .unwrap() .into_inner(); @@ -21739,6 +21862,31 @@ mod tests { drop(unrelated_guard); } + #[tokio::test] + async fn update_config_global_policy_rejects_cedar_policy() { + let state = test_server_state().await; + + let err = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(ProtoSandboxPolicy { + version: 1, + cedar_policy_source: "permit(principal, \ + action == Sandbox::Action::\"NetworkConnect\", \ + resource == Sandbox::NetworkEndpoint::\"pypi.org:443\");" + .to_string(), + ..Default::default() + }), + ..Default::default() + })), + ) + .await + .expect_err("a Cedar global policy must be rejected"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("Cedar"), "{}", err.message()); + } + #[tokio::test] async fn update_config_global_policy_rejects_reserved_provider_key() { let state = test_server_state().await; @@ -24447,7 +24595,7 @@ mod tests { err.code() ); - let err = handle_approve_all_draft_chunks( + let err = Box::pin(handle_approve_all_draft_chunks( &state, non_member_request(ApproveAllDraftChunksRequest { sandbox: ("any").to_string(), @@ -24456,7 +24604,7 @@ mod tests { )), ..Default::default() }), - ) + )) .await .unwrap_err(); assert_eq!( diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index abdfccda10..503284f1e7 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -1027,6 +1027,11 @@ pub(super) fn validate_no_reserved_provider_policy_keys( "network_policies key '{key}' uses reserved '_provider_' prefix for provider composition; use 'openshell policy get --base' for a round-trippable base policy, or use 'openshell policy get --full' to inspect the effective policy including provider entries" ))); } + if !policy.provider_credential_rules.is_empty() { + return Err(Status::invalid_argument( + "provider_credential_rules is managed by the gateway from attached providers; use 'openshell policy get --base' for a round-trippable base policy", + )); + } Ok(()) } @@ -1036,6 +1041,20 @@ pub(super) fn validate_static_fields_unchanged( baseline: &ProtoSandboxPolicy, new: &ProtoSandboxPolicy, ) -> Result<(), Status> { + // Checked first so a format switch is reported as such rather than as + // whichever static field happens to differ between the two formats. + // The supervisor picks its authoritative policy engine (OPA or Cedar) + // once at startup from whether cedar_policy_source was set; a live + // reload only ever re-evaluates within that same engine. Flipping + // formats via update would leave the originally-chosen engine stale + // (silently evaluating against the old policy) while the other engine's + // reload is simply never consulted. + if baseline.cedar_policy_source.is_empty() != new.cedar_policy_source.is_empty() { + return Err(Status::invalid_argument( + "cannot switch a live sandbox between YAML and Cedar policy formats; \ + recreate the sandbox instead", + )); + } // Filesystem: allow additive changes (new paths can be added, but // existing paths cannot be removed and include_workdir cannot change). // This supports the supervisor's baseline path enrichment at startup. @@ -1054,6 +1073,20 @@ pub(super) fn validate_static_fields_unchanged( "process policy cannot be changed on a live sandbox (applied at startup)", )); } + // A Cedar policy's Landlock grants come from its text. Apply the same + // additive-only rule as YAML `filesystem`: Landlock cannot revoke a grant + // from a running process, so a narrowing edit would report success while + // the old grants stay in force until restart. + if !baseline.cedar_policy_source.is_empty() + && baseline.cedar_policy_source != new.cedar_policy_source + { + let baseline_grants = + openshell_policy::cedar_filesystem_grants(&baseline.cedar_policy_source); + let new_grants = openshell_policy::cedar_filesystem_grants(&new.cedar_policy_source); + if let (Some(baseline_grants), Some(new_grants)) = (baseline_grants, new_grants) { + validate_filesystem_additive(Some(&baseline_grants), Some(&new_grants))?; + } + } Ok(()) } @@ -2254,6 +2287,69 @@ mod tests { assert!(err.message().contains("round-trippable base policy")); } + #[test] + fn validate_no_reserved_provider_policy_keys_rejects_provider_credential_rules() { + use openshell_core::proto::NetworkPolicyRule; + + let mut policy = ProtoSandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".into(), + ..Default::default() + }; + policy + .provider_credential_rules + .insert("_provider_work".into(), NetworkPolicyRule::default()); + + let error = validate_no_reserved_provider_policy_keys(&policy) + .expect_err("user-submitted provider_credential_rules must be rejected"); + assert!(error.message().contains("provider_credential_rules")); + } + + #[test] + fn validate_and_canonicalize_policy_checks_endpoint_settings() { + use openshell_core::proto::{McpOptions, NetworkEndpoint}; + + let setting = NetworkEndpoint { + host: "mcp.example.com".into(), + port: 443, + mcp: Some(McpOptions { + versions: vec!["2025-11-25".into(), "2025-06-18".into()], + ..Default::default() + }), + ..Default::default() + }; + let cedar = ProtoSandboxPolicy { + cedar_policy_source: r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"mcp.example.com:443");"# + .into(), + endpoint_settings: vec![setting.clone()], + ..Default::default() + }; + let canonical = validate_and_canonicalize_policy(cedar.clone()) + .expect("Cedar policies accept endpoint settings"); + assert_eq!( + canonical.endpoint_settings[0] + .mcp + .as_ref() + .expect("mcp") + .versions, + vec!["2025-06-18", "2025-11-25"] + ); + + let yaml = ProtoSandboxPolicy { + endpoint_settings: vec![setting], + ..openshell_policy::restrictive_default_policy() + }; + let error = validate_and_canonicalize_policy(yaml).expect_err("YAML policy"); + assert!(error.message().contains("endpoint_settings"), "{error}"); + + let mut access = cedar; + access.endpoint_settings[0].protocol = "rest".into(); + access.endpoint_settings[0].token_grant_owner = "owner".into(); + let error = validate_and_canonicalize_policy(access).expect_err("access field"); + assert!(error.message().contains("'protocol'"), "{error}"); + assert!(error.message().contains("'token_grant_owner'"), "{error}"); + } + #[test] fn validate_no_reserved_provider_policy_keys_accepts_user_key() { use openshell_core::proto::NetworkPolicyRule; @@ -2295,6 +2391,149 @@ mod tests { assert!(validate_static_fields_unchanged(&policy, &policy).is_ok()); } + #[test] + fn validate_static_fields_rejects_switching_from_yaml_to_cedar() { + let baseline = ProtoSandboxPolicy::default(); + let new = ProtoSandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".into(), + provider_credential_rules: HashMap::default(), + ..Default::default() + }; + let result = validate_static_fields_unchanged(&baseline, &new); + assert!(result.is_err(), "format flip must be rejected"); + } + + #[test] + fn validate_static_fields_reports_a_format_switch_before_other_static_fields() { + let baseline = ProtoSandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".into(), + provider_credential_rules: HashMap::default(), + ..Default::default() + }; + let new = ProtoSandboxPolicy { + landlock: Some(openshell_core::proto::LandlockPolicy { + compatibility: "best_effort".into(), + }), + ..Default::default() + }; + let error = validate_static_fields_unchanged(&baseline, &new) + .expect_err("format flip must be rejected"); + assert!( + error.message().contains("YAML and Cedar"), + "{}", + error.message() + ); + } + + #[test] + fn validate_static_fields_rejects_switching_from_cedar_to_yaml() { + let baseline = ProtoSandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".into(), + provider_credential_rules: HashMap::default(), + ..Default::default() + }; + let new = ProtoSandboxPolicy::default(); + let result = validate_static_fields_unchanged(&baseline, &new); + assert!(result.is_err(), "format flip must be rejected"); + } + + /// A Cedar sandbox's process identity comes from its middleware file, and + /// `policy set` with a different one is rejected as for a YAML policy. + #[test] + fn validate_static_fields_rejects_process_change_within_cedar() { + use openshell_core::proto::ProcessPolicy; + + let baseline = ProtoSandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".into(), + process: Some(ProcessPolicy { + run_as_user: "1000".into(), + run_as_group: "sandbox".into(), + }), + ..Default::default() + }; + let same = ProtoSandboxPolicy { + cedar_policy_source: "forbid(principal, action, resource);".into(), + ..baseline.clone() + }; + assert!(validate_static_fields_unchanged(&baseline, &same).is_ok()); + + let changed = ProtoSandboxPolicy { + process: Some(ProcessPolicy { + run_as_user: "1001".into(), + run_as_group: "sandbox".into(), + }), + ..baseline.clone() + }; + let error = validate_static_fields_unchanged(&baseline, &changed) + .expect_err("a changed process identity must be rejected"); + assert!( + error.message().contains("process policy cannot be changed"), + "{}", + error.message() + ); + + let dropped = ProtoSandboxPolicy { + process: None, + ..baseline.clone() + }; + let error = validate_static_fields_unchanged(&baseline, &dropped) + .expect_err("dropping the process section must be rejected"); + assert!( + error.message().contains("process policy cannot be changed"), + "{}", + error.message() + ); + } + + #[test] + fn validate_static_fields_allows_cedar_policy_source_change_within_cedar() { + let baseline = ProtoSandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".into(), + provider_credential_rules: HashMap::default(), + ..Default::default() + }; + let new = ProtoSandboxPolicy { + cedar_policy_source: "forbid(principal, action, resource);".into(), + provider_credential_rules: HashMap::default(), + ..Default::default() + }; + assert!(validate_static_fields_unchanged(&baseline, &new).is_ok()); + } + + fn cedar_policy_granting(paths: &[&str]) -> ProtoSandboxPolicy { + let cedar_policy_source = paths + .iter() + .map(|path| { + format!( + "permit(principal, action == Sandbox::Action::\"ReadFile\", \ + resource in Sandbox::FilesystemPath::\"{path}\");" + ) + }) + .collect::>() + .join("\n"); + ProtoSandboxPolicy { + cedar_policy_source, + provider_credential_rules: HashMap::default(), + ..Default::default() + } + } + + #[test] + fn validate_static_fields_rejects_removing_a_cedar_filesystem_grant() { + let baseline = cedar_policy_granting(&["/usr", "/etc"]); + let new = cedar_policy_granting(&["/usr"]); + let error = validate_static_fields_unchanged(&baseline, &new) + .expect_err("narrowing Landlock grants on a live sandbox must be rejected"); + assert!(error.message().contains("/etc"), "{}", error.message()); + } + + #[test] + fn validate_static_fields_allows_adding_a_cedar_filesystem_grant() { + let baseline = cedar_policy_granting(&["/usr"]); + let new = cedar_policy_granting(&["/usr", "/etc"]); + assert!(validate_static_fields_unchanged(&baseline, &new).is_ok()); + } + #[test] fn validate_static_fields_allows_additive_filesystem() { use openshell_core::proto::FilesystemPolicy; diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 3ffff8945e..3f5f290985 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -154,12 +154,18 @@ mod tests { // Sandbox-wide grant reporting adds TokenGrantFailureObservation to both // closures and TokenGrantStatus to public reports. Older rows have no grant // record; absent report extensions retain compatibility without a rewrite. + // Cedar policies add cedar_policy_source and the gateway-managed + // provider_credential_rules map to SandboxPolicy in both closures. Existing + // rows decode both empty and remain YAML policies. + // Cedar endpoint settings add the repeated endpoint_settings field to + // SandboxPolicy in both closures. Existing rows decode it empty, so stored + // Cedar policies keep their current behavior. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "cc06b38c9ecb4c6518e0d4963f40c4f16f992483ce111c7b23cbcb729db946ea"; + "b2e63bc7137d8368be1a39ac7545eef4fc853bf85b48a461dd2f3e6f1b2d77a8"; const DURABLE_SCHEMA_SHA256: &str = - "d4dc7abdb91b919db1f7be0d4652e30cbfda86f3b2ed9b59d5de1f76a2bf691d"; + "36ee37f13c85532dd659e6ad839d5c0b0c1ffd7778e3226b6d793c406d1e621d"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "ac1ffc4b5b81467de27270a4e33f6db1e31eeb2c52f79750dbaaf19515eadbb2"; + "f97da31f52d0508a34392ad5e1e14ff1d13bbc5384ce6b232fd0dc29e8d3ca7c"; // Encoded with the schema that omitted stable_placeholder. Keep these // bytes fixed so the current encoder cannot hide a compatibility change. const LEGACY_STATIC_PROFILE: &str = "0a240a116c65676163792d70726f66696c652d6964120d6c65676163792d737461746963280712510a0d6c65676163792d73746174696312184c6567616379207374617469632063726564656e7469616c2a260a076170695f6b65791a1153594e5448455449435f4150495f4b455920012a06626561726572"; @@ -627,9 +633,9 @@ mod tests { overlap_hash.as_str(), ), ( - (314, 28), - (95, 22), - (83, 22), + (315, 28), + (96, 22), + (84, 22), PUBLIC_RPC_SCHEMA_SHA256, DURABLE_SCHEMA_SHA256, PUBLIC_DURABLE_OVERLAP_SHA256 diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 2291fbf2ff..b3e5b09264 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -16,6 +16,7 @@ openshell-core = { path = "../openshell-core", features = ["oauth"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } +openshell-policy-cedar = { path = "../openshell-policy-cedar" } openshell-policy-schema = { path = "../openshell-policy-schema" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } @@ -31,6 +32,7 @@ bytes = { workspace = true } flate2 = "1" futures = { workspace = true } glob = { workspace = true } +globset = { version = "0.4", default-features = false } hex = "0.4" hickory-proto = "0.26.1" ipnet = "2" @@ -66,6 +68,7 @@ bundled-ca-roots = ["dep:webpki-roots"] openshell-driver-vm = { path = "../openshell-driver-vm", default-features = false, features = ["allocation-tracking"] } openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } openshell-otel = { path = "../openshell-otel" } +openshell-providers = { path = "../openshell-providers" } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } opentelemetry_sdk = { workspace = true, features = ["testing"] } temp-env = "0.3" diff --git a/crates/openshell-supervisor-network/src/binary_resolution.rs b/crates/openshell-supervisor-network/src/binary_resolution.rs new file mode 100644 index 0000000000..f6718ec2e4 --- /dev/null +++ b/crates/openshell-supervisor-network/src/binary_resolution.rs @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Resolution of policy binary paths through a sandbox's root filesystem. +//! +//! Policies name binaries by the path a user writes (e.g. `/usr/bin/python3`), +//! while the kernel reports the canonical executable (e.g. +//! `/usr/bin/python3.11`). Both policy engines resolve each authored path +//! through `/proc//root/` with [`resolve_policy_binary`] once the +//! entrypoint process is known: YAML adds the target as another binary +//! entry, and Cedar exposes it in `context.binary_aliases`. +//! +//! [`compile_binary_glob`] builds the matcher the Rego rules apply to a YAML +//! binary glob, which Cedar reuses for `context.binary_globs`. + +#[cfg(any(target_os = "linux", test))] +use std::path::{Path, PathBuf}; + +/// Compiles a YAML binary glob as the Rego rules match it. +/// +/// The rules call `glob.match(pattern, ["/"], path)`, which builds the glob +/// with a literal `/` separator: `*` stays within one path segment and `**` +/// spans segments only as a whole segment. +/// +/// # Errors +/// +/// Returns the `globset` error for a pattern that does not compile, which +/// fails Rego evaluation. +pub fn compile_binary_glob(pattern: &str) -> Result { + Ok(globset::GlobBuilder::new(pattern) + .literal_separator(true) + .build()? + .compile_matcher()) +} + +/// Normalize a path by resolving `.` and `..` components without touching +/// the filesystem. Only works correctly for absolute paths. +#[cfg(any(target_os = "linux", test))] +pub fn normalize_path(path: &Path) -> PathBuf { + let mut result = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + result.pop(); + } + std::path::Component::CurDir => {} + other => result.push(other), + } + } + result +} + +// Only the Linux resolver constructs the non-`Literal` variants; on other +// platforms the stub returns `Literal`, so the rest look dead there. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Debug, PartialEq, Eq)] +pub enum BinaryResolution { + /// Symlink resolved to a different canonical target — add it to policy. + Resolved(String), + /// Nothing to add: glob, pid 0, not a symlink, or already canonical. + Literal, + /// Candidate absent under an accessible process root — expected, quiet. + Absent, + /// The process root `/proc//root` itself is unreachable (pid gone or + /// denied) — actionable, and `CAP_SYS_PTRACE` guidance is relevant. + Inaccessible(std::io::ErrorKind), + /// The process root is reachable but the candidate path itself failed for a + /// non-NotFound reason (a parent component is permission-denied, or the path + /// forms a symlink loop) — actionable, but a target-path problem rather than + /// a process-root one, so `CAP_SYS_PTRACE` guidance does not apply. + CandidateInaccessible(std::io::ErrorKind), + /// A component mid symlink-chain failed to resolve. + ChainBroken(std::io::ErrorKind), +} + +impl BinaryResolution { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub const fn classify_first_probe_error(kind: std::io::ErrorKind) -> Self { + // Callers reach this only after confirming the process root is + // reachable, so a non-NotFound failure here is specific to the + // candidate path, not the process root. + match kind { + std::io::ErrorKind::NotFound => Self::Absent, + _ => Self::CandidateInaccessible(kind), + } + } +} + +/// Resolve a policy binary path through the container's root filesystem. +/// +/// On Linux, `/proc//root/` provides access to the container's mount +/// namespace. If the policy path is a symlink inside the container +/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), the canonical target is +/// returned as [`BinaryResolution::Resolved`]. The outcome is classified as: +/// - [`BinaryResolution::Literal`] — not on Linux, `entrypoint_pid` is 0 +/// (container not yet started), the path contains glob characters, it is not +/// a symlink, or the resolved path equals the original. +/// - [`BinaryResolution::Absent`] — the candidate does not exist under an +/// otherwise reachable process root (expected; the caller logs it quietly). +/// - [`BinaryResolution::Inaccessible`] — `/proc//root` itself is +/// unreachable (pid gone or access denied). +/// - [`BinaryResolution::CandidateInaccessible`] — the process root is +/// reachable but the candidate path failed for a non-NotFound reason. +/// - [`BinaryResolution::ChainBroken`] — a component mid symlink-chain failed, +/// or the chain forms a cycle / exceeds the kernel symlink limit. +#[cfg(target_os = "linux")] +pub fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> BinaryResolution { + if policy_path.contains('*') || entrypoint_pid == 0 { + return BinaryResolution::Literal; + } + + // Confirm the process root itself is reachable before probing candidates. + // A leaf `ENOENT` (absent candidate) and an unreachable process root + // (pid gone -> `ENOENT`, or denied -> `EACCES`) are indistinguishable at + // the leaf path, so check the root first. Any failure here is an access + // problem, not an absent candidate, and must stay actionable. + if let Err(e) = std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) { + return BinaryResolution::Inaccessible(e.kind()); + } + + // Walk the symlink chain inside the container filesystem using + // read_link rather than canonicalize. canonicalize resolves + // /proc//root itself (a kernel pseudo-symlink to /) which + // strips the prefix we need. read_link only reads the target of + // the specified symlink, keeping us in the container's namespace. + let mut resolved = PathBuf::from(policy_path); + + // Set once the walk reaches a real (non-symlink) target or a terminal + // dead end. If the iteration cap is hit while every component is still a + // symlink, this stays false and the chain is treated as broken. + let mut reached_target = false; + + // Linux SYMLOOP_MAX is 40; stop before infinite loops + for _ in 0..40 { + let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); + + tracing::debug!( + "Symlink resolution: probing container_path={container_path} for policy_path={policy_path} pid={entrypoint_pid}" + ); + + let meta = match std::fs::symlink_metadata(&container_path) { + Ok(m) => m, + Err(e) => { + // First iteration is the original policy path: an absent + // candidate (NotFound) is expected, any other error means the + // process root itself is unreachable. Later iterations are + // chain components, so a failure there is a broken symlink + // chain. Classify without logging; the caller emits the log at + // the appropriate level. + if resolved.as_os_str() == policy_path { + // The up-front root check and this probe are separate + // syscalls; if the process exited in between, the whole + // `/proc/` tree is gone and the leaf probe also + // reports NotFound. Re-check the root so a vanished process + // stays classified as Inaccessible (actionable) rather than + // being masked as an absent candidate (quiet). + if e.kind() == std::io::ErrorKind::NotFound + && let Err(root_error) = + std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) + { + return BinaryResolution::Inaccessible(root_error.kind()); + } + return BinaryResolution::classify_first_probe_error(e.kind()); + } + return BinaryResolution::ChainBroken(e.kind()); + } + }; + + if !meta.file_type().is_symlink() { + // Reached a non-symlink — this is the final resolved target + reached_target = true; + break; + } + + let target = match std::fs::read_link(&container_path) { + Ok(t) => t, + // A symlink whose target can't be read is a broken chain; the + // caller logs it. + Err(e) => return BinaryResolution::ChainBroken(e.kind()), + }; + + if target.is_absolute() { + resolved = target; + } else if let Some(parent) = resolved.parent() { + // Relative symlink: resolve against the containing directory + // e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11 + resolved = normalize_path(&parent.join(&target)); + } else { + // No parent to resolve a relative target against — terminal. + reached_target = true; + break; + } + } + + if !reached_target { + // The cap was exhausted while following symlinks. Linux SYMLOOP_MAX is + // 40, so a chain of exactly 40 symlinks ending at a real file is still + // valid — after the 40th hop `resolved` may already point at that file. + // Inspect it once more without following another link: a non-symlink is + // the valid final target (fall through to the Literal/Resolved logic + // below), while another symlink (or an error) is a genuine cycle + // (a -> b -> a) or a chain deeper than the kernel allows. read_link + // resolves one hop at a time, so the kernel never surfaces ELOOP; treat + // those as a broken chain so the caller logs it and matches literally. + let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); + + match std::fs::symlink_metadata(&container_path) { + Ok(meta) if !meta.file_type().is_symlink() => {} + Ok(_) => { + return BinaryResolution::ChainBroken( + std::io::Error::from_raw_os_error(libc::ELOOP).kind(), + ); + } + Err(e) => return BinaryResolution::ChainBroken(e.kind()), + } + } + + let resolved_str = resolved.to_string_lossy().into_owned(); + + if resolved_str == policy_path { + BinaryResolution::Literal + } else { + // The caller logs the resolution at info level. + BinaryResolution::Resolved(resolved_str) + } +} + +#[cfg(not(target_os = "linux"))] +pub fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> BinaryResolution { + BinaryResolution::Literal +} + +/// Resolves a policy binary path through the container's root filesystem +/// and logs the outcome. +/// +/// Returns the canonical target when `policy_path` is a symlink inside the +/// container that resolves to a different path, which YAML adds as another +/// binary entry; see [`resolve_binary_in_container`]. A Cedar sandbox +/// resolves the paths its policies test in `context.binary_aliases` with +/// this same function. +pub fn resolve_policy_binary(policy_path: &str, entrypoint_pid: u32) -> Option { + match resolve_binary_in_container(policy_path, entrypoint_pid) { + BinaryResolution::Resolved(resolved) => { + tracing::info!( + "Resolved policy binary symlink: original={policy_path} resolved={resolved} pid={entrypoint_pid}" + ); + return Some(resolved); + } + BinaryResolution::Absent => { + tracing::debug!( + "Policy binary candidate not present in container: path={policy_path} pid={entrypoint_pid}. Matched literally." + ); + } + BinaryResolution::Inaccessible(kind) => { + tracing::warn!( + "Cannot access container filesystem for symlink resolution: path={policy_path} pid={entrypoint_pid} \ + error_kind={kind:?}. Binary paths in policy will be matched literally. \ + If this binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ + use the canonical path instead, or run with CAP_SYS_PTRACE." + ); + } + BinaryResolution::CandidateInaccessible(kind) => { + tracing::warn!( + "Cannot access policy binary candidate path: path={policy_path} pid={entrypoint_pid} \ + error_kind={kind:?}. Binary path will be matched literally. \ + A parent component is permission-denied or the path forms a symlink loop; \ + the process root itself is reachable." + ); + } + BinaryResolution::ChainBroken(kind) => { + tracing::warn!( + "Symlink chain broken during resolution: path={policy_path} pid={entrypoint_pid} error_kind={kind:?}. \ + Matched by original path only." + ); + } + BinaryResolution::Literal => {} + } + None +} diff --git a/crates/openshell-supervisor-network/src/cedar_only.rs b/crates/openshell-supervisor-network/src/cedar_only.rs new file mode 100644 index 0000000000..c4e17a48f5 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_only.rs @@ -0,0 +1,4658 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Cedar as the sole, authoritative network policy engine for a sandbox. +//! +//! [`CedarOnlyEngine`] evaluates an authored `.cedar` policy directly. It is +//! selected instead of [`crate::opa::OpaEngine`], never alongside it: +//! a sandbox's `SandboxPolicy.cedar_policy_source` being non-empty is what +//! makes a sandbox use this engine instead of OPA, decided once at policy +//! load (see `crates/openshell-supervisor/src/lib.rs::load_policy`). +//! +//! CONNECT-time matching (host/binary/ancestor) is covered directly by +//! [`openshell_policy_cedar::CedarEngine::evaluate_network`]. +//! Per-request L7 enforcement is covered by +//! [`openshell_policy_cedar::CedarEngine::evaluate_l7`] via +//! [`CedarL7TunnelEngine`], the handle each inspected tunnel's +//! [`crate::policy_engine::TunnelPolicyEngine`] delegates to. [`l7_endpoint_configs_for`] populates +//! `EgressAuthorization::endpoint_configs` for every endpoint an +//! `HttpRequest` policy names, exactly in its scope or as a host glob in its +//! conditions, so the proxy routes an allowed CONNECT into L7 inspection +//! instead of unconditional passthrough. A host glob inspects every host it +//! matches, selected with the host matcher YAML endpoints and policy DNS +//! use. The Cedar engine rejects at load any `HttpRequest` policy it could +//! not route this way. +//! DNS eligibility is covered by +//! [`CedarOnlyEngine::policy_dns_eligibility_snapshot`], one record per host +//! and transport, named by its index under the policy name `cedar`. An +//! endpoint a `@transport("tcp")` permit names is native TCP, and its record +//! carries `protocol: tcp`, as a YAML `protocol: tcp` endpoint's does. +//! `EgressAuthorization::matched_endpoints` lists the records that cover an +//! allowed connection's host and port, with the same indices, so the +//! transparent TCP listener can correlate a decision with the policy DNS +//! mapping it dialed, as it does for YAML. +//! +//! Provider credentials work the same way as for YAML sandboxes, except that +//! providers never grant access: the gateway delivers attached providers' +//! rules in `SandboxPolicy.provider_credential_rules`, and for a connection +//! Cedar allows, each matching provider endpoint contributes its credential +//! settings (credential marker, rewrite options, request signing) to +//! `endpoint_configs` and to the credential guard. Whether the connection is +//! inspected per request is still Cedar's decision. +//! +//! Endpoint settings that are configuration rather than access control +//! (`tls: skip`, `allow_encoded_slash`, body limits, MCP revisions, and +//! GraphQL persisted queries) come from `SandboxPolicy.endpoint_settings`, +//! authored in the middleware file. They are merged into the endpoint +//! configs of connections Cedar allows (see +//! [`LoadedPolicy::endpoint_configs`]), and hash-only GraphQL persisted +//! queries are resolved through their registry before Cedar evaluates them, +//! as the Rego rules do. +//! +//! `@path` gives one `host:port` a different protocol and enforcement per +//! path. Each inspected path gets its own endpoint config, and each request +//! is evaluated with the inspection of the config the relay selects for it +//! (see [`LoadedPolicy::route_request`]). WebSocket messages arrive as +//! `WEBSOCKET_TEXT` requests on their upgrade path; for a +//! `websocket-graphql` endpoint the relay classifies them first and supplies +//! their GraphQL operations. +//! +//! Token grants follow the YAML rule too: an owner's grant is admitted only +//! for a request Cedar allows and the owner's own provider endpoint admits +//! under its own rules. The provider rules are compiled to Cedar (see +//! `crate::cedar_provider_rules`) and evaluated with the same request, so +//! no Rego is involved (see +//! [`CedarL7TunnelEngine::admitted_token_grant_owners`]). +//! +//! Destination addresses follow YAML `allowed_ips`: a permit may require +//! `context.destination_ip` to lie in fixed ranges. A connection is allowed +//! at CONNECT time when Cedar allows it for some address, and the decision +//! carries a [`CedarDestination`] that the proxy applies to each resolved +//! address (see `proxy::destination`). Policy DNS records carry each +//! permit's ranges as `allowed_ips`, so answers are filtered as YAML filters +//! them. Provider endpoints grant no access, so their `allowed_ips` only +//! narrow: each matching provider endpoint's ranges must also admit every +//! resolved address, as they restrict a YAML provider endpoint's +//! connections, but they never admit a private address Cedar's own rules +//! reject. A host-less provider endpoint, which YAML matches by its +//! `allowed_ips` alone, matches no connection. +//! +//! Every read of the generation counter happens under the engine lock, and +//! [`CedarOnlyEngine::commit`] advances it under the write lock, so a +//! decision is always reported against the generation of the policy that +//! made it. +//! +//! Network middleware is selected without Rego: the engine owns a +//! `MiddlewareState` holding the policy's `network_middlewares`, the +//! connected service registry, and a middleware generation of its own (see +//! `crate::network_middleware`). [`CedarOnlyEngine::commit_configuration`] +//! publishes the Cedar policy, the middleware, and the caller's provider +//! credentials together, or nothing, so a Cedar sandbox needs no OPA engine +//! at all. A quarantine advances both generations, so the configuration +//! guard the policy poll loop pins becomes stale as it does for YAML. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +use miette::Result; +use openshell_core::policy_identity::deterministic_policy_hash; +use openshell_core::proto::{NetworkPolicyRule, SandboxPolicy as ProtoSandboxPolicy}; +use openshell_ocsf::{ + ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, HttpActivityBuilder, + HttpRequest, SeverityId, StateId, StatusId, Url as OcsfUrl, ocsf_emit, +}; +use openshell_policy_cedar::{ + AuthorizedNetworkEndpoint, CedarEngine, CedarEngineError, Decision, HttpRequestInput, IpNet, + L7Endpoint, L7Protocol, NetworkConnectRequest, NetworkEvaluation, TokenGrantRules, + normalize_host, +}; +use tokio::sync::watch; + +use crate::network_middleware::{MiddlewareConfigs, MiddlewareState}; +use crate::policy_types::{ + DnsRecord, EgressAuthorization, EndpointConfig, EndpointProtocol, EnforcementSetting, + GraphqlPersistedQuery, MatchedEndpoint, NetworkAction, NetworkInput, + PolicyDnsEligibilitySnapshot, PolicyGenerationGuard, TlsSetting, generation_guard_for, +}; +use openshell_supervisor_middleware::MiddlewareRegistry; + +/// User and group entity id sent with every Cedar request. +/// +/// Process identity is fixed per sandbox and enforced by the runtime, not +/// by network policy, so requests carry a constant identity that satisfies +/// the schema's `Process` shape. Policies that test `principal.user` see +/// this value. +const PLACEHOLDER_IDENTITY: &str = "sandbox"; + +/// Policy name of every Cedar policy DNS record and matched endpoint. +/// +/// Cedar policies have no YAML-style names, so records are told apart by +/// their index alone. +const CEDAR_POLICY_NAME: &str = "cedar"; + +/// Builds the Cedar CONNECT request for one egress attempt. +fn network_request_from_input(input: &NetworkInput) -> NetworkConnectRequest { + NetworkConnectRequest { + user: PLACEHOLDER_IDENTITY.to_string(), + group: PLACEHOLDER_IDENTITY.to_string(), + host: input.host.clone(), + port: input.port, + binary_path: input.binary_path.to_string_lossy().into_owned(), + ancestors: input + .ancestors + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + // Filled from the loaded policy's aliases and globs; see + // `BinaryAliases` and `BinaryGlobs`. + binary_aliases: Vec::new(), + binary_globs: Vec::new(), + destination_ip: None, + } +} + +/// The active Cedar policy and the provider settings delivered with it. +/// +/// Swapped as one unit on reload so a decision never pairs one revision's +/// Cedar policy with another revision's provider settings. +struct LoadedPolicy { + /// Shared with the [`CedarDestination`] of each decision it makes. + cedar: Arc, + /// Hash of the canonical policy, stamped on every endpoint config so + /// token-grant reporting binds each request to the policy it was + /// selected under, as the YAML engine's endpoint data does. + policy_hash: String, + providers: Vec, + settings: Vec, + /// The provider rules compiled to Cedar, used only to select token-grant + /// owners. `None` when no provider endpoint carries an owner. + token_grants: Option, + /// The policy binary paths that are symlinks in the sandbox. + binary_aliases: BinaryAliases, + /// The binary glob patterns the Cedar policy and the provider rules + /// test, compiled with the YAML matcher. + binary_globs: BinaryGlobs, + /// The entrypoint process whose root filesystem `binary_aliases` and + /// `token_grants` were resolved in; 0 before it is known. + entrypoint_pid: u32, + /// Test-only: makes the next binary resolution fail, standing in for a + /// provider rule set that compiles for one entrypoint process but not + /// another. + #[cfg(test)] + fail_resolution: bool, +} + +impl std::fmt::Debug for LoadedPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoadedPolicy") + .field("cedar", &self.cedar) + .field("policy_hash", &self.policy_hash) + .field("providers", &self.providers) + .field("settings", &self.settings) + .field("token_grants", &self.token_grants.is_some()) + .field("binary_aliases", &self.binary_aliases) + .field("binary_globs", &self.binary_globs) + .field("entrypoint_pid", &self.entrypoint_pid) + .finish() + } +} + +impl LoadedPolicy { + /// Loads `policy`, resolving its binary paths in the root filesystem of + /// `entrypoint_pid` (none when it is 0), as YAML does. + fn from_proto(policy: &ProtoSandboxPolicy, entrypoint_pid: u32) -> Result { + let cedar = Arc::new( + CedarEngine::from_policy_str(&policy.cedar_policy_source) + .map_err(|e| miette::miette!("{e}"))?, + ); + // The CLI prints these when the file is submitted; the sandbox logs + // them so operators see them too. + for warning in cedar.warnings() { + tracing::warn!(%warning, "Cedar policy loaded with a warning"); + ocsf_emit!( + ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Low) + .status(StatusId::Success) + .state(StateId::Enabled, "cedar policy loaded") + .message(format!("CONFIG:CEDAR policy warning: {warning}")) + .build() + ); + } + let mut providers = Vec::new(); + for rule in policy.provider_credential_rules.values() { + for endpoint in &rule.endpoints { + providers.push(ProviderEndpoint::from_proto(endpoint)); + } + } + let token_grants = crate::cedar_provider_rules::compile( + &policy.provider_credential_rules, + entrypoint_pid, + )?; + Ok(Self { + binary_aliases: BinaryAliases::resolve(cedar.binary_alias_paths(), entrypoint_pid), + binary_globs: BinaryGlobs::compile(&cedar, token_grants.as_ref()), + cedar, + policy_hash: deterministic_policy_hash(policy), + providers, + settings: policy + .endpoint_settings + .iter() + .map(EndpointSetting::from_proto) + .collect(), + token_grants, + entrypoint_pid, + #[cfg(test)] + fail_resolution: false, + }) + } + + /// Returns this policy with its binary paths resolved again, for + /// `entrypoint_pid`. + /// + /// `rules` are the provider rules the policy was loaded with. The copy + /// shares the Cedar engine; only `binary_aliases`, `binary_globs`, + /// `token_grants`, and `entrypoint_pid` differ. Does no locking, so a caller can resolve the + /// active policy's copy outside the engine lock and swap it in later. + /// + /// # Errors + /// + /// Returns an error if the provider rules fail to compile for + /// `entrypoint_pid`. + fn resolved_for( + &self, + rules: &HashMap, + entrypoint_pid: u32, + ) -> Result { + #[cfg(test)] + if self.fail_resolution { + return Err(miette::miette!( + "provider credential rules failed to compile: injected test failure" + )); + } + let token_grants = crate::cedar_provider_rules::compile(rules, entrypoint_pid)?; + Ok(Self { + cedar: Arc::clone(&self.cedar), + policy_hash: self.policy_hash.clone(), + providers: self.providers.clone(), + settings: self.settings.clone(), + binary_aliases: BinaryAliases::resolve(self.cedar.binary_alias_paths(), entrypoint_pid), + binary_globs: BinaryGlobs::compile(&self.cedar, token_grants.as_ref()), + token_grants, + entrypoint_pid, + #[cfg(test)] + fail_resolution: false, + }) + } + + /// Returns whether `other` resolved to a different entrypoint process or + /// different aliases or glob patterns than this policy. + fn resolution_differs(&self, other: &Self) -> bool { + self.entrypoint_pid != other.entrypoint_pid + || self.binary_aliases != other.binary_aliases + || self.binary_globs != other.binary_globs + } + + /// Builds the endpoint configs for an allowed connection to `host:port`. + /// + /// Cedar inspects each path its `HttpRequest` policies declare with + /// `@path` (and, for policies without `@path`, every path) with that + /// path's protocol and enforcement; each gets a config with that `path`, + /// so the relay's most-specific-path selection picks protocol and + /// enforcement as it does for YAML endpoints sharing a `host:port`. + /// + /// Each matching provider endpoint contributes its settings, with its + /// own L7 rules removed and its protocol replaced by the inspection + /// Cedar declares for its path (see [`RoutePlan`]). A Cedar path whose + /// config no provider endpoint supplies gets a config of its own. + /// + /// Matching endpoint settings without a path apply to every config. + /// Settings with a path apply to the config with that path, which is + /// added with Cedar's inspection when absent and Cedar inspects that + /// path. Without inspection, only a path-less `tls` setting has an + /// effect, so a config carrying it is added when no other config exists. + fn endpoint_configs(&self, host: &str, port: u16) -> Vec { + let plan = self.route_plan(host, port); + plan.routes.iter().map(|route| plan.build(route)).collect() + } + + /// Plans the endpoint configs for `host:port`, in the order + /// [`Self::endpoint_configs`] returns them. + fn route_plan(&self, host: &str, port: u16) -> RoutePlan<'_> { + let host = normalize_host(host); + // Selected by the exact endpoint and every host glob the host + // matches, with the matcher endpoint settings and providers use. + let inspections: Vec<(String, L7Endpoint)> = self.cedar.l7_endpoints(&host, port).collect(); + let settings: Vec<&EndpointSetting> = self + .settings + .iter() + .filter(|setting| setting.matches(&host, port)) + .collect(); + let mut routes: Vec> = self + .providers + .iter() + .filter(|endpoint| endpoint.matches(&host, port)) + .map(|endpoint| { + let path = endpoint.config.path.clone(); + Route { + provider: Some(endpoint), + inspection: inspection_for(&inspections, &path), + path, + } + }) + .collect(); + + if inspections.is_empty() { + let tls_only = routes.is_empty() + && settings + .iter() + .any(|setting| setting.path.is_empty() && setting.fields.tls.is_some()); + if tls_only { + routes.push(Route { + provider: None, + path: String::new(), + inspection: None, + }); + } + return RoutePlan { + routes, + settings, + policy_hash: &self.policy_hash, + }; + } + + for (path, endpoint) in &inspections { + if !routes.iter().any(|route| route.path == *path) { + routes.push(Route { + provider: None, + path: path.clone(), + inspection: Some((path.clone(), *endpoint)), + }); + } + } + for setting in &settings { + if setting.path.is_empty() || routes.iter().any(|route| route.path == setting.path) { + continue; + } + // A setting for a path Cedar does not inspect has no effect. + if let Some(inspection) = inspection_for(&inspections, &setting.path) { + routes.push(Route { + provider: None, + path: setting.path.clone(), + inspection: Some(inspection), + }); + } + } + RoutePlan { + routes, + settings, + policy_hash: &self.policy_hash, + } + } + + /// Returns how the relay routes a request for `path` to `host:port`. + /// + /// Selected as the relay selects it: among the configs it inspects with, + /// the one whose path matches with the highest specificity, the last + /// such config on a tie. + fn route_request(&self, host: &str, port: u16, path: &str) -> RequestRoute { + let plan = self.route_plan(host, port); + match plan.select(path) { + Some(route) => RequestRoute::Routed { + endpoint_path: route + .inspection + .as_ref() + .map(|(endpoint_path, _)| endpoint_path.clone()) + .unwrap_or_default(), + }, + None if plan.routes.iter().any(Route::is_inspected) => RequestRoute::Unmatched, + None => RequestRoute::Uninspected, + } + } + + /// Returns the endpoint config the relay selects for a request to `path`. + fn request_config(&self, host: &str, port: u16, path: &str) -> Option { + let plan = self.route_plan(host, port); + plan.select(path).map(|route| plan.build(route)) + } + + /// Returns the `allowed_ips` of each provider endpoint matching + /// `host:port` that has them. + /// + /// Each such endpoint supplies credential settings to the connection, so + /// each one's ranges must admit every address it reaches, as a YAML + /// provider endpoint's `allowed_ips` restrict its connections. + fn provider_ranges(&self, host: &str, port: u16) -> Vec { + let host = normalize_host(host); + self.providers + .iter() + .filter(|endpoint| endpoint.matches(&host, port)) + .filter(|endpoint| !matches!(&endpoint.allowed_ips, Ok(ranges) if ranges.is_empty())) + .map(|endpoint| endpoint.allowed_ips.clone()) + .collect() + } +} + +/// How the relay routes one request on a connection Cedar allowed. +#[derive(Debug, Clone, PartialEq, Eq)] +enum RequestRoute { + /// Cedar does not inspect the connection. + Uninspected, + /// Cedar inspects the connection, but no inspected path matches the + /// request, so the relay denies it before evaluation. + Unmatched, + /// The request is inspected as the Cedar path `endpoint_path` declares. + Routed { endpoint_path: String }, +} + +/// The deny reason for a request no inspected path matches, the same reason +/// the relay reports. +const UNMATCHED_PATH: &str = "no L7 endpoint path matched request"; + +/// The endpoint configs of one allowed connection, before they are built. +struct RoutePlan<'a> { + routes: Vec>, + /// The endpoint settings matching the connection, in order. + settings: Vec<&'a EndpointSetting>, + /// The hash every built config carries; see [`LoadedPolicy::policy_hash`]. + policy_hash: &'a str, +} + +/// One endpoint config of a [`RoutePlan`]. +struct Route<'a> { + /// The provider endpoint supplying the config, if any. + provider: Option<&'a ProviderEndpoint>, + /// The config's `path`; empty for none. + path: String, + /// The Cedar path whose inspection the config carries, with that + /// inspection; `None` leaves the config uninspected. + inspection: Option<(String, L7Endpoint)>, +} + +impl Route<'_> { + /// Returns whether the relay inspects requests with this config: only a + /// config with a protocol is an L7 config. + fn is_inspected(&self) -> bool { + self.inspection.is_some() + } +} + +impl RoutePlan<'_> { + /// Selects the route for a request to `path` as the relay selects a + /// config: the most specific matching path, the last one on a tie. + fn select(&self, path: &str) -> Option<&Route<'_>> { + self.routes + .iter() + .filter(|route| route.is_inspected()) + .filter(|route| crate::l7::endpoint_path_matches(&route.path, path)) + .max_by_key(|route| route.path.chars().filter(|c| *c != '*').count()) + } + + /// Builds the endpoint config for `route`. + fn build(&self, route: &Route<'_>) -> EndpointConfig { + let inspection = route.inspection.as_ref().map(|(_, endpoint)| *endpoint); + let protocol = inspection.map(|inspection| inspection.protocol); + let mut config = route.provider.map_or_else( + || EndpointConfig { + path: route.path.clone(), + ..EndpointConfig::default() + }, + |endpoint| endpoint.config.clone(), + ); + if let Some(inspection) = inspection { + apply_inspection(&mut config, inspection); + } + // Every inspected protocol binds grant reporting to the policy + // selected before credential acquisition, as the YAML engine does. + config.policy_hash = self.policy_hash.to_string(); + let host_wide: Vec<&EndpointSetting> = self + .settings + .iter() + .copied() + .filter(|setting| setting.path.is_empty()) + .collect(); + apply_settings(&mut config, &host_wide, protocol); + if !route.path.is_empty() { + let scoped: Vec<&EndpointSetting> = self + .settings + .iter() + .copied() + .filter(|setting| setting.path == route.path) + .collect(); + apply_settings(&mut config, &scoped, protocol); + } + config + } +} + +/// Returns the Cedar inspection for a config with endpoint path `path`. +/// +/// The Cedar path declared as exactly `path`, or else the one the relay +/// would select for a request whose path is the text of `path`: for +/// example, a provider endpoint at `/api/**` takes the inspection of a Cedar +/// path `/api/**` or, failing that, of the path-less Cedar endpoint. +fn inspection_for( + inspections: &[(String, L7Endpoint)], + path: &str, +) -> Option<(String, L7Endpoint)> { + inspections + .iter() + .find(|(declared, _)| declared == path) + .or_else(|| { + inspections + .iter() + .filter(|(declared, _)| crate::l7::endpoint_path_matches(declared, path)) + .max_by_key(|(declared, _)| declared.chars().filter(|c| *c != '*').count()) + }) + .cloned() +} + +impl LoadedPolicy { + /// Returns the policy DNS records, in the order + /// [`CedarOnlyEngine::policy_dns_eligibility_snapshot`] publishes them; + /// a record's position is its endpoint index. + fn dns_records(&self) -> Vec { + self.cedar + .dns_endpoints() + .iter() + .enumerate() + .map(|(endpoint_index, authorized)| dns_record(endpoint_index, authorized)) + .collect() + } + + /// Returns the policy DNS records that cover `host:port`, with the + /// indices the DNS snapshot publishes. + /// + /// Matched as policy DNS matches a name against a record, so a + /// transparent connection whose mapping came from one of these records + /// correlates with the decision. + fn matched_endpoints(&self, host: &str, port: u16) -> Vec { + let host = normalize_host(host); + self.cedar + .dns_endpoints() + .iter() + .enumerate() + .filter(|(_, authorized)| { + authorized.ports.contains(&port) + && openshell_core::host_pattern::HostSelector::new( + std::slice::from_ref(&authorized.host), + &[], + ) + .is_ok_and(|selector| selector.matches(&host)) + }) + .map(|(endpoint_index, authorized)| dns_record(endpoint_index, authorized)) + .collect() + } +} + +/// Builds the policy DNS record for one Cedar endpoint, in the shape of a +/// YAML endpoint: `host`, `ports`, `protocol: tcp` for native TCP, and the +/// permits' `destination_ip` ranges as `allowed_ips`, so policy DNS filters +/// answers as it does for that YAML endpoint. +fn dns_record(endpoint_index: usize, authorized: &AuthorizedNetworkEndpoint) -> MatchedEndpoint { + let endpoint = DnsRecord { + host: authorized.host.clone(), + ports: authorized.ports.clone(), + protocol: authorized + .transport + .protocol() + .map_or(EndpointProtocol::None, EndpointProtocol::from), + allowed_ips: authorized + .allowed_ips + .iter() + .map(ToString::to_string) + .collect(), + }; + MatchedEndpoint { + policy_name: CEDAR_POLICY_NAME.to_string(), + endpoint_index, + endpoint, + } +} + +/// The binary paths a Cedar policy tests in `context.binary_aliases` that +/// are symlinks in the sandbox, with the targets they resolve to. +/// +/// YAML adds the target of a symlinked binary path as another binary entry +/// of its policy, which the Rego rules match like any entry: exactly against +/// the process's binary and ancestors, or as a glob when the target contains +/// `*`. A request's aliases are the paths whose targets match it that way, +/// so `context.binary_aliases.contains(P)` holds exactly when that added +/// entry would match. +#[derive(Debug, Default, PartialEq)] +struct BinaryAliases(Vec<(String, AliasTarget)>); + +/// The resolved target of one policy binary path. +#[derive(Debug)] +enum AliasTarget { + /// A target without `*`, compared exactly. + Exact(String), + /// A target containing `*`, which the Rego rules match as a glob. + Glob(String, globset::GlobMatcher), + /// A target containing `*` that is not a valid glob, which fails Rego + /// evaluation. + InvalidGlob(String), +} + +impl AliasTarget { + /// The resolved path. + fn text(&self) -> &str { + match self { + Self::Exact(target) | Self::Glob(target, _) | Self::InvalidGlob(target) => target, + } + } +} + +/// Targets are equal when they resolved to the same path. +impl PartialEq for AliasTarget { + fn eq(&self, other: &Self) -> bool { + self.text() == other.text() + } +} + +impl AliasTarget { + fn new(target: String) -> Self { + if !target.contains('*') { + return Self::Exact(target); + } + crate::binary_resolution::compile_binary_glob(&target).map_or_else( + |_| Self::InvalidGlob(target.clone()), + |glob| Self::Glob(target.clone(), glob), + ) + } +} + +impl BinaryAliases { + /// Resolves `paths` in the root filesystem of `entrypoint_pid`, keeping + /// the symlinks; none when `entrypoint_pid` is 0. + fn resolve<'a>(paths: impl Iterator, entrypoint_pid: u32) -> Self { + Self( + paths + .filter_map(|path| { + let target = + crate::binary_resolution::resolve_policy_binary(path, entrypoint_pid)?; + Some((path.to_string(), AliasTarget::new(target))) + }) + .collect(), + ) + } + + /// Returns the policy binary paths whose targets match `binary_path` or + /// one of `ancestors`. + /// + /// # Errors + /// + /// Returns an error when a target is an invalid glob, as Rego evaluation + /// fails for one. Callers deny the request. + fn matching(&self, binary_path: &str, ancestors: &[String]) -> Result> { + let mut aliases = Vec::new(); + for (path, target) in &self.0 { + let matched = match target { + AliasTarget::Exact(target) => { + target == binary_path || ancestors.iter().any(|ancestor| ancestor == target) + } + AliasTarget::Glob(_, glob) => { + glob.is_match(binary_path) + || ancestors.iter().any(|ancestor| glob.is_match(ancestor)) + } + AliasTarget::InvalidGlob(target) => { + return Err(miette::miette!( + "policy binary {path:?} resolves to {target:?}, which is not a valid glob" + )); + } + }; + if matched { + aliases.push(path.clone()); + } + } + Ok(aliases) + } +} + +/// The binary glob patterns a Cedar policy and its provider rules test in +/// `context.binary_globs`, compiled with the YAML matcher. +/// +/// A YAML binary glob matches the process's binary or any ancestor with +/// `glob.match(pattern, ["/"], path)`. Cedar cannot apply a glob across the +/// `context.ancestors` set, so a request's `binary_globs` lists the patterns +/// that match it that way, and `context.binary_globs.contains(P)` holds +/// exactly when the YAML glob `P` would match. +#[derive(Debug, Default)] +struct BinaryGlobs(Vec<(String, Result)>); + +/// Sets are equal when they hold the same patterns. +impl PartialEq for BinaryGlobs { + fn eq(&self, other: &Self) -> bool { + self.0.len() == other.0.len() + && self + .0 + .iter() + .zip(&other.0) + .all(|((left, _), (right, _))| left == right) + } +} + +impl BinaryGlobs { + /// Compiles the patterns `cedar` tests, and those `token_grants` test. + /// + /// Analysis rejects a Cedar pattern that does not compile, and the + /// token-grant compiler fails closed on one that might not, so a failure + /// here stands in for a Rego evaluation failure and denies the request. + fn compile(cedar: &CedarEngine, token_grants: Option<&TokenGrantRules>) -> Self { + let patterns: std::collections::BTreeSet<&str> = cedar + .binary_glob_patterns() + .chain( + token_grants + .into_iter() + .flat_map(TokenGrantRules::binary_glob_patterns), + ) + .collect(); + Self( + patterns + .into_iter() + .map(|pattern| { + let glob = crate::binary_resolution::compile_binary_glob(pattern) + .map_err(|error| error.to_string()); + (pattern.to_string(), glob) + }) + .collect(), + ) + } + + /// Returns the patterns that match `binary_path` or one of `ancestors`. + /// + /// # Errors + /// + /// Returns an error when a pattern is an invalid glob, as Rego + /// evaluation fails for one. Callers deny the request. + fn matching(&self, binary_path: &str, ancestors: &[String]) -> Result> { + let mut globs = Vec::new(); + for (pattern, glob) in &self.0 { + let glob = glob.as_ref().map_err(|error| { + miette::miette!("policy binary glob {pattern:?} is not a valid glob: {error}") + })?; + if glob.is_match(binary_path) + || ancestors.iter().any(|ancestor| glob.is_match(ancestor)) + { + globs.push(pattern.clone()); + } + } + Ok(globs) + } +} + +/// One attached provider endpoint, used for its credential settings and to +/// narrow the addresses a connection carrying its credentials may reach. +#[derive(Debug, Clone)] +struct ProviderEndpoint { + /// Lowercased host or host glob; empty for a host-less `allowed_ips` + /// endpoint, which matches no connection. + host: String, + ports: Vec, + /// The endpoint's `allowed_ips`, empty for none, or why one is invalid. + allowed_ips: ProviderRanges, + /// The endpoint's credential settings, as the L7 config parser reads + /// them (see [`provider_config`]). + config: EndpointConfig, +} + +/// A provider endpoint's parsed `allowed_ips`, or why an entry is invalid. +pub(crate) type ProviderRanges = std::result::Result, String>; + +/// Parses provider `allowed_ips` entries, each a CIDR range or a bare +/// address, as the proxy parses YAML `allowed_ips`. +fn parse_provider_allowed_ips(entries: &[String]) -> ProviderRanges { + entries + .iter() + .map(|entry| { + entry + .parse::() + .or_else(|_| entry.parse::().map(IpNet::from)) + .map_err(|_| format!("invalid CIDR/IP in provider allowed_ips: {entry}")) + }) + .collect() +} + +impl ProviderEndpoint { + fn from_proto(endpoint: &openshell_core::proto::NetworkEndpoint) -> Self { + let ports = if endpoint.ports.is_empty() { + vec![endpoint.port] + } else { + endpoint.ports.clone() + }; + Self { + host: endpoint.host.to_ascii_lowercase(), + ports: ports + .into_iter() + .filter_map(|port| u16::try_from(port).ok()) + .filter(|port| *port != 0) + .collect(), + allowed_ips: parse_provider_allowed_ips(&endpoint.allowed_ips), + config: provider_config(endpoint), + } + } + + /// Matches like the Rego `endpoint_matches_request` rule, except that a + /// host-less endpoint matches nothing. + /// + /// YAML matches a host-less endpoint by its `allowed_ips`, which also + /// restrict the connection's addresses. Providers grant no access on a + /// Cedar sandbox, so such an endpoint would lend its credentials to every + /// host on its ports. + fn matches(&self, host: &str, port: u16) -> bool { + if !self.ports.contains(&port) || self.host.is_empty() { + return false; + } + if self.host.contains('*') { + openshell_core::host_pattern::host_matches(&self.host, host).unwrap_or(false) + } else { + self.host == host + } + } +} + +/// One `endpoint_settings` entry: an endpoint selector and its settings. +#[derive(Debug, Clone)] +struct EndpointSetting { + /// Lowercased host or host glob. + host: String, + ports: Vec, + /// Endpoint path glob; empty for every path. + path: String, + /// The settings the entry sets. + fields: SettingFields, +} + +/// The endpoint config settings an `endpoint_settings` entry can set; `None` +/// leaves a config's value alone. +/// +/// Settings the proto leaves at its default are not set, as the YAML +/// endpoint conversion omits them. +#[derive(Debug, Clone, Default)] +struct SettingFields { + tls: Option, + allow_encoded_slash: Option, + json_rpc_max_body_bytes: Option, + graphql_max_body_bytes: Option, + mcp_strict_tool_names: Option, + persisted_queries: Option, + graphql_persisted_queries: Option>, + mcp_versions: Option>, +} + +impl EndpointSetting { + fn from_proto(setting: &openshell_core::proto::NetworkEndpoint) -> Self { + let ports = if setting.ports.is_empty() { + vec![setting.port] + } else { + setting.ports.clone() + }; + // Only the settings the gateway accepts in `endpoint_settings`, set + // as the YAML endpoint conversion sets them. + let mcp = setting.mcp.as_ref(); + let fields = SettingFields { + tls: (setting.tls != 0).then(|| TlsSetting::from_proto(setting.tls)), + allow_encoded_slash: setting.allow_encoded_slash.then_some(true), + json_rpc_max_body_bytes: (setting.json_rpc_max_body_bytes > 0) + .then_some(u64::from(setting.json_rpc_max_body_bytes)), + graphql_max_body_bytes: (setting.graphql_max_body_bytes > 0) + .then_some(u64::from(setting.graphql_max_body_bytes)), + mcp_strict_tool_names: mcp.and_then(|mcp| mcp.strict_tool_names), + persisted_queries: (!setting.persisted_queries.is_empty()) + .then(|| setting.persisted_queries.clone()), + graphql_persisted_queries: (!setting.graphql_persisted_queries.is_empty()) + .then(|| graphql_persisted_queries(&setting.graphql_persisted_queries)), + // The YAML conversion emits MCP revisions only for an MCP + // endpoint; a setting carries no protocol. + mcp_versions: mcp.filter(|mcp| !mcp.versions.is_empty()).map(|mcp| { + let mut versions = mcp.versions.clone(); + openshell_core::mcp::canonicalize_mcp_versions(&mut versions); + versions + }), + }; + Self { + host: setting.host.to_ascii_lowercase(), + ports: ports + .into_iter() + .filter_map(|port| u16::try_from(port).ok()) + .filter(|port| *port != 0) + .collect(), + path: setting.path.clone(), + fields, + } + } + + /// Matches like the Rego `endpoint_matches_request` rule. + fn matches(&self, host: &str, port: u16) -> bool { + if !self.ports.contains(&port) || self.host.is_empty() { + return false; + } + if self.host.contains('*') { + openshell_core::host_pattern::host_matches(&self.host, host).unwrap_or(false) + } else { + self.host == host + } + } +} + +/// Converts proto `graphql_persisted_queries` into the typed registry. +fn graphql_persisted_queries( + queries: &HashMap, +) -> std::collections::BTreeMap { + queries + .iter() + .map(|(key, op)| { + ( + key.clone(), + GraphqlPersistedQuery { + operation_type: op.operation_type.clone(), + operation_name: op.operation_name.clone(), + fields: op.fields.clone(), + }, + ) + }) + .collect() +} + +/// Copies `settings` into `config`, later entries overriding earlier ones. +/// +/// Protocol options apply only where Cedar selects that protocol, as YAML +/// rejects or ignores them on other endpoints. A plain WebSocket endpoint +/// takes no GraphQL option: a persisted-query setting would turn on the +/// GraphQL message policy Cedar did not declare. +fn apply_settings( + config: &mut EndpointConfig, + settings: &[&EndpointSetting], + protocol: Option, +) { + let jsonrpc_family = matches!(protocol, Some(L7Protocol::JsonRpc | L7Protocol::Mcp)); + let mcp = protocol == Some(L7Protocol::Mcp); + let graphql = matches!( + protocol, + Some(L7Protocol::Graphql | L7Protocol::WebsocketGraphql) + ); + for setting in settings { + let fields = &setting.fields; + if let Some(tls) = fields.tls { + config.tls = tls; + } + if let Some(allow) = fields.allow_encoded_slash { + config.allow_encoded_slash = allow; + } + if jsonrpc_family && let Some(bytes) = fields.json_rpc_max_body_bytes { + config.json_rpc_max_body_bytes = bytes; + } + if mcp { + if let Some(strict) = fields.mcp_strict_tool_names { + config.mcp_strict_tool_names = Some(strict); + } + if let Some(versions) = &fields.mcp_versions { + config.mcp_versions.clone_from(versions); + } + } + if graphql { + if let Some(bytes) = fields.graphql_max_body_bytes { + config.graphql_max_body_bytes = bytes; + } + if let Some(mode) = &fields.persisted_queries { + config.persisted_queries.clone_from(mode); + } + if let Some(queries) = &fields.graphql_persisted_queries { + config.graphql_persisted_queries.clone_from(queries); + } + } + } +} + +/// Builds a provider endpoint's config: its credential settings only. +/// +/// Settings that describe access, per-request rules, or inspection mode +/// (`allowed_ips`, `protocol`, `enforcement`, `access`, rules, persisted +/// queries, MCP options, and MCP identity) are left unset: on a Cedar +/// sandbox, Cedar's `HttpRequest` policies are the per-request rules and +/// decide inspection, and the proxy checks addresses with the connection's +/// [`CedarDestination`], which carries provider `allowed_ips` separately so +/// they can only narrow what Cedar admits. +fn provider_config(endpoint: &openshell_core::proto::NetworkEndpoint) -> EndpointConfig { + let ports = if endpoint.ports.is_empty() { + (endpoint.port > 0) + .then_some(endpoint.port) + .into_iter() + .collect() + } else { + endpoint.ports.clone() + }; + EndpointConfig { + host: endpoint.host.clone(), + ports: ports + .into_iter() + .filter_map(|port| u16::try_from(port).ok()) + .collect(), + path: endpoint.path.clone(), + tls: TlsSetting::from_proto(endpoint.tls), + 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, + provider_credentialed: endpoint.provider_credentialed, + credential_signing: endpoint.credential_signing.clone(), + signing_service: endpoint.signing_service.clone(), + signing_region: endpoint.signing_region.clone(), + graphql_max_body_bytes: u64::from(endpoint.graphql_max_body_bytes), + json_rpc_max_body_bytes: u64::from(endpoint.json_rpc_max_body_bytes), + ..EndpointConfig::default() + } +} + +/// Sets how the relay inspects requests with `config`. +/// +/// MCP endpoints also carry the MCP revision list the relay requires, set to +/// the same default a YAML MCP endpoint without `mcp.versions` gets. An +/// endpoint setting with `mcp.versions` replaces it. A `websocket-graphql` +/// endpoint is a `websocket` endpoint with `websocket_graphql_policy` set, +/// which a YAML endpoint gets from its GraphQL operation rules. +fn apply_inspection(config: &mut EndpointConfig, inspection: L7Endpoint) { + config.protocol = EndpointProtocol::from(inspection.protocol.config_label()); + config.websocket_graphql_policy = inspection.protocol.websocket_graphql_messages(); + config.enforcement = EnforcementSetting::from(inspection.enforcement.as_str()); + if inspection.protocol == L7Protocol::Mcp { + config.mcp_versions = vec![ + openshell_policy_schema::DEFAULT_MCP_PROTOCOL_VERSION + .as_str() + .to_string(), + ]; + } +} + +/// The Cedar text, provider rules, and endpoint settings a [`LoadedPolicy`] +/// was built from. +/// +/// Compared on reload so an unchanged policy keeps the current generation. +#[derive(Debug, Clone, PartialEq)] +struct PolicyInputs { + source: String, + /// Provider rules sorted by key; protobuf maps have no stable order. + provider_rules: Vec<(String, NetworkPolicyRule)>, + /// Endpoint settings, in order: a later entry overrides an earlier one. + endpoint_settings: Vec, +} + +impl PolicyInputs { + /// The provider rules, keyed as the policy delivered them. + fn provider_rule_map(&self) -> HashMap { + self.provider_rules.iter().cloned().collect() + } + + fn from_proto(policy: &ProtoSandboxPolicy) -> Self { + let mut provider_rules: Vec<_> = policy + .provider_credential_rules + .iter() + .map(|(key, rule)| (key.clone(), rule.clone())) + .collect(); + provider_rules.sort_by(|left, right| left.0.cmp(&right.0)); + Self { + source: policy.cedar_policy_source.clone(), + provider_rules, + endpoint_settings: policy.endpoint_settings.clone(), + } + } +} + +/// Builds a policy holding only Cedar text, for callers without provider rules. +fn policy_from_source(policy_src: &str) -> ProtoSandboxPolicy { + ProtoSandboxPolicy { + cedar_policy_source: policy_src.to_string(), + ..Default::default() + } +} + +/// Cedar-backed, fully authoritative network policy evaluator. +/// +/// No hidden fallback to OPA: a sandbox either uses this engine for every +/// network decision, or [`crate::opa::OpaEngine`] for every network +/// decision, chosen once at policy load. +#[derive(Debug)] +pub struct CedarOnlyEngine { + /// Shared with every [`CedarL7TunnelEngine`] handed out by + /// [`Self::l7_handle`]. + engine: Arc>, + /// What the active policy was built from, so a reload with unchanged + /// inputs keeps the generation. Advancing it would close every inspected + /// tunnel on each policy-poll reconciliation, even one unrelated to this + /// sandbox's policy (for example a middleware registry change). + inputs: RwLock, + generation: Arc, + generation_tx: watch::Sender, + /// Set while a fail-closed quarantine is active: every connection is + /// denied with this reason, and no name is eligible for policy DNS. + /// Changed only while the engine write lock is held. + fail_closed_reason: RwLock>, + /// The policy's `network_middlewares` and the connected services, with + /// a generation independent of the policy generation. + middleware: MiddlewareState, +} + +impl CedarOnlyEngine { + /// Builds the engine from a policy's Cedar text, provider rules, + /// endpoint settings, and network middlewares. + /// + /// The engine starts with no middleware services connected; see + /// [`crate::policy_engine::PolicyEngine::replace_middleware_registry`]. + /// + /// The policy gets the validation every gateway-delivered policy gets + /// (see [`crate::policy_validation::validate_sandbox_policy`]), as + /// `OpaEngine::from_proto` validates a YAML policy, and the engine is + /// built from the canonical form the validator returns. + /// + /// # Errors + /// + /// Returns an error if the policy fails validation, or its Cedar text + /// fails to parse, fails schema validation, or uses a policy shape Cedar + /// cannot enforce exactly. + pub fn from_proto(policy: &ProtoSandboxPolicy) -> Result { + let policy = crate::policy_validation::validate_sandbox_policy(policy)?; + let loaded = LoadedPolicy::from_proto(&policy, 0)?; + let (generation_tx, _) = watch::channel(0); + Ok(Self { + engine: Arc::new(RwLock::new(loaded)), + inputs: RwLock::new(PolicyInputs::from_proto(&policy)), + generation: Arc::new(AtomicU64::new(0)), + generation_tx, + fail_closed_reason: RwLock::new(None), + middleware: MiddlewareState::new(MiddlewareConfigs::from_proto(&policy)), + }) + } + + /// Builds the engine from Cedar text alone, with no provider rules. + /// + /// # Errors + /// + /// Returns an error if `policy_src` fails to load (see [`Self::from_proto`]). + pub fn from_policy_str(policy_src: &str) -> Result { + Self::from_proto(&policy_from_source(policy_src)) + } + + /// Returns the active policy generation, advanced by each committed reload. + #[must_use] + pub fn current_generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + /// Returns the sandbox's middleware configs, services, and generation. + pub(crate) fn middleware(&self) -> &MiddlewareState { + &self.middleware + } + + /// Builds the L7 decision handle for a tunnel pinned at `captured_generation`. + pub(crate) fn l7_handle(&self, captured_generation: u64) -> CedarL7TunnelEngine { + CedarL7TunnelEngine { + engine: Arc::clone(&self.engine), + generation: Arc::clone(&self.generation), + captured_generation, + } + } + + /// Pins `expected_generation` for a long-lived operation. + /// + /// # Errors + /// + /// Returns an error if `expected_generation` is already stale. + pub fn generation_guard(&self, expected_generation: u64) -> Result { + generation_guard_for( + expected_generation, + self.current_generation(), + &self.generation, + &self.generation_tx, + ) + } + + /// Runs `operation` only while `expected_generation` is current. + /// + /// Holds the engine read lock across the check and `operation`, so no + /// reload can commit between them. `operation` must not block. + /// + /// # Errors + /// + /// Returns an error if the engine lock is poisoned. + pub fn with_current_generation( + &self, + expected_generation: u64, + operation: impl FnOnce(u64) -> T, + ) -> Result> { + let _engine = self.read_engine()?; + let current_generation = self.current_generation(); + if current_generation != expected_generation { + return Ok(None); + } + Ok(Some(operation(current_generation))) + } + + fn read_engine(&self) -> Result> { + self.engine + .read() + .map_err(|_| miette::miette!("Cedar engine lock poisoned")) + } + + /// Test-only: the analysis warnings the active policy loaded with. + /// + /// # Panics + /// + /// Panics if the engine lock is poisoned. + #[cfg(test)] + fn warnings_for_tests(&self) -> Vec { + self.read_engine() + .expect("engine lock") + .cedar + .warnings() + .to_vec() + } + + /// Rebuilds the engine from a freshly reloaded policy and advances the + /// generation counter. Call this directly alongside wherever + /// `OpaEngine::reload*` would be called for a YAML-sourced sandbox. + /// + /// A no-op (generation unchanged) when `policy_src` is byte-identical + /// to what's already loaded — see the `source` field doc. + /// + /// # Errors + /// + /// Returns an error if `policy_src` fails to load (see + /// [`Self::from_policy_str`]). On error, the + /// previous policy and generation stay active (last-known-good, + /// matching `OpaEngine`'s reload failure behavior). + pub fn reload_from_policy_str(&self, policy_src: &str) -> Result<()> { + let staged = self.stage(&policy_from_source(policy_src), 0)?; + self.commit(staged) + } + + /// Validates and parses `policy` without activating it. + /// + /// Lets a caller validate the Cedar policy before committing any other + /// engine's reload, so a rejected Cedar policy leaves every engine on + /// the previous revision. Pass the result to [`Self::commit`]. + /// + /// The policy gets the same validation as in [`Self::from_proto`], and + /// the staged policy is built from the canonical form the validator + /// returns. Its binary paths are resolved in the root filesystem of + /// `entrypoint_pid`, as `OpaEngine::reload_from_proto_with_pid` resolves + /// a YAML policy's. A pid of 0 keeps the one the active policy was + /// resolved with, if any (see [`Self::resolve_binary_symlinks`]). + /// + /// # Errors + /// + /// Returns an error if `policy` fails validation or fails to load (see + /// [`Self::from_proto`]), or the engine lock is poisoned. + pub fn stage( + &self, + policy: &ProtoSandboxPolicy, + entrypoint_pid: u32, + ) -> Result { + let policy = crate::policy_validation::validate_sandbox_policy(policy)?; + let entrypoint_pid = match entrypoint_pid { + 0 => self.read_engine()?.entrypoint_pid, + pid => pid, + }; + let middleware = MiddlewareConfigs::from_proto(&policy); + let inputs = PolicyInputs::from_proto(&policy); + let unchanged = *self.read_inputs()? == inputs; + if unchanged { + return Ok(StagedCedarPolicy { + policy: None, + entrypoint_pid, + middleware, + }); + } + let loaded = LoadedPolicy::from_proto(&policy, entrypoint_pid)?; + Ok(StagedCedarPolicy { + policy: Some((loaded, inputs)), + entrypoint_pid, + middleware, + }) + } + + /// Activates a policy returned by [`Self::stage`], with its middleware. + /// + /// Equivalent to [`Self::commit_configuration`] with no new middleware + /// services and no credentials to publish. + /// + /// # Errors + /// + /// See [`Self::commit_configuration`]. + pub fn commit(&self, staged: StagedCedarPolicy) -> Result<()> { + self.commit_configuration(staged, None, || {}).map(drop) + } + + /// Activates a staged policy, its middleware, and the caller's + /// credentials as one runtime configuration, or changes nothing. + /// + /// Everything that can fail runs first, before any lock is taken: the + /// staged policy (or, when the inputs were unchanged, a copy of the + /// active one) is resolved for the entrypoint process (see + /// [`Self::prepare_policy`]). Then, under the middleware write lock, the + /// engine's own locks are acquired; the middleware generation advances, + /// so pinned middleware readers are stale; the Cedar policy is swapped + /// in and its generation advances when the policy changed, which closes + /// inspected tunnels; `commit_credentials` runs; and only then do the + /// new middleware configs and, when given, `registry` become visible. + /// Middleware readers stay blocked throughout. An unchanged Cedar policy + /// keeps its generation, so a middleware-only change does not close + /// inspected tunnels; they keep the middleware they started with. + /// + /// `commit_credentials` must be infallible and must not call back into + /// this engine. + /// + /// Returns a guard on the middleware generation this commit published. + /// + /// # Errors + /// + /// Returns an error if the provider rules fail to compile for the + /// entrypoint process, or a lock is poisoned. In either case the policy, + /// the middleware, and the credentials all stay on the previous revision, + /// no guard becomes stale, and `commit_credentials` is not called. + pub fn commit_configuration( + &self, + staged: StagedCedarPolicy, + registry: Option, + commit_credentials: impl FnOnce(), + ) -> Result { + let StagedCedarPolicy { + policy, + entrypoint_pid, + middleware, + } = staged; + let prepared = self.prepare_policy(policy, entrypoint_pid)?; + self.middleware.commit( + middleware, + registry, + || self.write_locks(), + |locks| { + self.publish_policy(locks, prepared); + commit_credentials(); + }, + ) + } + + /// Resolves the policy a commit will activate, for the entrypoint process. + /// + /// A policy staged before the entrypoint process was known is resolved + /// for it here. When the staged inputs matched the active policy's, the + /// active policy is resolved again once the entrypoint process is known, + /// as a YAML reload rebuilds its engine and resolves every binary path + /// again: a symlink retargeted since the last resolution must not keep + /// its old alias. Takes only read locks, and none across the resolution. + /// + /// # Errors + /// + /// Returns an error if the provider rules fail to compile for the + /// entrypoint process, or the engine lock is poisoned. + fn prepare_policy( + &self, + policy: Option<(LoadedPolicy, PolicyInputs)>, + entrypoint_pid: u32, + ) -> Result { + let entrypoint_pid = match entrypoint_pid { + 0 => self.read_engine()?.entrypoint_pid, + pid => pid, + }; + match policy { + Some((loaded, inputs)) => { + let loaded = if loaded.entrypoint_pid == entrypoint_pid { + loaded + } else { + loaded.resolved_for(&inputs.provider_rule_map(), entrypoint_pid)? + }; + Ok(PreparedPolicy::Replaced(loaded, inputs)) + } + None if entrypoint_pid != 0 => { + // The read lock is held across the resolution, which reads + // the entrypoint's root filesystem; the swap compares the + // result against the active policy again under the write lock. + let active = self.read_engine()?; + let rules = self.read_inputs()?.provider_rule_map(); + let resolved = active.resolved_for(&rules, entrypoint_pid)?; + Ok(PreparedPolicy::Resolved(resolved)) + } + None => Ok(PreparedPolicy::Unchanged), + } + } + + /// Acquires every lock a publish writes, in the engine's lock order. + /// + /// # Errors + /// + /// Returns an error if any of them is poisoned. + fn write_locks(&self) -> Result> { + Ok(WriteLocks { + engine: self.write_engine()?, + inputs: self + .inputs + .write() + .map_err(|_| miette::miette!("Cedar engine lock poisoned"))?, + fail_closed_reason: self.write_fail_closed_reason()?, + }) + } + + /// Swaps `prepared` into the active policy and ends any quarantine. + /// + /// The generation advances when the policy changed, when a resolved + /// alias or the entrypoint process changed, or when a quarantine ended. + /// Infallible: every lock is already held. + fn publish_policy(&self, locks: WriteLocks<'_>, prepared: PreparedPolicy) { + let WriteLocks { + mut engine, + mut inputs, + mut fail_closed_reason, + } = locks; + let was_fail_closed = fail_closed_reason.take().is_some(); + let changed = match prepared { + PreparedPolicy::Unchanged => false, + PreparedPolicy::Resolved(resolved) => { + let changed = engine.resolution_differs(&resolved); + *engine = resolved; + changed + } + PreparedPolicy::Replaced(loaded, new_inputs) => { + *engine = loaded; + *inputs = new_inputs; + true + } + }; + if changed || was_fail_closed { + self.advance_generation(); + } + } + + /// Resolves the active policy's binary paths in the root filesystem of + /// the entrypoint process, once it is known. + /// + /// The Cedar counterpart of rebuilding the OPA engine with + /// `OpaEngine::reload_from_proto_with_pid` when the entrypoint starts: + /// each path policies test in `context.binary_aliases`, and each + /// provider rule binary used to select token-grant owners, is resolved + /// as YAML resolves its binary paths. A no-op for 0 or for the pid the + /// policy was already resolved with; otherwise advances the generation. + /// + /// # Errors + /// + /// Returns an error if the engine lock is poisoned, or the provider rules + /// fail to load for the entrypoint process; the active policy is + /// unchanged then. + pub fn resolve_binary_symlinks(&self, entrypoint_pid: u32) -> Result<()> { + let mut guard = self.write_engine()?; + if entrypoint_pid == 0 || guard.entrypoint_pid == entrypoint_pid { + return Ok(()); + } + let rules = self.read_inputs()?.provider_rule_map(); + let resolved = guard.resolved_for(&rules, entrypoint_pid)?; + if guard.resolution_differs(&resolved) { + *guard = resolved; + self.advance_generation(); + } + Ok(()) + } + + fn read_inputs(&self) -> Result> { + self.inputs + .read() + .map_err(|_| miette::miette!("Cedar engine lock poisoned")) + } + + /// Publishes a deny-all quarantine generation without activating any + /// part of a rejected candidate policy. + /// + /// The active Cedar policy stays loaded for a later + /// [`Self::exit_fail_closed`] or valid reload, but every new connection + /// is denied with `reason`. A quarantine is a configuration change: the + /// Cedar generation advances, which closes every pinned tunnel, and so + /// does the middleware generation, which stales the configuration guard + /// the policy poll loop watches (see + /// [`crate::policy_engine::PolicyEngine::configuration_generation_guard`]), + /// as `OpaEngine::enter_fail_closed` stales both with its one counter. + /// + /// Returns the new Cedar generation. + /// + /// # Errors + /// + /// Returns an error if a lock is poisoned. + pub fn enter_fail_closed(&self, reason: impl Into) -> Result { + let reason = reason.into(); + // Lock order: middleware, then engine, as a commit takes them. + self.middleware.change_configuration(|| { + let _guard = self.write_engine()?; + *self.write_fail_closed_reason()? = Some(reason); + Ok((self.advance_generation(), true)) + }) + } + + /// Reactivates the active policy after a quarantine. + /// + /// Returns the current Cedar generation, advanced only if a quarantine + /// ended; the middleware generation advances with it, so the + /// configuration guard pinned during the quarantine becomes stale. + /// + /// # Errors + /// + /// Returns an error if a lock is poisoned. + pub fn exit_fail_closed(&self) -> Result { + self.middleware.change_configuration(|| { + let _guard = self.write_engine()?; + if self.write_fail_closed_reason()?.take().is_some() { + Ok((self.advance_generation(), true)) + } else { + Ok((self.current_generation(), false)) + } + }) + } + + /// Returns the quarantine reason while a fail-closed quarantine is active. + #[must_use] + pub fn fail_closed_reason(&self) -> Option { + self.fail_closed_reason + .read() + .ok() + .and_then(|reason| reason.clone()) + } + + fn write_engine(&self) -> Result> { + self.engine + .write() + .map_err(|_| miette::miette!("Cedar engine lock poisoned")) + } + + fn write_fail_closed_reason(&self) -> Result>> { + self.fail_closed_reason + .write() + .map_err(|_| miette::miette!("Cedar fail-closed state lock poisoned")) + } + + fn read_fail_closed_reason(&self) -> Result> { + Ok(self + .fail_closed_reason + .read() + .map_err(|_| miette::miette!("Cedar fail-closed state lock poisoned"))? + .clone()) + } + + /// Advances the generation. Callers hold the engine write lock, so a + /// reader that observes the new generation under the read lock also sees + /// the state that produced it. + fn advance_generation(&self) -> u64 { + let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + self.generation_tx.send_replace(generation); + generation + } + + /// Returns the Landlock path grants the loaded policy authorizes. + /// + /// See [`openshell_policy_cedar::CedarEngine::filesystem_grants`]. + /// + /// # Errors + /// + /// Returns an error if the engine lock is poisoned. + pub fn filesystem_grants(&self) -> Result { + Ok(self.read_engine()?.cedar.filesystem_grants().clone()) + } + + /// See [`openshell_policy_cedar::CedarEngine::has_network_permits`]. + /// + /// # Errors + /// + /// Returns an error if the engine lock is poisoned. + pub fn has_network_permits(&self) -> Result { + Ok(self.read_engine()?.cedar.has_network_permits()) + } +} + +/// A validated Cedar policy waiting for [`CedarOnlyEngine::commit`]. +#[derive(Debug)] +pub struct StagedCedarPolicy { + /// `None` when the staged inputs matched the active policy's. + policy: Option<(LoadedPolicy, PolicyInputs)>, + /// The entrypoint process the policy is to be resolved for; 0 if unknown. + entrypoint_pid: u32, + /// The candidate's `network_middlewares`, activated with the policy. + middleware: MiddlewareConfigs, +} + +impl StagedCedarPolicy { + /// Test-only: makes resolving this policy for the entrypoint process + /// fail at commit, as provider rules that do not compile for it would. + /// + /// # Panics + /// + /// Panics if the staged inputs matched the active policy's. + #[cfg(test)] + fn fail_binary_resolution_for_tests(&mut self) { + let (loaded, _) = self.policy.as_mut().expect("a changed policy is staged"); + loaded.fail_resolution = true; + } +} + +/// The policy a commit swaps in, resolved before any lock is taken. +#[derive(Debug)] +enum PreparedPolicy { + /// The staged inputs matched the active policy's, and the entrypoint + /// process is not known yet. + Unchanged, + /// The active policy, resolved again for the entrypoint process. + Resolved(LoadedPolicy), + /// A changed policy and the inputs it was built from. + Replaced(LoadedPolicy, PolicyInputs), +} + +/// Every lock a publish writes, acquired under the middleware write lock. +struct WriteLocks<'a> { + engine: std::sync::RwLockWriteGuard<'a, LoadedPolicy>, + inputs: std::sync::RwLockWriteGuard<'a, PolicyInputs>, + fail_closed_reason: std::sync::RwLockWriteGuard<'a, Option>, +} + +impl CedarOnlyEngine { + /// Authorizes one egress request against the active Cedar policy. + /// + /// # Errors + /// + /// Returns an error if the engine lock is poisoned, or Cedar fails to + /// evaluate the request. Callers deny the connection on error. + pub fn authorize_egress(&self, input: &NetworkInput) -> Result { + let mut request = network_request_from_input(input); + let guard = self.read_engine()?; + let generation = self.current_generation(); + if let Some(reason) = self.read_fail_closed_reason()? { + return Ok(EgressAuthorization { + action: NetworkAction::Deny { reason }, + endpoint_configs: Vec::new(), + matched_endpoints: Vec::new(), + exact_declared_endpoint_host: false, + generation, + cedar_destination: None, + }); + } + request.binary_aliases = guard + .binary_aliases + .matching(&request.binary_path, &request.ancestors)?; + request.binary_globs = guard + .binary_globs + .matching(&request.binary_path, &request.ancestors)?; + // Decided before the host resolves: an allow means Cedar allows some + // address, and `cedar_destination` checks each resolved one. + let evaluation = guard + .cedar + .authorize_network(&request) + .map_err(|e| miette::miette!("{e}"))?; + + let action = match evaluation.decision.clone() { + Decision::Allow { matched_policies } => NetworkAction::Allow { + matched_policy: matched_policies.into_iter().next(), + }, + Decision::Deny { matched_policies } => NetworkAction::Deny { + reason: if matched_policies.is_empty() { + "no Cedar policy permits this endpoint/binary".to_string() + } else { + format!("denied by Cedar policy (forbid matched: {matched_policies:?})") + }, + }, + }; + + let allowed = matches!(action, NetworkAction::Allow { .. }); + // Without these, an allowed CONNECT is relayed without inspection + // and without provider credential settings. + let endpoint_configs = if allowed { + guard.endpoint_configs(&request.host, request.port) + } else { + Vec::new() + }; + // Matches the YAML path: an allowed connection to a host a matching + // policy names exactly (not through a glob) may resolve to private + // addresses. For Cedar, that is an allowing permit that names this + // `host:port`. + let exact_declared_endpoint_host = allowed && evaluation.names_endpoint; + // Correlates the decision with the policy DNS mapping a transparent + // connection dialed; see the module docs. + let matched_endpoints = if allowed { + guard.matched_endpoints(&request.host, request.port) + } else { + Vec::new() + }; + + let cedar_destination = allowed.then(|| CedarDestination { + engine: Arc::clone(&guard.cedar), + provider_ranges: guard.provider_ranges(&request.host, request.port), + request, + evaluation, + }); + + Ok(EgressAuthorization { + action, + endpoint_configs, + matched_endpoints, + exact_declared_endpoint_host, + generation, + cedar_destination, + }) + } + + /// Returns the endpoint settings the credential guard checks for `host:port`. + /// + /// The same configs [`Self::authorize_egress`] returns for an allowed + /// connection, including every path-scoped provider endpoint. + /// + /// # Errors + /// + /// Returns an error if the engine lock is poisoned. + pub fn credential_guards(&self, host: &str, port: u16) -> Result> { + Ok(self.read_engine()?.endpoint_configs(host, port)) + } + + /// Returns the endpoints eligible for policy-gated DNS resolution. + /// + /// # Errors + /// + /// Returns an error if the engine lock is poisoned. + pub fn policy_dns_eligibility_snapshot(&self) -> Result { + let guard = self.read_engine()?; + let generation = self.current_generation(); + if self.read_fail_closed_reason()?.is_some() { + return Ok(PolicyDnsEligibilitySnapshot { + endpoints: Vec::new(), + generation, + fail_closed: true, + }); + } + Ok(PolicyDnsEligibilitySnapshot { + endpoints: guard.dns_records(), + generation, + fail_closed: false, + }) + } +} + +/// How the resolved addresses of one allowed Cedar connection are checked. +/// +/// Carries the connection's decision made before its host resolved, and the +/// policy that made it, so each resolved address is evaluated against the +/// same policy generation. The admission rules that use it live in +/// `proxy::destination`. +#[derive(Clone)] +pub struct CedarDestination { + engine: Arc, + request: NetworkConnectRequest, + evaluation: NetworkEvaluation, + /// The `allowed_ips` of the matching provider endpoints that have them. + provider_ranges: Vec, +} + +impl CedarDestination { + /// The `allowed_ips` of each matching provider endpoint that has them. + /// + /// Each must admit every address, but they never admit an address + /// Cedar's own rules reject: providers grant no access. + pub(crate) fn provider_ranges(&self) -> &[ProviderRanges] { + &self.provider_ranges + } + + /// The `destination_ip` ranges of each allowing permit that has them. + pub(crate) fn address_conditions(&self) -> &[Vec] { + &self.evaluation.address_conditions + } + + /// Whether an allowing permit has no `destination_ip` condition. + pub(crate) fn unconstrained_permit(&self) -> bool { + self.evaluation.unconstrained_permit + } + + /// Whether an allowing permit names the connection's host and port + /// exactly. + pub(crate) fn names_endpoint(&self) -> bool { + self.evaluation.names_endpoint + } + + /// Returns whether Cedar allows the connection to resolved address `ip`, + /// with every policy: permits with their `destination_ip` ranges, and + /// forbids with their address conditions, which were false in the + /// CONNECT-time decision made with the address absent. + /// + /// # Errors + /// + /// Returns the evaluation error; callers reject the address. + pub(crate) fn allows(&self, ip: IpAddr) -> std::result::Result { + let request = NetworkConnectRequest { + destination_ip: Some(ip), + ..self.request.clone() + }; + self.engine + .evaluate_network(&request) + .map(|decision| decision.is_allow()) + .map_err(|e| e.to_string()) + } +} + +impl std::fmt::Debug for CedarDestination { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CedarDestination") + .field("request", &self.request) + .field("evaluation", &self.evaluation) + .field("provider_ranges", &self.provider_ranges) + .finish_non_exhaustive() + } +} + +impl PartialEq for CedarDestination { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.engine, &other.engine) + && self.request == other.request + && self.evaluation == other.evaluation + && self.provider_ranges == other.provider_ranges + } +} + +impl Eq for CedarDestination {} + +/// Per-tunnel L7 decision handle for a Cedar-sourced sandbox. +/// +/// Unlike a YAML tunnel's Rego evaluator, this needs no actual +/// per-tunnel engine clone — `Authorizer`/`PolicySet` are immutable, so +/// concurrent evaluation is just concurrent `RwLock::read()` calls, same as +/// [`CedarOnlyEngine::authorize_egress`]. Only `captured_generation` is +/// per-tunnel state. +#[derive(Debug)] +pub(crate) struct CedarL7TunnelEngine { + engine: Arc>, + generation: Arc, + captured_generation: u64, +} + +impl CedarL7TunnelEngine { + /// Evaluates one L7 request and returns `(allowed, deny_reason)`. + /// + /// # Errors + /// + /// Returns an error if the tunnel's generation is stale, the engine lock + /// is poisoned, or Cedar fails to evaluate the request. + pub(crate) fn evaluate_request( + &self, + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + ) -> Result<(bool, String)> { + let guard = self.read_pinned()?; + Self::decide(&guard, ctx, request, StagedAudit::Log) + } + + /// Returns the token-grant owners admitted for one request. + /// + /// Admits owner O when Cedar allows the request and a provider endpoint + /// stamped with O admits it under that endpoint's own rules, as a YAML + /// sandbox admits O. The provider rules are compiled to Cedar and + /// evaluated with the request's own Cedar evaluations; they only narrow + /// which owners qualify and never allow a request Cedar denies. A denial + /// on a Cedar audit path admits no owner, matching a YAML audit denial. + /// + /// Two requests that Cedar sees differently from the Rego rules admit + /// no owner: a JSON-RPC request whose method is not uppercase, which the + /// Rego rules compare case-sensitively, and a hash-only GraphQL persisted + /// query, which the Rego rules resolve through the provider endpoint's + /// registry rather than the endpoint settings. + /// + /// # Errors + /// + /// Returns an error if the tunnel's generation is stale, the engine lock + /// is poisoned, or an evaluation fails. Callers inject no token on error. + pub(crate) fn admitted_token_grant_owners( + &self, + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + ) -> Result> { + let none = std::collections::HashSet::new; + let guard = self.read_pinned()?; + let Some(grants) = &guard.token_grants else { + return Ok(none()); + }; + let Prepared::Evaluate(requests) = Self::prepare(&guard, ctx, request)? else { + return Ok(none()); + }; + // `evaluate_request` already logged any staged audit decision for + // this request. + for l7_request in &requests { + if !Self::evaluate_one(&guard.cedar, ctx, request, l7_request, StagedAudit::Skip)?.0 { + return Ok(none()); + } + } + let hash_only = request + .graphql + .as_ref() + .is_some_and(|graphql| graphql.operations.iter().any(needs_graphql_registry)); + let lowercase_jsonrpc = + request.jsonrpc.is_some() && request.action != request.action.to_ascii_uppercase(); + if hash_only || lowercase_jsonrpc { + return Ok(none()); + } + match grants.admitted_owners(&requests) { + Ok(owners) => Ok(owners.into_iter().collect()), + // Too many query combinations to evaluate admits no owner. + Err(CedarEngineError::TooManyQueryCombinations { .. }) => Ok(none()), + Err(error) => Err(miette::miette!("{error}")), + } + } + + /// Takes the engine read lock and checks the tunnel's pinned generation. + /// + /// Compared under the read lock: a reload advances the generation under + /// the write lock, so a request is judged by the policy generation the + /// tunnel was pinned to, never a newer one. + fn read_pinned(&self) -> Result> { + let guard = self + .engine + .read() + .map_err(|_| miette::miette!("Cedar engine lock poisoned"))?; + let current_generation = self.generation.load(Ordering::Acquire); + if current_generation != self.captured_generation { + return Err(miette::miette!( + "L7 tunnel policy generation is stale [captured_generation:{} current_generation:{current_generation}]", + self.captured_generation, + )); + } + Ok(guard) + } + + /// Decides one L7 request and returns `(allowed, deny_reason)`. + /// + /// Same as the YAML path: a GraphQL request is allowed only when every + /// operation is allowed. + fn decide( + policy: &LoadedPolicy, + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + staged_audit: StagedAudit, + ) -> Result<(bool, String)> { + let requests = match Self::prepare(policy, ctx, request)? { + Prepared::Denied(reason) => return Ok((false, reason)), + Prepared::Evaluate(requests) => requests, + }; + for l7_request in &requests { + let decision = + Self::evaluate_one(&policy.cedar, ctx, request, l7_request, staged_audit)?; + if !decision.0 { + return Ok(decision); + } + } + Ok(Self::decision(true)) + } + + /// Builds the Cedar evaluations of one L7 request, or the reason it is + /// denied before evaluation. + fn prepare( + policy: &LoadedPolicy, + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + ) -> Result { + let mut base = HttpRequestInput { + user: PLACEHOLDER_IDENTITY.to_string(), + group: PLACEHOLDER_IDENTITY.to_string(), + binary_path: ctx.binary_path.clone(), + ancestors: ctx.ancestors.clone(), + binary_aliases: policy + .binary_aliases + .matching(&ctx.binary_path, &ctx.ancestors)?, + binary_globs: policy + .binary_globs + .matching(&ctx.binary_path, &ctx.ancestors)?, + // The relay parsed the request as GraphQL: an operation on a + // GraphQL path or a GraphQL-over-WebSocket client message. As + // the Rego `is_object(request.graphql)`, this makes the routed + // path authoritative for it. + graphql_request: request.graphql.is_some(), + host: ctx.host.clone(), + port: ctx.port, + // YAML matches methods case-insensitively, and the request parser + // keeps the client's case, so normalize before Cedar compares. + method: request.action.to_ascii_uppercase(), + path: request.target.clone(), + // Decoded by the request parser, as the Rego input receives them. + query: request + .query_params + .iter() + .map(|(key, values)| (key.clone(), values.clone())) + .collect(), + ..HttpRequestInput::default() + }; + // The relay selects the endpoint config, and with it the protocol and + // enforcement, by the most specific matching path, and denies a + // request no inspected path matches before evaluating it. A + // WebSocket message carries its upgrade path, so it is routed as its + // upgrade was. + match policy.route_request(&ctx.host, ctx.port, &request.target) { + RequestRoute::Unmatched => return Ok(Prepared::Denied(UNMATCHED_PATH.to_string())), + RequestRoute::Routed { endpoint_path } => base.endpoint_path = endpoint_path, + RequestRoute::Uninspected => {} + } + + if let Some(info) = &request.jsonrpc { + // Same as the YAML path: a request that failed inspection is + // never matched against policy. + if info.error.is_some() { + return Ok(Prepared::Denied( + "JSON-RPC request could not be inspected".to_string(), + )); + } + // The relay splits batches into single calls before policy + // evaluation; a batch seen here carries no single method. + let call = if info.is_batch { + None + } else { + info.calls.first() + }; + // Same as the YAML path: calls and response frames are only ever + // allowed in a POST, so a body carried by any other method is + // denied before Cedar sees it. An MCP receive stream (a GET with + // no body) is unaffected. + if (!info.calls.is_empty() || info.has_response) + && !request.action.eq_ignore_ascii_case("POST") + { + return Ok(Prepared::Denied( + "JSON-RPC calls and responses must be sent with POST".to_string(), + )); + } + if let Some(call) = call { + // `method` stays empty for a call so policies match on + // `jsonrpc_method` instead, the schema's not-applicable + // convention. + base.method = String::new(); + base.jsonrpc_method.clone_from(&call.method); + base.mcp_tool = call.tool.clone().unwrap_or_default(); + base.mcp_method_class = match call.mcp_classification { + Some(crate::l7::jsonrpc::McpMethodClassification::Available) => "available", + Some(crate::l7::jsonrpc::McpMethodClassification::Extension) => "extension", + None => "", + } + .to_string(); + } + base.jsonrpc_receive_stream = info.receive_stream; + base.jsonrpc_response = info.has_response; + } + + let Some(graphql) = &request.graphql else { + return Ok(Prepared::Evaluate(vec![base])); + }; + // Same as the YAML path: a GraphQL request is allowed only when it + // parsed into at least one operation and every operation is allowed. + if graphql.error.is_some() || graphql.operations.is_empty() { + return Ok(Prepared::Denied( + "GraphQL request could not be inspected".to_string(), + )); + } + // Resolve every hash-only persisted query before evaluating any + // operation, so an unregistered one is reported as such, as in YAML. + let registry = graphql + .operations + .iter() + .any(needs_graphql_registry) + .then(|| PersistedQueryRegistry::for_request(policy, ctx, &request.target)); + let mut operations = Vec::with_capacity(graphql.operations.len()); + for operation in &graphql.operations { + let effective = match ®istry { + Some(registry) if needs_graphql_registry(operation) => { + let Some(registered) = registry.lookup(operation) else { + return Ok(Prepared::Denied(UNREGISTERED_PERSISTED_QUERY.to_string())); + }; + registered + } + _ => EffectiveGraphqlOperation::parsed(operation), + }; + operations.push(effective); + } + Ok(Prepared::Evaluate( + operations + .into_iter() + .map(|operation| { + let mut l7_request = base.clone(); + l7_request.graphql_operation_type = + operation.operation_type.to_ascii_lowercase(); + l7_request.graphql_operation_name = operation.operation_name; + l7_request.graphql_fields = operation.fields; + l7_request + }) + .collect(), + )) + } + + /// Evaluates one Cedar request and returns `(allowed, deny_reason)`, + /// logging any staged audit decision when `staged_audit` asks for it. + /// + /// A query with too many value combinations to evaluate is denied with + /// that reason. + fn evaluate_one( + cedar: &CedarEngine, + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + l7_request: &HttpRequestInput, + staged_audit: StagedAudit, + ) -> Result<(bool, String)> { + let evaluation = match cedar.evaluate_l7(l7_request) { + Ok(evaluation) => evaluation, + Err(error @ CedarEngineError::TooManyQueryCombinations { .. }) => { + return Ok((false, error.to_string())); + } + Err(error) => return Err(miette::miette!("{error}")), + }; + if staged_audit == StagedAudit::Log + && let Some(staged) = &evaluation.staged + { + ocsf_emit!(staged_audit_event( + ctx, + request, + evaluation.is_allow(), + staged + )); + } + Ok(Self::decision(evaluation.is_allow())) + } + + /// Returns the relay's `(allowed, deny_reason)` pair. + fn decision(allowed: bool) -> (bool, String) { + let reason = if allowed { + String::new() + } else { + "denied by Cedar HttpRequest policy".to_string() + }; + (allowed, reason) + } +} + +/// The Cedar evaluations of one L7 request. +#[derive(Debug)] +enum Prepared { + /// Denied before evaluation, with the reason. + Denied(String), + /// One evaluation per GraphQL operation, else one. + Evaluate(Vec), +} + +/// The deny reason for a hash-only persisted query with no registry entry, +/// the same reason YAML reports. +const UNREGISTERED_PERSISTED_QUERY: &str = "GraphQL persisted query is not registered"; + +/// Returns whether a parsed operation is a hash-only persisted query. +/// +/// Mirrors the Rego `graphql_operation_needs_registry`: the request named a +/// persisted query and sent no operation text, so the operation type is +/// unknown until the registry supplies it. +fn needs_graphql_registry(operation: &crate::l7::graphql::GraphqlOperationInfo) -> bool { + operation.persisted_query && operation.operation_type.is_empty() +} + +/// The GraphQL operation Cedar evaluates. +#[derive(Debug, Clone, PartialEq, Eq)] +struct EffectiveGraphqlOperation { + operation_type: String, + operation_name: String, + fields: Vec, +} + +impl EffectiveGraphqlOperation { + /// The operation as the request spelled it. + fn parsed(operation: &crate::l7::graphql::GraphqlOperationInfo) -> Self { + Self { + operation_type: operation.operation_type.clone(), + operation_name: operation.operation_name.clone().unwrap_or_default(), + fields: operation.fields.clone(), + } + } +} + +/// The persisted-query registry of the endpoint config a request selects. +#[derive(Debug, Default)] +struct PersistedQueryRegistry { + /// `None` unless the config sets `persisted_queries: allow_registered`. + operations: Option>, +} + +impl PersistedQueryRegistry { + /// Reads the registry from the endpoint config the relay selects for + /// `target`, which carries the matching `endpoint_settings`. + fn for_request( + policy: &LoadedPolicy, + ctx: &crate::l7::relay::L7EvalContext, + target: &str, + ) -> Self { + let Some(config) = policy.request_config(&ctx.host, ctx.port, target) else { + return Self::default(); + }; + Self { + operations: (config.persisted_queries == "allow_registered") + .then_some(config.graphql_persisted_queries), + } + } + + /// Returns the registered operation for a hash-only persisted query. + /// + /// Mirrors the Rego `graphql_registered_operation`: the key is the + /// query hash when the request has one, and the saved-query id + /// otherwise. + fn lookup( + &self, + operation: &crate::l7::graphql::GraphqlOperationInfo, + ) -> Option { + let key = operation + .persisted_query_hash + .as_deref() + .filter(|hash| !hash.is_empty()) + .or_else(|| { + operation + .persisted_query_id + .as_deref() + .filter(|id| !id.is_empty()) + })?; + let registered = self.operations.as_ref()?.get(key)?; + Some(EffectiveGraphqlOperation { + operation_type: registered.operation_type.clone(), + operation_name: registered.operation_name.clone(), + fields: registered.fields.clone(), + }) + } +} + +/// Whether an evaluation logs the decision staged policies would make. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StagedAudit { + /// Log it; the evaluation is the request's own policy decision. + Log, + /// Skip it; the request's own decision already logged it. + Skip, +} + +/// Builds the event for a request whose decision staged policies would +/// change. +/// +/// The request itself is decided, and logged by the relay, without them. +fn staged_audit_event( + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + allowed: bool, + staged: &Decision, +) -> openshell_ocsf::OcsfEvent { + let (action_id, disposition_id) = if allowed { + (ActionId::Allowed, DispositionId::Allowed) + } else { + (ActionId::Denied, DispositionId::Blocked) + }; + let (outcome, policies) = match staged { + Decision::Allow { matched_policies } => ("allow", matched_policies), + Decision::Deny { matched_policies } => ("deny", matched_policies), + }; + let method = if request.action.is_empty() { + "-" + } else { + request.action.as_str() + }; + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(action_id) + .disposition(disposition_id) + .severity(SeverityId::Informational) + .http_request(HttpRequest::new( + method, + OcsfUrl::new("http", &ctx.host, &request.target, ctx.port), + )) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, "cedar") + .unmapped("cedar_audit", format!("staged_{outcome}")) + .unmapped("cedar_audit_policies", policies.join(",")) + .message(format!( + "L7_AUDIT staged {outcome} {method} {}:{}{} policies={}", + ctx.host, + ctx.port, + request.target, + policies.join(","), + )) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::l7::L7RequestInfo; + use crate::l7::relay::L7EvalContext; + + const POLICY: &str = r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { + context.binary_path == "/usr/bin/curl" +}; + +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { + context.binary_path == "/usr/bin/curl" + && context.method == "GET" + && context.path == "/v1/status" +}; +"#; + + fn ctx() -> L7EvalContext { + L7EvalContext { + host: "api.example.com".to_string(), + port: 443, + binary_path: "/usr/bin/curl".to_string(), + ..Default::default() + } + } + + fn request(action: &str, target: &str) -> L7RequestInfo { + L7RequestInfo { + action: action.to_string(), + target: target.to_string(), + query_params: HashMap::new(), + graphql: None, + jsonrpc: None, + } + } + + #[test] + fn authorize_egress_populates_endpoint_configs_for_an_l7_endpoint() { + // Without this, query_l7_route_snapshot (proxy.rs) always sees an + // empty endpoint_configs and routes every allowed CONNECT to + // unconditional passthrough — the HttpRequest permit above would + // then never actually be consulted for a real connection. + let engine = CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses"); + let input = NetworkInput { + host: "api.example.com".to_string(), + port: 443, + binary_path: "/usr/bin/curl".into(), + binary_sha256: "deadbeef".to_string(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + let authorization = engine.authorize_egress(&input).expect("request evaluates"); + assert!( + matches!(authorization.action, NetworkAction::Allow { .. }), + "{:?}", + authorization.action + ); + assert_eq!(authorization.endpoint_configs.len(), 1); + let config = crate::l7::parse_l7_config(&authorization.endpoint_configs[0]) + .expect("config must parse"); + assert_eq!(config.protocol, openshell_policy::L7Protocol::Rest); + } + + #[test] + fn loading_an_audit_only_path_beside_enforced_policies_keeps_the_warning() { + // `LoadedPolicy::from_proto` logs every analysis warning as an OCSF + // config event. Tracing callsite interest is process-wide, so the + // emission is not captured here; this checks that the loaded engine + // carries exactly the warning the loader iterates over. + const AUDIT_PATH_POLICY: &str = r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { context.binary_path == "/usr/bin/curl" }; + +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { context.method == "GET" && context.path like("/v1/**", "/") }; + +@path("/admin/**") +@enforcement("audit") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { context.path like("/admin/**", "/") }; +"#; + let engine = CedarOnlyEngine::from_policy_str(AUDIT_PATH_POLICY) + .expect("policy loads with a warning"); + assert_eq!( + engine.warnings_for_tests(), + [ + openshell_policy_cedar::AnalysisWarning::AuditPathOnEnforcedEndpoint { + endpoint: "api.example.com:443".to_string(), + path: "/admin/**".to_string(), + } + ] + ); + + let engine = CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses"); + assert_eq!(engine.warnings_for_tests(), []); + } + + #[test] + fn authorize_egress_omits_endpoint_configs_for_a_connect_only_endpoint() { + const CONNECT_ONLY_POLICY: &str = r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443" +) +when { context.binary_path == "/usr/bin/curl" }; +"#; + let engine = CedarOnlyEngine::from_policy_str(CONNECT_ONLY_POLICY).expect("policy parses"); + let input = NetworkInput { + host: "pypi.org".to_string(), + port: 443, + binary_path: "/usr/bin/curl".into(), + binary_sha256: "deadbeef".to_string(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + let authorization = engine.authorize_egress(&input).expect("request evaluates"); + assert!( + matches!(authorization.action, NetworkAction::Allow { .. }), + "{:?}", + authorization.action + ); + assert!(authorization.endpoint_configs.is_empty()); + } + + #[test] + fn l7_override_allows_the_permitted_request() { + let engine = CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses"); + let generation = engine.current_generation(); + let handle = engine.l7_handle(generation); + let (allowed, _) = handle + .evaluate_request(&ctx(), &request("GET", "/v1/status")) + .expect("request evaluates"); + assert!(allowed); + } + + #[test] + fn l7_override_denies_a_different_path() { + let engine = CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses"); + let generation = engine.current_generation(); + let handle = engine.l7_handle(generation); + let (allowed, reason) = handle + .evaluate_request(&ctx(), &request("GET", "/v1/admin")) + .expect("request evaluates"); + assert!(!allowed); + assert!(!reason.is_empty()); + } + + #[test] + fn l7_override_fails_closed_when_tunnel_generation_is_stale() { + let engine = CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses"); + let captured_generation = engine.current_generation(); + let handle = engine.l7_handle(captured_generation); + // A reload with genuinely different policy text must still advance + // the generation — only a byte-identical reload is a no-op. + let changed_policy = format!("{POLICY}\n// a trailing comment to change the source\n"); + engine + .reload_from_policy_str(&changed_policy) + .expect("reload with changed policy advances the generation"); + + let result = handle.evaluate_request(&ctx(), &request("GET", "/v1/status")); + assert!( + result.is_err(), + "stale tunnel must fail closed, not silently re-evaluate" + ); + } + + #[test] + fn reload_with_identical_policy_source_is_a_no_op() { + // An unconditional generation bump here would invalidate every + // in-flight L7 tunnel whenever a policy poll reconciliation pass + // fires for a reason unrelated to this sandbox's Cedar policy (e.g. + // middleware registry reconciliation) — not just a real change. + let engine = CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses"); + let generation_before = engine.current_generation(); + engine + .reload_from_policy_str(POLICY) + .expect("reload succeeds"); + assert_eq!( + engine.current_generation(), + generation_before, + "reloading byte-identical policy text must not advance the generation" + ); + } + + #[test] + fn tunnel_engine_delegates_l7_decisions_to_cedar() { + let cedar = Arc::new(CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses")); + let engine = crate::policy_engine::PolicyEngine::from(Arc::clone(&cedar)); + + let tunnel = engine + .tunnel_engine(cedar.current_generation()) + .expect("tunnel builds"); + + let (allowed, _) = tunnel + .evaluate_request(&ctx(), &request("GET", "/v1/status")) + .expect("request evaluates"); + assert!(allowed, "Cedar must decide the request"); + } + + #[test] + fn tunnel_engine_tracks_the_cedar_generation() { + let cedar = Arc::new(CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses")); + let engine = crate::policy_engine::PolicyEngine::from(Arc::clone(&cedar)); + let tunnel = engine + .tunnel_engine(cedar.current_generation()) + .expect("tunnel builds"); + + // A middleware registry swap advances the middleware generation, not + // Cedar's, so it must not close a Cedar tunnel. + engine + .replace_middleware_registry(MiddlewareRegistry::default()) + .expect("registry swap"); + assert!(!tunnel.is_stale()); + + // A Cedar reload must. + cedar + .reload_from_policy_str(&format!("{POLICY}\n// changed\n")) + .expect("reload"); + assert!(tunnel.is_stale()); + } + + #[test] + fn tunnel_engine_rejects_a_stale_cedar_generation() { + let cedar = Arc::new(CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses")); + let engine = crate::policy_engine::PolicyEngine::from(Arc::clone(&cedar)); + let decided_at = cedar.current_generation(); + cedar + .reload_from_policy_str(&format!("{POLICY}\n// changed\n")) + .expect("reload"); + + assert!(engine.tunnel_engine(decided_at).is_err()); + } + + fn middleware_policy(include: &str) -> ProtoSandboxPolicy { + let mut policy = policy_from_source(POLICY); + policy.network_middlewares.insert( + "redactor".to_string(), + openshell_core::proto::NetworkMiddlewareConfig { + middleware: "openshell/regex".to_string(), + order: 10, + endpoints: Some(openshell_core::proto::MiddlewareEndpointSelector { + include: vec![include.to_string()], + exclude: Vec::new(), + }), + ..Default::default() + }, + ); + policy + } + + fn middleware_input(host: &str) -> NetworkInput { + NetworkInput { + host: host.to_string(), + port: 443, + binary_path: "/usr/bin/curl".into(), + binary_sha256: String::new(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + } + } + + /// A middleware-only reload publishes the new chain under a new + /// middleware generation, keeps the Cedar generation so inspected + /// tunnels stay open, and leaves existing tunnels on the chain they + /// started with. + #[test] + fn middleware_only_reload_keeps_the_cedar_generation_and_tunnel_chains() { + let cedar = Arc::new( + CedarOnlyEngine::from_proto(&middleware_policy("api.example.com")) + .expect("policy loads"), + ); + let engine = crate::policy_engine::PolicyEngine::from(Arc::clone(&cedar)); + let policy_generation = cedar.current_generation(); + let middleware_generation = engine.middleware_generation(); + let tunnel = engine + .tunnel_engine(policy_generation) + .expect("tunnel builds"); + let selected = |chain: Vec| { + chain + .into_iter() + .map(|entry| entry.name) + .collect::>() + }; + assert_eq!( + selected( + tunnel + .query_middleware_chain(&middleware_input("api.example.com")) + .expect("tunnel chain") + ), + vec!["redactor"] + ); + + let staged = cedar + .stage(&middleware_policy("other.example.com"), 0) + .expect("stage"); + let mut credentials_committed = false; + let guard = cedar + .commit_configuration(staged, None, || credentials_committed = true) + .expect("commit"); + + assert!(credentials_committed); + assert_eq!(cedar.current_generation(), policy_generation); + assert!(!tunnel.is_stale()); + assert_eq!(guard.captured_generation(), middleware_generation + 1); + let (chain, generation) = engine + .query_middleware_chain_with_generation(&middleware_input("api.example.com")) + .expect("chain"); + assert!(chain.is_empty()); + assert_eq!(generation, middleware_generation + 1); + assert_eq!( + selected( + tunnel + .query_middleware_chain(&middleware_input("api.example.com")) + .expect("tunnel chain") + ), + vec!["redactor"], + "an open tunnel keeps the middleware it started with" + ); + } + + /// Existing Cedar guards become stale before credentials change, as the + /// YAML reload guarantees. + #[test] + fn commit_configuration_invalidates_policy_guards_before_credentials_change() { + let cedar = CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses"); + let policy_guard = cedar + .generation_guard(cedar.current_generation()) + .expect("policy guard"); + let middleware_guard = cedar + .middleware() + .generation_guard(cedar.middleware().current_generation()) + .expect("middleware guard"); + let staged = cedar + .stage(&policy_from_source(&format!("{POLICY}\n// changed\n")), 0) + .expect("stage"); + let mut stale_at_credential_commit = None; + cedar + .commit_configuration(staged, None, || { + stale_at_credential_commit = + Some((policy_guard.is_stale(), middleware_guard.is_stale())); + }) + .expect("commit"); + assert_eq!(stale_at_credential_commit, Some((true, true))); + } + + fn chain_names(chain: Vec) -> Vec { + chain.into_iter().map(|entry| entry.name).collect() + } + + /// A quarantine is a configuration change: the guard the policy poll + /// loop pins (the middleware generation) goes stale when the quarantine + /// starts and when it ends, as the single-counter YAML guard does. + #[test] + fn quarantine_entry_and_exit_stale_the_configuration_guard() { + let cedar = Arc::new(CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses")); + let engine = crate::policy_engine::PolicyEngine::from(Arc::clone(&cedar)); + let policy_generation = cedar.current_generation(); + let before = engine + .configuration_generation_guard() + .expect("configuration guard"); + + let quarantined = cedar + .enter_fail_closed("invalid candidate") + .expect("quarantine"); + assert_eq!(quarantined, policy_generation + 1); + assert!(before.is_stale(), "entering a quarantine stales the guard"); + assert!(cedar.fail_closed_reason().is_some()); + + let during = engine + .configuration_generation_guard() + .expect("configuration guard"); + assert_eq!( + cedar.exit_fail_closed().expect("exit"), + policy_generation + 2 + ); + assert!(during.is_stale(), "ending a quarantine stales the guard"); + assert!(cedar.fail_closed_reason().is_none()); + + // Without a quarantine to end, nothing changes. + let after = engine + .configuration_generation_guard() + .expect("configuration guard"); + assert_eq!( + cedar.exit_fail_closed().expect("exit"), + policy_generation + 2 + ); + assert!(!after.is_stale()); + } + + /// A commit whose binary resolution fails changes nothing: the policy, + /// the middleware, and the credentials all stay on the previous + /// revision, and no guard becomes stale. + #[test] + fn a_commit_that_fails_to_resolve_changes_nothing() { + const PID: u32 = 4242; + let cedar = Arc::new( + CedarOnlyEngine::from_proto(&middleware_policy("api.example.com")) + .expect("policy loads"), + ); + let engine = crate::policy_engine::PolicyEngine::from(Arc::clone(&cedar)); + let input = middleware_input("api.example.com"); + + // A changed policy with changed middleware, staged before the + // entrypoint process is known, so the commit has to resolve it. + let mut candidate = middleware_policy("other.example.com"); + candidate.cedar_policy_source.push_str("\n// changed\n"); + let mut staged = cedar.stage(&candidate, 0).expect("stage"); + staged.fail_binary_resolution_for_tests(); + cedar.resolve_binary_symlinks(PID).expect("resolves"); + + let policy_generation = cedar.current_generation(); + let policy_guard = cedar + .generation_guard(policy_generation) + .expect("policy guard"); + let configuration = engine + .configuration_generation_guard() + .expect("configuration guard"); + let mut credentials_committed = false; + + let error = cedar + .commit_configuration(staged, None, || credentials_committed = true) + .expect_err("resolution fails"); + + assert!( + error.to_string().contains("injected test failure"), + "{error}" + ); + assert!( + !credentials_committed, + "credentials stay on the previous revision" + ); + assert!(!policy_guard.is_stale()); + assert!(!configuration.is_stale()); + assert_eq!(cedar.current_generation(), policy_generation); + assert_eq!( + cedar.read_inputs().expect("lock").source, + POLICY, + "the active policy is unchanged" + ); + let (chain, generation) = engine + .query_middleware_chain_with_generation(&input) + .expect("chain"); + assert_eq!(chain_names(chain), vec!["redactor"]); + assert_eq!(generation, configuration.captured_generation()); + + // The engine is still usable: the same candidate commits once it resolves. + let staged = cedar.stage(&candidate, 0).expect("stage"); + cedar + .commit_configuration(staged, None, || credentials_committed = true) + .expect("commit"); + assert!(credentials_committed); + assert!(policy_guard.is_stale()); + assert!(configuration.is_stale()); + } + + /// A passthrough tunnel keeps the middleware it started with across a + /// middleware-only reload, as an inspected tunnel does, and closes when + /// the Cedar policy changes. + #[test] + fn passthrough_middleware_keeps_its_snapshot_across_a_middleware_only_reload() { + let cedar = Arc::new( + CedarOnlyEngine::from_proto(&middleware_policy("api.example.com")) + .expect("policy loads"), + ); + let engine = crate::policy_engine::PolicyEngine::from(Arc::clone(&cedar)); + let guard = engine + .generation_guard(cedar.current_generation()) + .expect("tunnel guard"); + let passthrough = engine.passthrough_middleware().expect("snapshot"); + let input = middleware_input("api.example.com"); + let (chain, _) = passthrough + .select(&input, &guard) + .expect("select") + .expect("tunnel open"); + assert_eq!(chain_names(chain), vec!["redactor"]); + + let staged = cedar + .stage(&middleware_policy("other.example.com"), 0) + .expect("stage"); + cedar.commit(staged).expect("commit"); + + let (live, _) = engine + .query_middleware_chain_with_generation(&input) + .expect("chain"); + assert!(live.is_empty(), "new tunnels select the new middleware"); + assert!( + !guard.is_stale(), + "a middleware-only reload keeps the Cedar generation" + ); + let (chain, _) = passthrough + .select(&input, &guard) + .expect("select") + .expect("tunnel open"); + assert_eq!( + chain_names(chain), + vec!["redactor"], + "an open passthrough tunnel keeps the middleware it started with" + ); + + cedar + .reload_from_policy_str(&format!("{POLICY}\n// changed\n")) + .expect("reload"); + assert!(guard.is_stale()); + assert!( + passthrough + .select(&input, &guard) + .expect("select") + .is_none(), + "a policy change closes the tunnel" + ); + } + + /// `from_proto` and `stage` validate the policy as `OpaEngine::from_proto` + /// validates a YAML policy, so no caller can build an unvalidated engine. + #[test] + fn engines_validate_the_policy_as_yaml_engines_do() { + let mut invalid = middleware_policy("api.example.com"); + invalid + .network_middlewares + .get_mut("redactor") + .expect("middleware") + .on_error = "maybe".to_string(); + + let error = CedarOnlyEngine::from_proto(&invalid).expect_err("invalid middleware"); + assert!( + error + .to_string() + .contains("invalid middleware configuration"), + "{error}" + ); + let engine = CedarOnlyEngine::from_policy_str(POLICY).expect("policy loads"); + let error = engine.stage(&invalid, 0).expect_err("invalid middleware"); + assert!( + error + .to_string() + .contains("invalid middleware configuration"), + "{error}" + ); + + let error = CedarOnlyEngine::from_proto(&policy_from_source("not cedar {{{")) + .expect_err("invalid Cedar text"); + assert!( + error.to_string().contains("invalid Cedar policy"), + "{error}" + ); + } + + const CONNECT_ONLY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +"#; + + fn provider_policy( + cedar: &str, + endpoints: Vec, + ) -> ProtoSandboxPolicy { + let mut policy = policy_from_source(cedar); + policy.provider_credential_rules.insert( + "_provider_work".to_string(), + NetworkPolicyRule { + name: "_provider_work".to_string(), + endpoints, + ..Default::default() + }, + ); + policy + } + + fn credentialed_endpoint() -> openshell_core::proto::NetworkEndpoint { + openshell_core::proto::NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + protocol: "rest".to_string(), + access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32, + provider_credentialed: true, + request_body_credential_rewrite: true, + ..Default::default() + } + } + + fn curl_input(host: &str) -> NetworkInput { + NetworkInput { + host: host.to_string(), + port: 443, + binary_path: "/usr/bin/curl".into(), + binary_sha256: String::new(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + } + } + + #[test] + fn provider_settings_apply_with_cedar_inspection() { + let engine = + CedarOnlyEngine::from_proto(&provider_policy(POLICY, vec![credentialed_endpoint()])) + .expect("policy loads"); + let authorization = engine + .authorize_egress(&curl_input("api.example.com")) + .expect("request evaluates"); + assert_eq!(authorization.endpoint_configs.len(), 1); + let config = crate::l7::parse_l7_config(&authorization.endpoint_configs[0]) + .expect("config must parse"); + assert_eq!(config.protocol, openshell_policy::L7Protocol::Rest); + assert!(config.provider_credentialed); + assert!(config.request_body_credential_rewrite); + assert_eq!( + authorization.endpoint_configs[0].access, + crate::policy_types::AccessSetting::None, + "provider L7 rules must not apply on a Cedar sandbox" + ); + } + + #[test] + fn uninspected_credentialed_endpoint_is_refused_at_connect() { + // Cedar allows the connection but has no HttpRequest policy for it, + // so it would be relayed without inspection. + let engine = CedarOnlyEngine::from_proto(&provider_policy( + CONNECT_ONLY, + vec![credentialed_endpoint()], + )) + .expect("policy loads"); + let guards = engine + .credential_guards("api.example.com", 443) + .expect("guards evaluate"); + assert_eq!(guards.len(), 1); + let guard = crate::l7::parse_endpoint_credential_guard(&guards[0]); + assert!(guard.provider_credentialed); + assert!(guard.blocks_connect()); + } + + #[test] + fn provider_rules_grant_no_access() { + let mut other = credentialed_endpoint(); + other.host = "other.example.com".to_string(); + let engine = CedarOnlyEngine::from_proto(&provider_policy(POLICY, vec![other])) + .expect("policy loads"); + let authorization = engine + .authorize_egress(&curl_input("other.example.com")) + .expect("request evaluates"); + assert!( + matches!(authorization.action, NetworkAction::Deny { .. }), + "{:?}", + authorization.action + ); + assert!(authorization.endpoint_configs.is_empty()); + } + + #[test] + fn provider_allowed_ips_narrow_addresses_and_hostless_endpoints_match_nothing() { + let mut ranged = credentialed_endpoint(); + ranged.allowed_ips = vec!["10.0.0.0/8".to_string()]; + let hostless = openshell_core::proto::NetworkEndpoint { + host: String::new(), + port: 8443, + allowed_ips: vec!["10.0.0.0/8".to_string()], + provider_credentialed: true, + ..Default::default() + }; + let cedar = format!( + r#"{POLICY} +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when {{ resource.port == 8443 }}; +"# + ); + let engine = CedarOnlyEngine::from_proto(&provider_policy(&cedar, vec![ranged, hostless])) + .expect("policy loads"); + + let authorization = engine + .authorize_egress(&curl_input("api.example.com")) + .expect("request evaluates"); + assert_eq!(authorization.endpoint_configs.len(), 1); + assert!( + authorization.endpoint_configs[0].allowed_ips.is_empty(), + "{:?}", + authorization.endpoint_configs[0] + ); + let destination = authorization + .cedar_destination + .expect("the connection is allowed"); + assert_eq!( + destination.provider_ranges(), + [Ok(vec!["10.0.0.0/8".parse::().unwrap()])], + "the ranges travel with the destination rules, not the config" + ); + + let guards = engine + .credential_guards("anything.example.com", 8443) + .expect("guards evaluate"); + assert!( + guards.is_empty(), + "a host-less provider endpoint lends no credentials" + ); + } + + const ADDRESS_POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.corp.net:5432") +when { + context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.2.0.0/16")) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.corp.net:443") +when { context.binary_path == "/usr/bin/wget" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.corp.net", ".") && resource.port == 443 }; +"#; + + #[test] + fn allowed_connections_carry_their_destination_rules() { + let engine = CedarOnlyEngine::from_policy_str(ADDRESS_POLICY).expect("policy loads"); + let mut input = curl_input("db.corp.net"); + input.port = 5432; + let authorization = engine.authorize_egress(&input).expect("request evaluates"); + assert!(matches!(authorization.action, NetworkAction::Allow { .. })); + assert!(authorization.exact_declared_endpoint_host); + let destination = authorization + .cedar_destination + .expect("an allowed connection carries its destination rules"); + assert_eq!( + destination.address_conditions(), + [vec!["10.2.0.0/16".parse::().unwrap()]] + ); + assert!(!destination.unconstrained_permit()); + assert_eq!(destination.allows("10.2.3.4".parse().unwrap()), Ok(true)); + assert_eq!(destination.allows("10.3.3.4".parse().unwrap()), Ok(false)); + + input.port = 5433; + let denied = engine.authorize_egress(&input).expect("request evaluates"); + assert!(matches!(denied.action, NetworkAction::Deny { .. })); + assert!(denied.cedar_destination.is_none()); + } + + #[test] + fn exact_host_counts_only_permits_that_allow_the_connection() { + let engine = CedarOnlyEngine::from_policy_str(ADDRESS_POLICY).expect("policy loads"); + let curl = engine + .authorize_egress(&curl_input("api.corp.net")) + .expect("request evaluates"); + assert!(matches!(curl.action, NetworkAction::Allow { .. })); + assert!( + !curl.exact_declared_endpoint_host, + "the exact permit is for another binary" + ); + let mut wget = curl_input("api.corp.net"); + wget.binary_path = "/usr/bin/wget".into(); + let wget = engine.authorize_egress(&wget).expect("request evaluates"); + assert!(wget.exact_declared_endpoint_host); + } + + #[test] + fn dns_records_carry_destination_ranges_as_allowed_ips() { + let engine = CedarOnlyEngine::from_policy_str(ADDRESS_POLICY).expect("policy loads"); + let snapshot = engine + .policy_dns_eligibility_snapshot() + .expect("snapshot builds"); + let db = snapshot + .endpoints + .iter() + .map(|record| &record.endpoint) + .find(|record| record.host == "db.corp.net") + .expect("db.corp.net is eligible"); + assert_eq!(db.allowed_ips, ["10.2.0.0/16"]); + } + + #[test] + fn path_scoped_provider_endpoint_keeps_other_paths_inspected() { + let mut scoped = credentialed_endpoint(); + scoped.path = "/v1/**".to_string(); + let engine = CedarOnlyEngine::from_proto(&provider_policy(POLICY, vec![scoped])) + .expect("policy loads"); + let configs = engine + .credential_guards("api.example.com", 443) + .expect("configs build"); + assert_eq!(configs.len(), 2, "path-scoped config plus a path-less one"); + assert!(configs.iter().all(|config| { + crate::l7::parse_l7_config(config) + .is_some_and(|config| config.protocol == openshell_policy::L7Protocol::Rest) + })); + } + + #[test] + fn reload_tracks_provider_rule_changes() { + let engine = + CedarOnlyEngine::from_proto(&provider_policy(POLICY, vec![credentialed_endpoint()])) + .expect("policy loads"); + let generation = engine.current_generation(); + + let unchanged = engine + .stage(&provider_policy(POLICY, vec![credentialed_endpoint()]), 0) + .expect("stage"); + engine.commit(unchanged).expect("commit"); + assert_eq!(engine.current_generation(), generation); + + let changed = engine.stage(&policy_from_source(POLICY), 0).expect("stage"); + engine.commit(changed).expect("commit"); + assert_eq!(engine.current_generation(), generation + 1); + assert_eq!( + engine + .credential_guards("api.example.com", 443) + .expect("configs") + .len(), + 1, + "only Cedar's own inspection config remains" + ); + } + + #[test] + fn exact_declared_host_follows_the_permit_scope() { + let engine = CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses"); + assert!( + engine + .authorize_egress(&curl_input("api.example.com")) + .expect("request evaluates") + .exact_declared_endpoint_host + ); + + let glob = CedarOnlyEngine::from_policy_str( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) + when { resource.host like "*.example.com" };"#, + ) + .expect("policy parses"); + let authorization = glob + .authorize_egress(&curl_input("api.example.com")) + .expect("request evaluates"); + assert!(matches!(authorization.action, NetworkAction::Allow { .. })); + assert!(!authorization.exact_declared_endpoint_host); + } + + const HOST_GLOB_POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +"#; + + #[test] + fn delimited_host_glob_is_eligible_for_policy_dns() { + let engine = CedarOnlyEngine::from_policy_str(HOST_GLOB_POLICY).expect("policy parses"); + let snapshot = engine.policy_dns_eligibility_snapshot().expect("snapshot"); + let hosts: Vec<_> = snapshot + .endpoints + .iter() + .map(|endpoint| endpoint.endpoint.host.clone()) + .collect(); + assert_eq!(hosts, ["*.example.com"]); + + let selector = + openshell_core::host_pattern::HostSelector::new(&["*.example.com".to_string()], &[]) + .expect("policy DNS accepts the glob"); + assert!(selector.matches("api.example.com")); + assert!(!selector.matches("a.b.example.com")); + } + + #[test] + fn host_glob_never_counts_as_an_exact_declared_host() { + let engine = CedarOnlyEngine::from_policy_str(HOST_GLOB_POLICY).expect("policy parses"); + for host in ["api.example.com", "*.example.com"] { + let authorization = engine + .authorize_egress(&curl_input(host)) + .expect("request evaluates"); + assert!( + matches!(authorization.action, NetworkAction::Allow { .. }), + "{host}" + ); + assert!(!authorization.exact_declared_endpoint_host, "{host}"); + } + } + + const NATIVE_TCP_POLICY: &str = r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example.com:5432") +when { context.binary_path == "/usr/bin/curl" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +"#; + + #[test] + fn dns_snapshot_marks_native_tcp_records() { + let engine = CedarOnlyEngine::from_policy_str(NATIVE_TCP_POLICY).expect("policy parses"); + let snapshot = engine.policy_dns_eligibility_snapshot().expect("snapshot"); + let records: Vec<_> = snapshot + .endpoints + .iter() + .map(|record| { + ( + record.policy_name.as_str(), + record.endpoint_index, + record.endpoint.clone(), + ) + }) + .collect(); + let record = |host: &str, port: u16, protocol: EndpointProtocol| DnsRecord { + host: host.to_string(), + ports: vec![port], + protocol, + allowed_ips: Vec::new(), + }; + assert_eq!( + records, + [ + ( + "cedar", + 0, + record("*.example.com", 443, EndpointProtocol::None) + ), + ( + "cedar", + 1, + record("api.example.com", 443, EndpointProtocol::None) + ), + ( + "cedar", + 2, + record("db.example.com", 5432, EndpointProtocol::Tcp) + ), + ] + ); + } + + /// Regression: `matched_endpoints` was always empty, so the transparent + /// TCP listener, which requires a decision endpoint that the dialed + /// policy DNS mapping also names, refused every Cedar-allowed connection. + #[test] + fn matched_endpoints_are_the_dns_records_covering_the_connection() { + let engine = CedarOnlyEngine::from_policy_str(NATIVE_TCP_POLICY).expect("policy parses"); + let snapshot = engine.policy_dns_eligibility_snapshot().expect("snapshot"); + let matched = |host: &str, port: u16| { + let authorization = engine + .authorize_egress(&NetworkInput { + port, + ..curl_input(host) + }) + .expect("request evaluates"); + authorization + .matched_endpoints + .iter() + .map(|record| { + let published = &snapshot.endpoints[record.endpoint_index]; + assert_eq!(published.policy_name, record.policy_name); + assert_eq!(published.endpoint, record.endpoint); + record.endpoint_index + }) + .collect::>() + }; + assert_eq!(matched("db.example.com", 5432), [2]); + // The exact host and the glob both cover it. + assert_eq!(matched("API.example.com.", 443), [0, 1]); + assert_eq!(matched("www.example.com", 443), [0]); + // A denied connection matches nothing. + assert!(matched("db.example.com", 80).is_empty()); + assert!(matched("other.example.org", 443).is_empty()); + } + + const AUDIT_POLICY: &str = r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +); + +@enforcement("audit") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { context.method == "GET" }; +"#; + + #[test] + fn audit_endpoint_configs_use_audit_enforcement() { + let engine = CedarOnlyEngine::from_proto(&provider_policy( + AUDIT_POLICY, + vec![credentialed_endpoint()], + )) + .expect("policy loads"); + let authorization = engine + .authorize_egress(&curl_input("api.example.com")) + .expect("request evaluates"); + assert!(!authorization.endpoint_configs.is_empty()); + for value in &authorization.endpoint_configs { + let config = crate::l7::parse_l7_config(value).expect("config must parse"); + assert_eq!(config.enforcement, crate::l7::EnforcementMode::Audit); + } + } + + #[test] + fn audit_endpoint_reports_the_policy_decision_for_the_relay_to_log() { + let engine = CedarOnlyEngine::from_policy_str(AUDIT_POLICY).expect("policy parses"); + let tunnel = engine.l7_handle(engine.current_generation()); + let (allowed, reason) = tunnel + .evaluate_request(&ctx(), &request("DELETE", "/v1/items/1")) + .expect("request evaluates"); + assert!(!allowed, "the relay logs and forwards an audit deny"); + assert!(!reason.is_empty()); + let (allowed, _) = tunnel + .evaluate_request(&ctx(), &request("GET", "/v1/items/1")) + .expect("request evaluates"); + assert!(allowed); + } + + #[test] + fn staged_audit_event_reports_the_staged_outcome() { + let staged = Decision::Deny { + matched_policies: vec!["no-hooks".to_string()], + }; + let event = staged_audit_event(&ctx(), &request("GET", "/v1/hooks"), true, &staged) + .to_json() + .expect("serialize"); + let message = event["message"].as_str().expect("message"); + assert_eq!( + message, + "L7_AUDIT staged deny GET api.example.com:443/v1/hooks policies=no-hooks" + ); + assert_eq!( + event["action_id"], + serde_json::json!(ActionId::Allowed as u8) + ); + assert_eq!(event["unmapped"]["cedar_audit"], "staged_deny"); + assert_eq!(event["unmapped"]["cedar_audit_policies"], "no-hooks"); + } + + #[test] + fn mcp_and_graphql_endpoint_configs_parse_for_the_relay() { + for (protocol, expected) in [ + ("mcp", openshell_policy::L7Protocol::Mcp), + ("graphql", openshell_policy::L7Protocol::Graphql), + ] { + let engine = CedarOnlyEngine::from_policy_str(&format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +@protocol("{protocol}") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +"# + )) + .expect("policy parses"); + let authorization = engine + .authorize_egress(&curl_input("api.example.com")) + .expect("request evaluates"); + assert_eq!(authorization.endpoint_configs.len(), 1, "{protocol}"); + let config = crate::l7::parse_l7_config(&authorization.endpoint_configs[0]) + .unwrap_or_else(|| panic!("{protocol} config must parse")); + assert_eq!(config.protocol, expected); + assert_eq!(config.enforcement, crate::l7::EnforcementMode::Enforce); + } + } + + /// Token-grant reporting binds a request to the policy its endpoint was + /// selected under through `policy_hash`, for every protocol, as the YAML + /// engine's endpoint data does; a Cedar config without it would never + /// match the installed observation inventory. + #[test] + fn endpoint_configs_carry_the_policy_hash_for_every_protocol() { + for protocol in ["rest", "mcp", "graphql", "json-rpc"] { + let policy = policy_from_source(&protocol_policy(protocol)); + let canonical = crate::policy_validation::validate_sandbox_policy(&policy) + .expect("policy validates"); + let engine = CedarOnlyEngine::from_proto(&policy).expect("policy loads"); + let configs = parsed_configs(&engine, "api.example.com"); + assert_eq!(configs.len(), 1, "{protocol}"); + assert_eq!( + configs[0].policy_hash, + deterministic_policy_hash(&canonical), + "{protocol}" + ); + } + + // A provider endpoint's config is bound to the same policy. + let engine = owner_engine(OWNER_POLICY); + let configs = parsed_configs(&engine, "api.example.com"); + assert!(!configs.is_empty()); + let expected = engine + .engine + .read() + .expect("engine lock") + .policy_hash + .clone(); + assert!(!expected.is_empty()); + assert!( + configs.iter().all(|config| config.policy_hash == expected), + "{configs:?}" + ); + } + + const OWNER_POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { + (context.method == "GET" && context.path like("/a/**", "/")) + || (context.method == "POST" && context.path like("/a/private/**", "/")) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.path == "/a/private/blocked" }; +"#; + + /// A provider endpoint stamped with `owner` whose own rule allows + /// `method` on `path`. + fn owned_endpoint( + owner: &str, + path: &str, + method: &str, + ) -> openshell_core::proto::NetworkEndpoint { + openshell_core::proto::NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: path.to_string(), + protocol: "rest".to_string(), + token_grant_owner: owner.to_string(), + rules: vec![openshell_core::proto::L7Rule { + allow: Some(openshell_core::proto::L7Allow { + method: method.to_string(), + path: path.to_string(), + ..Default::default() + }), + }], + ..Default::default() + } + } + + fn owner_engine(cedar: &str) -> CedarOnlyEngine { + let mut policy = provider_policy( + cedar, + vec![ + owned_endpoint("owner-broad", "/a/**", "GET"), + owned_endpoint("owner-narrow", "/a/private/**", "POST"), + ], + ); + policy + .provider_credential_rules + .get_mut("_provider_work") + .expect("provider rule") + .binaries + .push(openshell_core::proto::NetworkBinary { + path: "/usr/bin/curl".to_string(), + }); + CedarOnlyEngine::from_proto(&policy).expect("policy loads") + } + + fn owners(engine: &CedarOnlyEngine, method: &str, target: &str) -> Vec { + let mut owners: Vec<_> = engine + .l7_handle(engine.current_generation()) + .admitted_token_grant_owners(&ctx(), &request(method, target)) + .expect("owners evaluate") + .into_iter() + .collect(); + owners.sort(); + owners + } + + #[test] + fn token_grant_owner_requires_its_own_provider_rules_to_allow() { + let engine = owner_engine(OWNER_POLICY); + // Both endpoints cover the path, but only the broad endpoint's own + // rule allows GET: Cedar's allow does not lend the narrow grant. + assert_eq!(owners(&engine, "GET", "/a/private/item"), ["owner-broad"]); + assert_eq!(owners(&engine, "POST", "/a/private/item"), ["owner-narrow"]); + assert!(owners(&engine, "GET", "/b/item").is_empty()); + } + + #[test] + fn token_grant_owner_requires_cedar_to_allow() { + let engine = owner_engine(OWNER_POLICY); + // The broad provider rule allows this request, but Cedar forbids it. + assert!(owners(&engine, "GET", "/a/private/blocked").is_empty()); + // Provider rules never allow what Cedar does not permit. + let narrow_cedar = OWNER_POLICY.replace(r#"context.method == "GET""#, "false"); + let engine = owner_engine(&narrow_cedar); + assert!(owners(&engine, "GET", "/a/item").is_empty()); + } + + #[test] + fn token_grant_owner_needs_an_allowing_audit_decision() { + let engine = owner_engine(AUDIT_POLICY); + // Cedar's audit-only endpoint forwards a denied POST, but a denial + // admits no grant. + assert!(owners(&engine, "POST", "/a/private/item").is_empty()); + assert_eq!(owners(&engine, "GET", "/a/item"), ["owner-broad"]); + } + + #[test] + fn token_grant_owners_are_empty_without_stamped_provider_endpoints() { + let engine = + CedarOnlyEngine::from_proto(&provider_policy(POLICY, vec![credentialed_endpoint()])) + .expect("policy loads"); + assert!(owners(&engine, "GET", "/v1/status").is_empty()); + } + + #[test] + fn token_grant_owners_fail_closed_when_tunnel_generation_is_stale() { + let engine = owner_engine(OWNER_POLICY); + let handle = engine.l7_handle(engine.current_generation()); + engine + .reload_from_policy_str(OWNER_POLICY) + .expect("reload without provider rules advances the generation"); + assert!( + handle + .admitted_token_grant_owners(&ctx(), &request("GET", "/a/item")) + .is_err() + ); + } + + #[test] + fn tunnel_engine_delegates_token_grant_owners_to_cedar() { + let cedar = Arc::new(owner_engine(OWNER_POLICY)); + let engine = crate::policy_engine::PolicyEngine::from(Arc::clone(&cedar)); + let tunnel = engine + .tunnel_engine(cedar.current_generation()) + .expect("tunnel builds"); + let owners = tunnel + .admitted_token_grant_owners(&ctx(), &request("POST", "/a/private/item")) + .expect("owners evaluate"); + assert_eq!( + owners, + std::collections::HashSet::from(["owner-narrow".to_string()]) + ); + } + + /// A Cedar policy with `settings` as its endpoint settings. + fn settings_policy( + cedar: &str, + settings: Vec, + ) -> ProtoSandboxPolicy { + let mut policy = policy_from_source(cedar); + policy.endpoint_settings = settings; + policy + } + + fn setting(host: &str, path: &str) -> openshell_core::proto::NetworkEndpoint { + openshell_core::proto::NetworkEndpoint { + host: host.to_string(), + port: 443, + path: path.to_string(), + ..Default::default() + } + } + + fn parsed_configs(engine: &CedarOnlyEngine, host: &str) -> Vec { + engine + .authorize_egress(&curl_input(host)) + .expect("request evaluates") + .endpoint_configs + .iter() + .map(|config| crate::l7::parse_l7_config(config).expect("config must parse")) + .collect() + } + + fn protocol_policy(protocol: &str) -> String { + format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +@protocol("{protocol}") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +"# + ) + } + + /// A connect permit and REST inspection for every host matching + /// `*.example.com` on port 443, allowing `GET`. + const GLOB_INSPECTED: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +permit (principal, action == Sandbox::Action::"HttpRequest", resource is Sandbox::NetworkEndpoint) +when { + resource.host like("*.example.com", ".") && resource.port == 443 + && context.method == "GET" +}; +"#; + + #[test] + fn host_glob_selects_inspection_for_every_matching_host() { + let engine = CedarOnlyEngine::from_policy_str(GLOB_INSPECTED).expect("policy loads"); + for host in ["api.example.com", "other.example.com"] { + let configs = parsed_configs(&engine, host); + assert_eq!(configs.len(), 1, "{host}: {configs:?}"); + assert_eq!(configs[0].protocol, openshell_policy::L7Protocol::Rest); + assert_eq!(configs[0].path, ""); + let guards = engine.credential_guards(host, 443).expect("guards"); + assert_eq!( + guards.len(), + 1, + "{host}: the credential guard sees the inspection" + ); + + // Cedar evaluates the actual host, so the glob condition decides + // each request on the inspected tunnel. + let handle = engine.l7_handle(engine.current_generation()); + let ctx = L7EvalContext { + host: host.to_string(), + ..ctx() + }; + let (allowed, _) = handle + .evaluate_request(&ctx, &request("GET", "/x")) + .expect("evaluates"); + assert!(allowed, "{host}: GET is allowed"); + let (allowed, _) = handle + .evaluate_request(&ctx, &request("DELETE", "/x")) + .expect("evaluates"); + assert!(!allowed, "{host}: DELETE is denied"); + } + + // A host outside the glob is not allowed to connect at all. + let denied = engine + .authorize_egress(&curl_input("deep.api.example.com")) + .expect("evaluates"); + assert!(matches!(denied.action, NetworkAction::Deny { .. })); + assert!(denied.endpoint_configs.is_empty()); + } + + #[test] + fn exact_endpoint_paths_join_the_matching_glob_paths() { + let policy = format!( + r#"{GLOB_INSPECTED} +@path("/api/**") @enforcement("audit") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when {{ context.path like("/api/**", "/") }}; +"# + ); + let engine = CedarOnlyEngine::from_policy_str(&policy).expect("policy loads"); + + // The exact host gets a config per path of both entries, and the + // relay routes a request to the most specific one. + let configs = parsed_configs(&engine, "api.example.com"); + let paths: Vec<(&str, crate::l7::EnforcementMode)> = configs + .iter() + .map(|config| (config.path.as_str(), config.enforcement)) + .collect(); + assert_eq!( + paths, + vec![ + ("", crate::l7::EnforcementMode::Enforce), + ("/api/**", crate::l7::EnforcementMode::Audit), + ] + ); + let guard = engine.read_engine().expect("engine"); + assert_eq!( + guard.route_request("api.example.com", 443, "/api/items"), + RequestRoute::Routed { + endpoint_path: "/api/**".to_string() + } + ); + assert_eq!( + guard.route_request("api.example.com", 443, "/other"), + RequestRoute::Routed { + endpoint_path: String::new() + } + ); + // Another matching host has only the glob's path. + assert_eq!( + guard.route_request("other.example.com", 443, "/api/items"), + RequestRoute::Routed { + endpoint_path: String::new() + } + ); + drop(guard); + assert_eq!(parsed_configs(&engine, "other.example.com").len(), 1); + } + + #[test] + fn settings_merge_into_glob_inspected_configs() { + let mut glob_setting = setting("*.example.com", ""); + glob_setting.allow_encoded_slash = true; + let mut exact_setting = setting("api.example.com", ""); + exact_setting.tls = openshell_core::proto::NetworkTlsMode::Skip as i32; + let engine = CedarOnlyEngine::from_proto(&settings_policy( + GLOB_INSPECTED, + vec![glob_setting, exact_setting], + )) + .expect("policy loads"); + + let api = engine + .authorize_egress(&curl_input("api.example.com")) + .expect("evaluates") + .endpoint_configs; + assert_eq!(api.len(), 1); + let config = crate::l7::parse_l7_config(&api[0]).expect("inspected"); + assert!( + config.allow_encoded_slash, + "the glob setting applies: {config:?}" + ); + assert_eq!(crate::l7::parse_tls_mode(&api[0]), crate::l7::TlsMode::Skip); + + let other = engine + .authorize_egress(&curl_input("other.example.com")) + .expect("evaluates") + .endpoint_configs; + assert_eq!(other.len(), 1); + let config = crate::l7::parse_l7_config(&other[0]).expect("inspected"); + assert!( + config.allow_encoded_slash, + "the glob setting applies: {config:?}" + ); + assert_eq!( + crate::l7::parse_tls_mode(&other[0]), + crate::l7::TlsMode::Auto, + "the exact-host setting does not reach another host" + ); + } + + #[test] + fn mcp_settings_replace_the_default_revision() { + let mut mcp = setting("*.example.com", ""); + mcp.mcp = Some(openshell_core::proto::McpOptions { + versions: vec!["2026-07-28".to_string(), "2025-06-18".to_string()], + strict_tool_names: Some(false), + ..Default::default() + }); + mcp.json_rpc_max_body_bytes = 4096; + let engine = + CedarOnlyEngine::from_proto(&settings_policy(&protocol_policy("mcp"), vec![mcp])) + .expect("policy loads"); + let configs = parsed_configs(&engine, "api.example.com"); + assert_eq!(configs.len(), 1); + assert_eq!( + configs[0].mcp_versions, + vec![ + openshell_core::mcp::McpProtocolVersion::V2025_06_18, + openshell_core::mcp::McpProtocolVersion::V2026_07_28, + ] + ); + assert!(!configs[0].mcp_strict_tool_names); + assert_eq!(configs[0].json_rpc_max_body_bytes, 4096); + + // Without settings, the pinned default applies. + let default = CedarOnlyEngine::from_policy_str(&protocol_policy("mcp")).expect("loads"); + let configs = parsed_configs(&default, "api.example.com"); + assert_eq!( + configs[0].mcp_versions, + vec![openshell_policy_schema::DEFAULT_MCP_PROTOCOL_VERSION] + ); + assert!(configs[0].mcp_strict_tool_names); + } + + #[test] + fn protocol_settings_apply_only_to_their_protocol() { + let mut graphql = setting("api.example.com", ""); + graphql.graphql_max_body_bytes = 1024; + graphql.persisted_queries = "allow_registered".to_string(); + graphql.mcp = Some(openshell_core::proto::McpOptions { + versions: vec!["2025-06-18".to_string()], + ..Default::default() + }); + let engine = + CedarOnlyEngine::from_proto(&settings_policy(&protocol_policy("rest"), vec![graphql])) + .expect("policy loads"); + let config = engine + .authorize_egress(&curl_input("api.example.com")) + .expect("evaluates") + .endpoint_configs + .remove(0); + assert_eq!( + config.graphql_max_body_bytes, 0, + "graphql_max_body_bytes must not apply to REST: {config:?}" + ); + assert!( + config.persisted_queries.is_empty(), + "persisted_queries must not apply to REST: {config:?}" + ); + assert!( + config.mcp_versions.is_empty(), + "mcp_versions must not apply to REST: {config:?}" + ); + } + + #[test] + fn tls_skip_setting_applies_to_a_connect_only_endpoint() { + let mut skip = setting("api.example.com", ""); + skip.tls = openshell_core::proto::NetworkTlsMode::Skip as i32; + let engine = + CedarOnlyEngine::from_proto(&settings_policy(CONNECT_ONLY, vec![skip.clone()])) + .expect("policy loads"); + let authorization = engine + .authorize_egress(&curl_input("api.example.com")) + .expect("evaluates"); + assert_eq!(authorization.endpoint_configs.len(), 1); + assert!(crate::l7::parse_l7_config(&authorization.endpoint_configs[0]).is_none()); + assert_eq!( + crate::l7::parse_tls_mode(&authorization.endpoint_configs[0]), + crate::l7::TlsMode::Skip + ); + + // The setting reaches a provider config too, which the proxy reads + // first, so the credential guard sees the skipped TLS. + let mut policy = provider_policy(CONNECT_ONLY, vec![credentialed_endpoint()]); + policy.endpoint_settings = vec![skip]; + let engine = CedarOnlyEngine::from_proto(&policy).expect("policy loads"); + let guards = engine + .credential_guards("api.example.com", 443) + .expect("guards"); + assert_eq!(guards.len(), 1); + assert_eq!( + crate::l7::parse_tls_mode(&guards[0]), + crate::l7::TlsMode::Skip + ); + + // A denied connection gets no configs. + let denied = engine + .authorize_egress(&curl_input("other.example.com")) + .expect("evaluates"); + assert!(denied.endpoint_configs.is_empty()); + } + + #[test] + fn path_setting_adds_a_scoped_config_with_cedar_inspection() { + let mut slash = setting("api.example.com", "/repos/**"); + slash.allow_encoded_slash = true; + let engine = CedarOnlyEngine::from_proto(&settings_policy(POLICY, vec![slash])) + .expect("policy loads"); + let configs = parsed_configs(&engine, "api.example.com"); + assert_eq!(configs.len(), 2); + let scoped = configs + .iter() + .find(|config| config.path == "/repos/**") + .expect("scoped config"); + assert!(scoped.allow_encoded_slash); + assert_eq!(scoped.protocol, openshell_policy::L7Protocol::Rest); + assert_eq!(scoped.enforcement, crate::l7::EnforcementMode::Enforce); + let base = configs + .iter() + .find(|config| config.path.is_empty()) + .expect("path-less config"); + assert!(!base.allow_encoded_slash); + } + + #[test] + fn later_settings_override_earlier_ones() { + let mut glob = setting("*.example.com", ""); + glob.graphql_max_body_bytes = 1024; + let mut exact = setting("api.example.com", ""); + exact.graphql_max_body_bytes = 2048; + let engine = CedarOnlyEngine::from_proto(&settings_policy( + &protocol_policy("graphql"), + vec![glob, exact], + )) + .expect("policy loads"); + assert_eq!( + parsed_configs(&engine, "api.example.com")[0].graphql_max_body_bytes, + 2048 + ); + } + + #[test] + fn reload_tracks_endpoint_setting_changes() { + let mut slash = setting("api.example.com", ""); + slash.allow_encoded_slash = true; + let engine = CedarOnlyEngine::from_proto(&settings_policy(POLICY, vec![slash.clone()])) + .expect("policy loads"); + let generation = engine.current_generation(); + let unchanged = engine + .stage(&settings_policy(POLICY, vec![slash]), 0) + .expect("stage"); + engine.commit(unchanged).expect("commit"); + assert_eq!(engine.current_generation(), generation); + + let changed = engine.stage(&policy_from_source(POLICY), 0).expect("stage"); + engine.commit(changed).expect("commit"); + assert_eq!(engine.current_generation(), generation + 1); + assert!(!parsed_configs(&engine, "api.example.com")[0].allow_encoded_slash); + } + + const GRAPHQL_POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +@protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { + context.graphql_operation_type == "query" + && ["viewer"].containsAll(context.graphql_fields) +}; +"#; + + fn registry_setting(mode: &str) -> openshell_core::proto::NetworkEndpoint { + let mut registry = setting("api.example.com", ""); + registry.persisted_queries = mode.to_string(); + registry.graphql_persisted_queries.insert( + "abc123".to_string(), + openshell_core::proto::GraphqlOperation { + operation_type: "query".to_string(), + operation_name: "Viewer".to_string(), + fields: vec!["viewer".to_string()], + }, + ); + registry.graphql_persisted_queries.insert( + "saved-admin".to_string(), + openshell_core::proto::GraphqlOperation { + operation_type: "query".to_string(), + operation_name: "Admin".to_string(), + fields: vec!["admin".to_string()], + }, + ); + registry + } + + fn persisted(hash: Option<&str>, id: Option<&str>) -> L7RequestInfo { + L7RequestInfo { + graphql: Some(crate::l7::graphql::GraphqlRequestInfo { + operations: vec![crate::l7::graphql::GraphqlOperationInfo { + operation_type: String::new(), + operation_name: None, + fields: Vec::new(), + persisted_query: true, + persisted_query_hash: hash.map(str::to_string), + persisted_query_id: id.map(str::to_string), + }], + error: None, + }), + ..request("POST", "/graphql") + } + } + + fn evaluate(engine: &CedarOnlyEngine, request: &L7RequestInfo) -> (bool, String) { + engine + .l7_handle(engine.current_generation()) + .evaluate_request(&ctx(), request) + .expect("request evaluates") + } + + #[test] + fn hash_only_persisted_queries_resolve_through_the_registry() { + let engine = CedarOnlyEngine::from_proto(&settings_policy( + GRAPHQL_POLICY, + vec![registry_setting("allow_registered")], + )) + .expect("policy loads"); + assert!(evaluate(&engine, &persisted(Some("abc123"), None)).0); + // The registered operation, not an empty one, is what Cedar judges. + assert!(!evaluate(&engine, &persisted(None, Some("saved-admin"))).0); + // The hash is the key whenever it is present. + let (allowed, reason) = evaluate(&engine, &persisted(Some("missing"), Some("abc123"))); + assert!(!allowed); + assert_eq!(reason, UNREGISTERED_PERSISTED_QUERY); + } + + #[test] + fn hash_only_persisted_queries_are_denied_without_allow_registered() { + for settings in [vec![registry_setting("deny")], Vec::new()] { + let engine = CedarOnlyEngine::from_proto(&settings_policy(GRAPHQL_POLICY, settings)) + .expect("policy loads"); + let (allowed, reason) = evaluate(&engine, &persisted(Some("abc123"), None)); + assert!(!allowed); + assert_eq!(reason, UNREGISTERED_PERSISTED_QUERY); + } + } + + #[test] + fn invalid_provider_rules_with_owners_fail_to_load() { + let mut endpoint = owned_endpoint("owner", "/a/**", "GET"); + endpoint.protocol = "not-a-protocol".to_string(); + assert!( + CedarOnlyEngine::from_proto(&provider_policy(OWNER_POLICY, vec![endpoint])).is_err() + ); + } + + /// REST under `/api/**`, GraphQL-over-WebSocket at `/ws`, and audited + /// GraphQL at `/graphql`, all on `api.example.com:443`. + const PATHS_POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +@path("/api/**") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.method == "GET" && context.path like("/api/**", "/") }; +@path("/ws") @protocol("websocket-graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { + context.path == "/ws" + && (context.method == "GET" + || (context.method == "WEBSOCKET_TEXT" + && context.graphql_operation_type == "subscription")) +}; +@path("/graphql") @protocol("graphql") @enforcement("audit") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.path == "/graphql" && context.graphql_operation_type == "query" }; +"#; + + #[test] + fn each_cedar_path_gets_its_own_endpoint_config() { + use crate::l7::EnforcementMode::{Audit, Enforce}; + use openshell_policy::L7Protocol::{Graphql, Rest, Websocket}; + + let engine = CedarOnlyEngine::from_policy_str(PATHS_POLICY).expect("policy loads"); + let mut configs: Vec<_> = parsed_configs(&engine, "api.example.com") + .into_iter() + .map(|config| { + ( + config.path, + config.protocol, + config.enforcement, + config.websocket_graphql_policy, + ) + }) + .collect(); + configs.sort_by(|left, right| left.0.cmp(&right.0)); + assert_eq!( + configs, + vec![ + ("/api/**".to_string(), Rest, Enforce, false), + ("/graphql".to_string(), Graphql, Audit, false), + ("/ws".to_string(), Websocket, Enforce, true), + ], + "no path-less config: no policy lacks @path" + ); + } + + #[test] + fn requests_take_the_inspection_of_their_routed_path() { + let engine = CedarOnlyEngine::from_policy_str(PATHS_POLICY).expect("policy loads"); + let evaluate = |request: &L7RequestInfo| { + engine + .l7_handle(engine.current_generation()) + .evaluate_request(&ctx_for("api.example.com"), request) + .expect("request evaluates") + }; + assert!(evaluate(&request("GET", "/api/items")).0); + assert_eq!( + evaluate(&request("GET", "/other")), + (false, UNMATCHED_PATH.to_string()), + "no inspected path matches, as the relay denies it" + ); + assert!(evaluate(&request("GET", "/ws")).0, "the upgrade"); + let message = |operation_type: &str| L7RequestInfo { + graphql: Some(crate::l7::graphql::GraphqlRequestInfo { + operations: vec![crate::l7::graphql::GraphqlOperationInfo { + operation_type: operation_type.to_string(), + operation_name: None, + fields: vec!["messageAdded".to_string()], + persisted_query: false, + persisted_query_hash: None, + persisted_query_id: None, + }], + error: None, + }), + ..request("WEBSOCKET_TEXT", "/ws") + }; + assert!(evaluate(&message("subscription")).0); + assert!(!evaluate(&message("mutation")).0); + // Routed to the audit path, the denial is reported for the relay to + // log and forward. + let mutation = L7RequestInfo { + graphql: message("mutation").graphql, + ..request("POST", "/graphql") + }; + assert!(!evaluate(&mutation).0); + } + + fn ctx_for(host: &str) -> L7EvalContext { + L7EvalContext { + host: host.to_string(), + ..ctx() + } + } + + #[test] + fn provider_endpoints_take_the_inspection_of_their_path() { + let mut ws = credentialed_endpoint(); + ws.path = "/ws".to_string(); + let mut unrelated = credentialed_endpoint(); + unrelated.path = "/elsewhere/**".to_string(); + let engine = + CedarOnlyEngine::from_proto(&provider_policy(PATHS_POLICY, vec![ws, unrelated])) + .expect("policy loads"); + let configs = engine + .credential_guards("api.example.com", 443) + .expect("configs build"); + let provider = |path: &str| { + configs + .iter() + .find(|config| { + config.path == path + && crate::l7::parse_endpoint_credential_guard(config).provider_credentialed + }) + .expect("provider config") + }; + let ws = crate::l7::parse_l7_config(provider("/ws")).expect("inspected"); + assert_eq!(ws.protocol, openshell_policy::L7Protocol::Websocket); + assert!(ws.websocket_graphql_policy); + // No Cedar path covers the provider path, so its config is not an L7 + // config and the relay never routes requests to it. + assert!(crate::l7::parse_l7_config(provider("/elsewhere/**")).is_none()); + assert_eq!( + configs.len(), + 4, + "two provider configs plus /api/** and /graphql" + ); + } + + #[test] + fn path_settings_follow_cedar_inspection() { + let mut registry = setting("api.example.com", "/ws"); + registry.persisted_queries = "allow_registered".to_string(); + let mut uninspected = setting("api.example.com", "/elsewhere/**"); + uninspected.allow_encoded_slash = true; + let mut nested = setting("api.example.com", "/api/v2/**"); + nested.allow_encoded_slash = true; + let engine = CedarOnlyEngine::from_proto(&settings_policy( + PATHS_POLICY, + vec![registry, uninspected, nested], + )) + .expect("policy loads"); + let configs = parsed_configs(&engine, "api.example.com"); + let paths: Vec<&str> = configs.iter().map(|config| config.path.as_str()).collect(); + assert_eq!(paths, ["/api/**", "/graphql", "/ws", "/api/v2/**"]); + let nested = &configs[3]; + assert_eq!(nested.protocol, openshell_policy::L7Protocol::Rest); + assert!(nested.allow_encoded_slash); + } + + #[test] + fn plain_websocket_endpoints_take_no_graphql_settings() { + let mut registry = setting("api.example.com", ""); + registry.persisted_queries = "allow_registered".to_string(); + let engine = CedarOnlyEngine::from_proto(&settings_policy( + &protocol_policy("websocket"), + vec![registry], + )) + .expect("policy loads"); + let configs = parsed_configs(&engine, "api.example.com"); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].protocol, openshell_policy::L7Protocol::Websocket); + assert!( + !configs[0].websocket_graphql_policy, + "a persisted-query setting must not turn on GraphQL message policy" + ); + } + + const QUERY_POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.query.hasTag("tag") && context.query.getTag("tag") like "foo-*" }; +"#; + + fn with_query(query: &[(&str, &[&str])]) -> L7RequestInfo { + L7RequestInfo { + query_params: query + .iter() + .map(|(key, values)| { + ( + (*key).to_string(), + values.iter().map(ToString::to_string).collect(), + ) + }) + .collect(), + ..request("GET", "/download") + } + } + + #[test] + fn query_params_reach_cedar_decoded() { + let engine = CedarOnlyEngine::from_policy_str(QUERY_POLICY).expect("policy parses"); + let tunnel = engine.l7_handle(engine.current_generation()); + let decide = |query: &[(&str, &[&str])]| { + tunnel + .evaluate_request(&ctx(), &with_query(query)) + .expect("request evaluates") + }; + assert!(decide(&[("tag", &["foo-a", "foo-b"])]).0); + assert!(!decide(&[("tag", &["foo-a", "evil"])]).0); + assert!(!decide(&[]).0); + + // Through the relay's parser, which decodes keys and values. + let (_, parsed) = + crate::l7::rest::parse_target_query("/download?t%61g=foo%2Da&tag=foo-x+y") + .expect("target parses"); + let mut request = request("GET", "/download"); + request.query_params = parsed; + assert!(tunnel.evaluate_request(&ctx(), &request).unwrap().0); + } + + #[test] + fn query_over_the_combination_cap_is_denied_with_a_reason() { + let engine = CedarOnlyEngine::from_policy_str(QUERY_POLICY).expect("policy parses"); + let tunnel = engine.l7_handle(engine.current_generation()); + let values: Vec = (0..=openshell_policy_cedar::MAX_QUERY_COMBINATIONS) + .map(|value| format!("foo-{value}")) + .collect(); + let values: Vec<&str> = values.iter().map(String::as_str).collect(); + let (allowed, reason) = tunnel + .evaluate_request(&ctx(), &with_query(&[("tag", &values)])) + .expect("an over-cap query is a decision, not an error"); + assert!(!allowed); + assert!(reason.contains("value combinations"), "{reason}"); + let (allowed, _) = tunnel + .evaluate_request(&ctx(), &with_query(&[("tag", &values[1..])])) + .unwrap(); + assert!(allowed, "the cap itself is evaluated"); + } + + /// A connect permit and an HTTP forbid that name `/usr/bin/python3` + /// through its symlink aliases only. + const ALIAS_POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.binary_aliases.contains("/usr/bin/python3") }; + +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); + +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.binary_aliases.contains("/usr/bin/python3") && context.method == "DELETE" }; +"#; + + fn alias_set(entries: &[(&str, &str)]) -> BinaryAliases { + BinaryAliases( + entries + .iter() + .map(|(path, target)| { + ((*path).to_string(), AliasTarget::new((*target).to_string())) + }) + .collect(), + ) + } + + #[test] + fn aliases_match_the_binary_or_an_ancestor_exactly() { + let aliases = alias_set(&[ + ("/usr/bin/python3", "/usr/bin/python3.11"), + ("/usr/bin/node", "/opt/node/bin/node"), + ]); + let matching = |binary: &str, ancestors: &[&str]| { + let ancestors: Vec = ancestors.iter().map(ToString::to_string).collect(); + aliases.matching(binary, &ancestors).expect("matches") + }; + assert_eq!(matching("/usr/bin/python3.11", &[]), ["/usr/bin/python3"]); + assert_eq!( + matching("/usr/bin/curl", &["/bin/bash", "/opt/node/bin/node"]), + ["/usr/bin/node"] + ); + // The symlink itself and near misses are not aliases. + assert!(matching("/usr/bin/python3", &[]).is_empty()); + assert!(matching("/usr/bin/python3.1", &["/usr/bin/python3.11x"]).is_empty()); + } + + #[test] + fn a_glob_target_matches_as_the_rego_rules_match_it() { + let aliases = alias_set(&[("/usr/bin/tool", "/opt/*/tool")]); + let matching = |binary: &str| aliases.matching(binary, &[]).expect("matches"); + assert_eq!(matching("/opt/v2/tool"), ["/usr/bin/tool"]); + // `*` does not cross `/`, as in `glob.match(path, ["/"], p)`. + assert!(matching("/opt/v2/bin/tool").is_empty()); + + let invalid = alias_set(&[("/usr/bin/tool", "/opt/[*/tool")]); + assert!(invalid.matching("/opt/v2/tool", &[]).is_err()); + } + + #[test] + fn requests_carry_the_aliases_of_their_binary() { + let engine = CedarOnlyEngine::from_policy_str(ALIAS_POLICY).expect("policy loads"); + let python = NetworkInput { + binary_path: "/usr/bin/python3.11".into(), + ..curl_input("api.example.com") + }; + let allowed = |engine: &CedarOnlyEngine| { + matches!( + engine.authorize_egress(&python).expect("evaluates").action, + NetworkAction::Allow { .. } + ) + }; + assert!(!allowed(&engine), "no alias before resolution"); + + engine.write_engine().expect("lock").binary_aliases = + alias_set(&[("/usr/bin/python3", "/usr/bin/python3.11")]); + assert!(allowed(&engine)); + let python_ctx = L7EvalContext { + binary_path: "/usr/bin/python3.11".to_string(), + ..ctx() + }; + let evaluate = |method: &str| { + engine + .l7_handle(engine.current_generation()) + .evaluate_request(&python_ctx, &request(method, "/v1/status")) + .expect("evaluates") + .0 + }; + assert!(evaluate("GET")); + assert!(!evaluate("DELETE"), "the forbid reaches the alias"); + let curl = engine + .l7_handle(engine.current_generation()) + .evaluate_request(&ctx(), &request("DELETE", "/v1/status")) + .expect("evaluates") + .0; + assert!(curl, "another binary has no alias"); + } + + /// A connect permit and an HTTP forbid for the YAML binary glob + /// `/usr/lib/node_modules/@openai/**`. + const GLOB_POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.binary_globs.contains("/usr/lib/node_modules/@openai/**") }; + +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); + +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.binary_globs.contains("/opt/*/tool") && context.method == "DELETE" }; +"#; + + #[test] + fn binary_globs_match_the_binary_or_an_ancestor_as_the_rego_rules_do() { + let engine = CedarOnlyEngine::from_policy_str(GLOB_POLICY).expect("policy loads"); + let globs = &engine.read_engine().expect("lock").binary_globs; + let matching = |binary: &str, ancestors: &[&str]| { + let ancestors: Vec = ancestors.iter().map(ToString::to_string).collect(); + globs.matching(binary, &ancestors).expect("matches") + }; + assert_eq!( + matching("/usr/lib/node_modules/@openai/codex/bin/codex.js", &[]), + ["/usr/lib/node_modules/@openai/**"] + ); + assert_eq!( + matching( + "/usr/bin/node", + &["/bin/sh", "/usr/lib/node_modules/@openai/codex"] + ), + ["/usr/lib/node_modules/@openai/**"] + ); + // `*` does not cross `/`, as in `glob.match(path, ["/"], p)`. + assert_eq!(matching("/opt/v2/tool", &[]), ["/opt/*/tool"]); + assert!(matching("/opt/v2/bin/tool", &[]).is_empty()); + assert!(matching("/usr/lib/node_modules/@other/x", &["/usr/bin/node"]).is_empty()); + } + + #[test] + fn requests_carry_the_globs_matching_their_binary() { + let engine = CedarOnlyEngine::from_policy_str(GLOB_POLICY).expect("policy loads"); + let connect = |binary: &str, ancestors: &[&str]| { + let input = NetworkInput { + binary_path: binary.into(), + ancestors: ancestors.iter().map(std::path::PathBuf::from).collect(), + ..curl_input("api.example.com") + }; + matches!( + engine.authorize_egress(&input).expect("evaluates").action, + NetworkAction::Allow { .. } + ) + }; + assert!(connect( + "/usr/bin/node", + &["/usr/lib/node_modules/@openai/codex/bin/codex.js"] + )); + assert!(!connect("/usr/bin/node", &["/usr/bin/bash"])); + + let tool_ctx = L7EvalContext { + binary_path: "/usr/bin/curl".to_string(), + ancestors: vec!["/opt/v1/tool".to_string()], + ..ctx() + }; + let evaluate = |ctx: &L7EvalContext, method: &str| { + engine + .l7_handle(engine.current_generation()) + .evaluate_request(ctx, &request(method, "/v1/status")) + .expect("evaluates") + .0 + }; + assert!(evaluate(&tool_ctx, "GET")); + assert!( + !evaluate(&tool_ctx, "DELETE"), + "the forbid reaches the ancestor" + ); + assert!( + evaluate(&ctx(), "DELETE"), + "another process matches no glob" + ); + } + + #[test] + fn token_grant_binary_globs_match_ancestors() { + // The provider rule names a binary glob; a YAML sandbox admits the + // owner for a process whose ancestor the glob matches. + let mut policy = provider_policy( + OWNER_POLICY, + vec![owned_endpoint("owner-broad", "/a/**", "GET")], + ); + policy + .provider_credential_rules + .get_mut("_provider_work") + .expect("provider rule") + .binaries + .push(openshell_core::proto::NetworkBinary { + path: "/opt/tools/*".to_string(), + }); + let engine = CedarOnlyEngine::from_proto(&policy).expect("policy loads"); + assert_eq!( + engine + .read_engine() + .expect("lock") + .binary_globs + .matching("/usr/bin/curl", &["/opt/tools/run".to_string()]) + .expect("matches"), + ["/opt/tools/*"] + ); + let owners = |ancestors: &[&str]| { + let ctx = L7EvalContext { + ancestors: ancestors.iter().map(ToString::to_string).collect(), + ..ctx() + }; + engine + .l7_handle(engine.current_generation()) + .admitted_token_grant_owners(&ctx, &request("GET", "/a/item")) + .expect("owners evaluate") + .into_iter() + .collect::>() + }; + assert_eq!(owners(&["/opt/tools/run"]), ["owner-broad"]); + assert!(owners(&["/usr/bin/bash"]).is_empty()); + } + + #[test] + fn requests_parsed_as_graphql_are_flagged_for_cedar() { + // A broad REST permit never allows a GraphQL operation on the + // GraphQL path, as a YAML GraphQL endpoint is authoritative for the + // requests it parses; the same request without GraphQL inspection + // sees the REST permit. + let engine = CedarOnlyEngine::from_policy_str( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +@path("/**") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.method == "GET" }; +@path("/graphql") @protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.path == "/graphql" && context.graphql_operation_type == "query" }; +"#, + ) + .expect("policy loads"); + let evaluate = |request: &L7RequestInfo| { + engine + .l7_handle(engine.current_generation()) + .evaluate_request(&ctx(), request) + .expect("evaluates") + .0 + }; + let operation = |operation_type: &str| L7RequestInfo { + graphql: Some(crate::l7::graphql::GraphqlRequestInfo { + operations: vec![crate::l7::graphql::GraphqlOperationInfo { + operation_type: operation_type.to_string(), + operation_name: None, + fields: vec!["viewer".to_string()], + persisted_query: false, + persisted_query_hash: None, + persisted_query_id: None, + }], + error: None, + }), + ..request("GET", "/graphql") + }; + assert!(evaluate(&operation("query"))); + assert!( + !evaluate(&operation("mutation")), + "the REST permit is left out" + ); + assert!( + evaluate(&request("GET", "/graphql")), + "not parsed as GraphQL" + ); + assert!(evaluate(&request("GET", "/other"))); + } + + #[test] + fn audit_policies_decide_requests_routed_to_an_enforced_path() { + // YAML applies the rules of every matching endpoint and the selected + // endpoint's enforcement decides whether a denial blocks: the audit + // path's forbid blocks on the enforced path and is forwarded on its + // own path. + let engine = CedarOnlyEngine::from_policy_str( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +@path("/**") @enforcement("audit") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.method == "GET" }; +@path("/**") @enforcement("audit") +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.method == "DELETE" }; +@path("/api/**") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { ["DELETE", "POST"].contains(context.method) && context.path like("/api/**", "/") }; +@path("/api/**") @enforcement("staged") +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.method == "POST" }; +"#, + ) + .expect("policy loads"); + let configs: HashMap = + parsed_configs(&engine, "api.example.com") + .into_iter() + .map(|config| (config.path, config.enforcement)) + .collect(); + assert_eq!(configs["/**"], crate::l7::EnforcementMode::Audit); + assert_eq!(configs["/api/**"], crate::l7::EnforcementMode::Enforce); + let evaluate = |method: &str, target: &str| { + engine + .l7_handle(engine.current_generation()) + .evaluate_request(&ctx(), &request(method, target)) + .expect("evaluates") + .0 + }; + assert!(!evaluate("DELETE", "/api/item"), "the audit forbid decides"); + assert!(evaluate("GET", "/api/item"), "the audit permit decides"); + assert!( + evaluate("POST", "/api/item"), + "the staged forbid never decides" + ); + assert!( + !evaluate("DELETE", "/other"), + "forwarded by the relay on the audit path" + ); + } + + #[test] + fn the_entrypoint_pid_is_kept_across_reloads() { + const PID: u32 = 4242; + let engine = CedarOnlyEngine::from_policy_str(ALIAS_POLICY).expect("policy loads"); + let generation = engine.current_generation(); + engine.resolve_binary_symlinks(0).expect("resolves"); + assert_eq!(engine.current_generation(), generation, "0 is a no-op"); + + engine.resolve_binary_symlinks(PID).expect("resolves"); + assert_eq!(engine.current_generation(), generation + 1); + engine.resolve_binary_symlinks(PID).expect("resolves"); + assert_eq!(engine.current_generation(), generation + 1, "same pid"); + + // A reload that does not know the pid resolves for the known one. + engine + .reload_from_policy_str(POLICY) + .expect("reload succeeds"); + assert_eq!(engine.read_engine().expect("lock").entrypoint_pid, PID); + } + + #[test] + fn a_policy_staged_before_the_pid_is_resolved_on_commit() { + const PID: u32 = 4242; + let engine = CedarOnlyEngine::from_policy_str(POLICY).expect("policy loads"); + let changed = engine + .stage(&policy_from_source(ALIAS_POLICY), 0) + .expect("stage"); + engine.resolve_binary_symlinks(PID).expect("resolves"); + engine.commit(changed).expect("commit"); + assert_eq!(engine.read_engine().expect("lock").entrypoint_pid, PID); + + // An unchanged policy staged with a new pid resolves the active one. + let other = CedarOnlyEngine::from_policy_str(ALIAS_POLICY).expect("policy loads"); + let generation = other.current_generation(); + let unchanged = other + .stage(&policy_from_source(ALIAS_POLICY), PID) + .expect("stage"); + other.commit(unchanged).expect("commit"); + assert_eq!(other.read_engine().expect("lock").entrypoint_pid, PID); + assert_eq!(other.current_generation(), generation + 1); + } + + /// Resolves a real symlink through `/proc//root`, as for YAML. + #[cfg(target_os = "linux")] + #[test] + fn resolution_finds_symlinked_policy_binaries() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("python3.11"); + let link = dir.path().join("python3"); + std::fs::write(&target, b"python").expect("target"); + symlink(&target, &link).expect("symlink"); + let pid = std::process::id(); + if crate::binary_resolution::resolve_policy_binary(&link.to_string_lossy(), pid).is_none() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let policy = ALIAS_POLICY.replace("/usr/bin/python3", &link.to_string_lossy()); + let engine = CedarOnlyEngine::from_policy_str(&policy).expect("policy loads"); + let allowed = |binary: &std::path::Path| { + let input = NetworkInput { + binary_path: binary.to_path_buf(), + ..curl_input("api.example.com") + }; + matches!( + engine.authorize_egress(&input).expect("evaluates").action, + NetworkAction::Allow { .. } + ) + }; + assert!(!allowed(&target), "denied before the pid is known"); + engine.resolve_binary_symlinks(pid).expect("resolves"); + assert!(allowed(&target), "allowed once the symlink resolves"); + + // A reload of the same policy resolves the link again, as a YAML + // reload does, so a retargeted link loses its old alias. + let retarget = dir.path().join("python3.12"); + std::fs::write(&retarget, b"python").expect("new target"); + std::fs::remove_file(&link).expect("remove link"); + symlink(&retarget, &link).expect("symlink"); + let generation = engine.current_generation(); + let unchanged = engine + .stage(&policy_from_source(&policy), pid) + .expect("stage"); + engine.commit(unchanged).expect("commit"); + assert_eq!(engine.current_generation(), generation + 1); + assert!(!allowed(&target), "the old target is no longer an alias"); + assert!(allowed(&retarget), "the new target is"); + } +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/examples.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/examples.rs new file mode 100644 index 0000000000..306b34b464 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/examples.rs @@ -0,0 +1,1138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Parity of the repository's example YAML policies with their Cedar versions. +//! +//! Each example YAML policy has a `.cedar` file beside it (and a middleware +//! file when the YAML has `network_middlewares`, endpoint settings, or a +//! `process` section). These tests load both from +//! disk the way the CLI loads them, rendering templates with the sample values +//! their scripts substitute, so a broken example fails here. +//! +//! The network probes are generated from the YAML structure: for every +//! endpoint, connections from each listed binary (directly and as an +//! ancestor), from an unlisted binary, to another port and an unlisted host, +//! policy DNS eligibility and answers, destination address checks, the +//! selected inspection and endpoint settings, and for inspected endpoints a +//! request for every method on paths derived from each rule's path glob, plus +//! a relayed exchange. The tests also compare the effective Landlock grants, +//! Landlock compatibility, process identity, and the middleware chain each +//! engine selects. + +use std::collections::BTreeSet; + +use openshell_core::proto::SandboxPolicy; + +use super::*; + +/// The workdir `include_workdir` grants in the default sandbox image. +const WORKDIR: &str = "/sandbox"; + +/// A binary no example policy lists. +const UNLISTED_BINARY: &str = "/usr/bin/unlisted-tool"; + +/// A process that no example lists by name, used as the child of a listed +/// binary to exercise ancestor matching. +const CHILD_BINARY: &str = "/usr/bin/child-process"; + +/// A host no example policy lists. +const UNLISTED_HOST: &str = "unlisted.example.net"; + +/// Methods every inspected endpoint is probed with. The lowercase spelling +/// checks that both engines compare methods case-insensitively. +const METHODS: &[&str] = &[ + "GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE", "get", +]; + +/// Read-only and read-write baseline paths the supervisor adds for proxy-mode +/// sandboxes; `PROXY_BASELINE_READ_ONLY` and `PROXY_BASELINE_READ_WRITE` in +/// `openshell-supervisor`, which depends on this crate. +const BASELINE_READ_ONLY: &[&str] = &[ + "/usr", + "/lib", + "/etc", + "/app", + "/var/log", + "/proc", + "/dev/urandom", +]; +const BASELINE_READ_WRITE: &[&str] = &["/tmp", "/dev/null"]; + +/// Sample values the multi-agent notepad demo substitutes into its template. +const NOTEPAD_VALUES: &[(&str, &str)] = &[ + ("__OWNER__", "octo-org"), + ("__REPO__", "notepad"), + ("__RUN_ID__", "20260101-120000"), +]; + +// Known differences. Each matches a difference documented in +// docs/how-it-works/policies/cedar.mdx. + +/// Cedar has no `process` section; a middleware file may set one. +const PROCESS_IDENTITY: &str = "a Cedar policy carries no process section and the example's middleware \ + file does not set one; the compute driver selects the identity, as for a YAML policy that \ + omits it"; + +/// One example: its YAML policy, its Cedar version, and its middleware file. +struct Example { + name: &'static str, + yaml: String, + cedar: String, + middleware: Option, + /// The sandbox workdir, which YAML `include_workdir` grants. + workdir: &'static str, + /// A provider profile the example attaches. The gateway adds its rules + /// to a YAML policy's network policies, and gives them to a Cedar + /// sandbox as credential rules only. + provider: Option<&'static str>, +} + +impl Example { + fn new(name: &'static str, yaml: &str, cedar: &str) -> Self { + Self { + name, + yaml: yaml.to_string(), + cedar: cedar.to_string(), + middleware: None, + workdir: WORKDIR, + provider: None, + } + } + + fn with_middleware(self, middleware: &str) -> Self { + Self { + middleware: Some(middleware.to_string()), + ..self + } + } + + /// Renders every file of a template, replacing each `from` with `to`, + /// as its script does. + fn rendered(self, values: &[(&str, &str)]) -> Self { + let render = |text: &str| { + values + .iter() + .fold(text.to_string(), |text, (from, to)| text.replace(from, to)) + }; + let example = Self { + yaml: render(&self.yaml), + cedar: render(&self.cedar), + middleware: self.middleware.as_deref().map(render), + ..self + }; + let unrendered = |text: &str| { + text.lines().any(|line| { + let line = line.trim_start(); + !line.starts_with("//") + && !line.starts_with('#') + && (line.contains("__") || line.contains("${")) + }) + }; + assert!( + !unrendered(&example.yaml) + && !unrendered(&example.cedar) + && !example.middleware.as_deref().is_some_and(unrendered), + "{}: every placeholder is rendered", + example.name + ); + example + } +} + +/// Both policies of an example, loaded as the CLI loads them. +struct Loaded { + yaml: SandboxPolicy, + cedar: SandboxPolicy, + engines: Engines, +} + +impl Loaded { + fn new(example: &Example) -> Self { + let mut yaml = openshell_policy::parse_sandbox_policy(&example.yaml) + .unwrap_or_else(|error| panic!("{}: YAML policy parses: {error:?}", example.name)); + let dir = tempfile::tempdir().expect("temporary directory"); + let path = dir.path().join("policy.cedar"); + std::fs::write(&path, &example.cedar).expect("write Cedar policy"); + // The CLI's client-side loader validates the Cedar policy. + let mut cedar = openshell_policy::parse_sandbox_policy_file_auto(&path) + .unwrap_or_else(|error| panic!("{}: Cedar policy loads: {error:?}", example.name)); + assert_eq!(cedar.cedar_policy_source, example.cedar); + if let Some(middleware) = &example.middleware { + let path = dir.path().join("middleware.yaml"); + std::fs::write(&path, middleware).expect("write middleware file"); + openshell_policy::apply_middleware_file(&mut cedar, &path).unwrap_or_else(|error| { + panic!("{}: middleware file applies: {error:?}", example.name) + }); + openshell_policy::validate_sandbox_policy(&cedar).unwrap_or_else(|errors| { + panic!( + "{}: policy with middleware validates: {errors:?}", + example.name + ) + }); + } + if let Some(profile) = example.provider { + let profile = openshell_providers::parse_profile_yaml(profile) + .unwrap_or_else(|error| panic!("{}: profile parses: {error}", example.name)); + let rule = provider_rules::composed_rule(&profile); + yaml.network_policies + .insert(rule.name.clone(), rule.clone()); + cedar + .provider_credential_rules + .insert(rule.name.clone(), rule); + } + let opa = OpaEngine::from_proto(&yaml) + .unwrap_or_else(|error| panic!("{}: YAML engine loads: {error}", example.name)); + let engine = CedarOnlyEngine::from_proto(&cedar) + .unwrap_or_else(|error| panic!("{}: Cedar engine loads: {error}", example.name)); + Self { + yaml, + cedar, + engines: Engines::new(opa, engine), + } + } +} + +/// One YAML endpoint, flattened with its policy's binaries. +struct Endpoint { + policy: String, + host: String, + ports: Vec, + protocol: String, + /// The endpoint `path`, which selects the requests it inspects. + path: String, + allowed_ips: Vec, + binaries: Vec, + /// The path glob of each allow rule and deny rule, with access presets + /// expanded as the YAML engine expands them. Requests probe each with + /// every method in [`METHODS`]. + rule_paths: Vec, +} + +/// Reads every endpoint of a YAML policy. +fn endpoints(name: &str, yaml: &str) -> Vec { + let mut data: serde_json::Value = serde_yml::from_str(yaml).expect("YAML parses as data"); + let warnings = crate::l7::expand_access_presets(&mut data); + assert!(warnings.is_empty(), "{name}: {warnings:?}"); + let Some(policies) = data["network_policies"].as_object() else { + return Vec::new(); + }; + let strings = |value: &serde_json::Value| -> Vec { + value + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }; + let mut keys: Vec<&String> = policies.keys().collect(); + keys.sort(); + let mut endpoints = Vec::new(); + for key in keys { + let policy = &policies[key]; + let binaries: Vec = policy["binaries"] + .as_array() + .into_iter() + .flatten() + .filter_map(|binary| binary["path"].as_str().map(str::to_string)) + .collect(); + for endpoint in policy["endpoints"].as_array().into_iter().flatten() { + let mut ports: Vec = endpoint["ports"] + .as_array() + .into_iter() + .flatten() + .filter_map(|port| port.as_u64().and_then(|port| u16::try_from(port).ok())) + .collect(); + if let Some(port) = endpoint["port"].as_u64() { + ports.push(u16::try_from(port).expect("port fits in u16")); + } + let protocol = endpoint["protocol"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!( + ["", "tcp", "rest", "mcp"].contains(&protocol.as_str()), + "{name}: add probes for protocol {protocol}" + ); + let mut rule_paths = Vec::new(); + for rule in endpoint["rules"].as_array().into_iter().flatten() { + assert!( + rule["allow"].get("query").is_none(), + "{name}: add query probes" + ); + rule_paths.push(rule["allow"]["path"].as_str().unwrap_or("**").to_string()); + } + for rule in endpoint["deny_rules"].as_array().into_iter().flatten() { + assert!(rule.get("query").is_none(), "{name}: add query probes"); + rule_paths.push(rule["path"].as_str().unwrap_or("**").to_string()); + } + endpoints.push(Endpoint { + policy: key.clone(), + host: endpoint["host"].as_str().unwrap_or_default().to_string(), + ports, + protocol, + path: endpoint["path"].as_str().unwrap_or_default().to_string(), + allowed_ips: strings(&endpoint["allowed_ips"]), + binaries: binaries.clone(), + rule_paths, + }); + } + } + endpoints +} + +/// A binary path a YAML binary glob matches, or the path itself. +fn binary_sample(binary: &str) -> String { + binary.replace("**", "sample/bin").replace('*', "sample") +} + +/// A request path a YAML rule path glob matches. +fn path_inside(pattern: &str) -> String { + if pattern == "**" { + "/any/deep/path".to_string() + } else { + pattern.replace("**", "deep/er").replace('*', "seg") + } +} + +/// Request paths inside and just outside a YAML rule path glob. +fn path_samples(pattern: &str) -> BTreeSet { + let mut samples = BTreeSet::from([path_inside(pattern)]); + if pattern == "**" { + return samples; + } + if let Some(prefix) = pattern.strip_suffix("/**") { + samples.insert(prefix.to_string()); + samples.insert(format!("{prefix}/")); + samples.insert(format!("{prefix}-sibling")); + samples.insert(format!("{prefix}/a/b/c")); + } else if pattern.contains('*') { + // An empty match, and a match that would cross a segment. + samples.insert(pattern.replace('*', "")); + samples.insert(pattern.replace('*', "seg/extra")); + } else { + samples.insert(format!("{pattern}/extra")); + samples.insert(format!("{pattern}x")); + if let Some((parent, _)) = pattern.rsplit_once('/') + && !parent.is_empty() + { + samples.insert(parent.to_string()); + } + } + samples +} + +/// An address inside `range`, a few hosts past its network address. +fn address_in(range: &str) -> IpAddr { + let net: ipnet::IpNet = range.parse().expect("allowed_ips entry is a CIDR range"); + net.hosts().nth(4).expect("range has hosts") +} + +fn public_address() -> IpAddr { + IpAddr::from([8, 8, 8, 8]) +} + +fn private_address() -> IpAddr { + IpAddr::from([100, 64, 0, 9]) +} + +fn metadata_address() -> IpAddr { + IpAddr::from([169, 254, 169, 254]) +} + +fn loopback_address() -> IpAddr { + IpAddr::from([127, 0, 0, 1]) +} + +/// A raw HTTP/1 request that closes the connection. +fn http_request(method: &str, path: &str, host: &str) -> String { + format!( + "{method} {path} HTTP/1.1\r\nHost: {host}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) +} + +/// Cases being generated, with the example's known differences. +struct Cases<'a> { + known: &'a dyn Fn(&str) -> Option<&'static str>, + list: Vec, +} + +impl Cases<'_> { + /// Adds a case, recording the known difference for its name, if any. + fn add(&mut self, name: String, probe: Probe) { + let reason = (self.known)(&name); + self.add_known(name, probe, reason); + } + + fn add_known(&mut self, name: String, probe: Probe, reason: Option<&'static str>) { + self.list.push(match reason { + Some(reason) => diverges_probe(name, probe, reason), + None => same_probe(name, probe), + }); + } +} + +/// Generates the network cases of one endpoint. +fn endpoint_cases(endpoint: &Endpoint, cases: &mut Cases<'_>) { + let host = endpoint.host.as_str(); + let samples: Vec = endpoint + .binaries + .iter() + .map(|binary| binary_sample(binary)) + .collect(); + // A process every engine should treat as allowed, if any binary is listed. + let allowed = samples + .first() + .cloned() + .unwrap_or_else(|| BINARY.to_string()); + let answers: Vec = vec![ + endpoint + .allowed_ips + .first() + .map_or_else(|| TRANSPARENT_UPSTREAM.into(), |range| address_in(range)), + ]; + for &port in &endpoint.ports { + let at = format!("{}: {host}:{port}", endpoint.policy); + for sample in &samples { + cases.add( + format!("{at} connect from {sample}"), + Probe::Connect(network(host, port, sample)), + ); + cases.add( + format!("{at} connect from a child of {sample}"), + Probe::Connect(network_with_ancestors(host, port, CHILD_BINARY, &[sample])), + ); + } + let input = || network(host, port, &allowed); + cases.add( + format!("{at} connect from an unlisted binary"), + Probe::Connect(network(host, port, UNLISTED_BINARY)), + ); + cases.add( + format!("{at} other port"), + Probe::Connect(network(host, port.wrapping_add(1), &allowed)), + ); + cases.add( + format!("{at} unlisted host"), + Probe::Connect(network(UNLISTED_HOST, port, &allowed)), + ); + cases.add(format!("{at} exact host"), Probe::ExactHost(input())); + cases.add(format!("{at} inspection"), Probe::Inspection(input())); + cases.add(format!("{at} endpoint configs"), Probe::Configs(input())); + cases.add(format!("{at} TLS mode"), Probe::TlsMode(input())); + cases.add( + format!("{at} matched DNS records"), + Probe::MatchedEndpoints(input()), + ); + cases.add( + format!("{at} DNS eligible"), + Probe::DnsEligible { + name: host.to_string(), + port, + }, + ); + cases.add( + format!("{at} DNS eligible on another port"), + Probe::DnsEligible { + name: host.to_string(), + port: port.wrapping_add(1), + }, + ); + let mut addresses = vec![ + ("public", public_address()), + ("private", private_address()), + ("metadata", metadata_address()), + ("loopback", loopback_address()), + ]; + for range in &endpoint.allowed_ips { + addresses.push(("in allowed_ips", address_in(range))); + } + for (kind, address) in addresses { + cases.add( + format!("{at} destination {kind} {address}"), + Probe::Destination { + input: input(), + addresses: vec![address], + }, + ); + cases.add( + format!("{at} DNS answer {kind} {address}"), + Probe::DnsAnswers { + name: host.to_string(), + port, + answers: vec![address], + }, + ); + } + cases.add( + format!("{at} transparent open"), + Probe::TransparentOpen { + name: host.to_string(), + port, + binary: allowed.clone(), + answers: answers.clone(), + }, + ); + cases.add( + format!("{at} transparent open from an unlisted binary"), + Probe::TransparentOpen { + name: host.to_string(), + port, + binary: UNLISTED_BINARY.to_string(), + answers: answers.clone(), + }, + ); + match endpoint.protocol.as_str() { + "rest" => request_cases(endpoint, port, &allowed, cases), + "mcp" => mcp_cases(endpoint, port, &allowed, cases), + _ => {} + } + } +} + +/// Generates the request cases of an inspected REST endpoint on `port`. +fn request_cases(endpoint: &Endpoint, port: u16, allowed: &str, cases: &mut Cases<'_>) { + let host = endpoint.host.as_str(); + let at = format!("{}: {host}:{port}", endpoint.policy); + let mut paths: BTreeSet = endpoint + .rule_paths + .iter() + .flat_map(|pattern| path_samples(pattern)) + .collect(); + paths.insert("/".to_string()); + paths.insert("/unlisted/path".to_string()); + for path in &paths { + for method in METHODS { + cases.add( + format!("{at} {method} {path}"), + request_in(l7_ctx(host, port, allowed), rest(method, path)), + ); + } + } + let first = endpoint + .rule_paths + .first() + .map_or_else(|| "/".to_string(), |pattern| path_inside(pattern)); + for binary in &endpoint.binaries { + let sample = binary_sample(binary); + cases.add( + format!("{at} GET {first} from {sample}"), + request_in(l7_ctx(host, port, &sample), rest("GET", &first)), + ); + let child = L7EvalContext { + ancestors: vec![sample.clone()], + ..l7_ctx(host, port, CHILD_BINARY) + }; + cases.add( + format!("{at} GET {first} from a child of {sample}"), + request_in(child, rest("GET", &first)), + ); + } + cases.add( + format!("{at} GET {first} from an unlisted binary"), + request_in(l7_ctx(host, port, UNLISTED_BINARY), rest("GET", &first)), + ); + // Token-grant owners: attached providers' credentials apply only where + // both engines admit the provider's endpoint. + for method in ["GET", "POST", "DELETE"] { + cases.add( + format!("{at} token-grant owners for {method} {first}"), + owners_in(l7_ctx(host, port, allowed), rest(method, &first)), + ); + } + for (method, path) in [("GET", first.as_str()), ("DELETE", "/unlisted/path")] { + cases.add( + format!("{at} relayed {method} {path}"), + Probe::Relay(Box::new(RelayExchange { + ctx: l7_ctx(host, port, allowed), + route_selected: false, + request: http_request(method, path, host), + upstream_response: NO_CONTENT.to_string(), + marker: None, + })), + ); + } +} + +/// Generates the request cases of an inspected MCP endpoint on `port`. +fn mcp_cases(endpoint: &Endpoint, port: u16, allowed: &str, cases: &mut Cases<'_>) { + use McpMethodClassification::{Available, Extension}; + let host = endpoint.host.as_str(); + let at = format!("{}: {host}:{port}", endpoint.policy); + let path = endpoint.path.as_str(); + assert!( + !path.is_empty() && !path.contains('*'), + "{at}: add probes for MCP path {path:?}" + ); + let calls = |method: &str, tool: Option<&str>, class| { + jsonrpc_request(path, vec![call(method, tool, Some(class))]) + }; + let frames = |receive_stream, has_response, method: &str| L7RequestInfo { + jsonrpc: Some(JsonRpcRequestInfo { + receive_stream, + has_response, + ..jsonrpc_info(Vec::new()) + }), + ..rest(method, path) + }; + let requests = [ + ("initialize", calls("initialize", None, Available)), + ("tools/list", calls("tools/list", None, Available)), + ("tools/call", calls("tools/call", Some("echo"), Available)), + ("resources/read", calls("resources/read", None, Available)), + ("an extension method", calls("x/custom", None, Extension)), + ( + "tools/call sent with GET", + L7RequestInfo { + action: "GET".to_string(), + ..calls("tools/call", Some("echo"), Available) + }, + ), + ( + "initialize on another path", + jsonrpc_request( + "/elsewhere", + vec![call("initialize", None, Some(Available))], + ), + ), + ("the receive stream", frames(true, false, "GET")), + ("a response frame", frames(false, true, "POST")), + ("a response frame sent with GET", frames(false, true, "GET")), + ("a plain GET", rest("GET", path)), + ("a plain DELETE", rest("DELETE", path)), + ]; + for (what, request) in requests { + cases.add( + format!("{at} {what}"), + request_in(l7_ctx(host, port, allowed), request), + ); + } + for binary in &endpoint.binaries { + let sample = binary_sample(binary); + cases.add( + format!("{at} initialize from {sample}"), + request_in( + l7_ctx(host, port, &sample), + calls("initialize", None, Available), + ), + ); + let child = L7EvalContext { + ancestors: vec![sample.clone()], + ..l7_ctx(host, port, CHILD_BINARY) + }; + cases.add( + format!("{at} initialize from a child of {sample}"), + request_in(child, calls("initialize", None, Available)), + ); + } + cases.add( + format!("{at} initialize from an unlisted binary"), + request_in( + l7_ctx(host, port, UNLISTED_BINARY), + calls("initialize", None, Available), + ), + ); +} + +/// Effective Landlock grants as sorted `ro:` and `rw:` entries. +type Grants = BTreeSet; + +/// Adds the proxy-mode baseline, as the supervisor's enrichment does: a +/// baseline path already granted either way is left alone. +fn add_baseline(read_only: &mut Vec, read_write: &mut Vec) { + for path in BASELINE_READ_ONLY { + if !read_only.iter().any(|p| p == path) && !read_write.iter().any(|p| p == path) { + read_only.push((*path).to_string()); + } + } + for path in BASELINE_READ_WRITE { + if !read_only.iter().any(|p| p == path) && !read_write.iter().any(|p| p == path) { + read_write.push((*path).to_string()); + } + } +} + +fn grants(read_only: &[String], read_write: &[String]) -> Grants { + read_only + .iter() + .map(|path| format!("ro:{path}")) + .chain(read_write.iter().map(|path| format!("rw:{path}"))) + .collect() +} + +/// The Landlock grants a YAML sandbox applies, in gateway mode. +/// +/// The supervisor adds the baseline (and an `include_workdir` filesystem +/// section when none is given) only when the policy has network policies, +/// then Landlock adds the workdir when `include_workdir` is set. +fn yaml_grants(policy: &SandboxPolicy, workdir: &str) -> Grants { + let mut filesystem = policy.filesystem.clone(); + if !policy.network_policies.is_empty() { + let filesystem = + filesystem.get_or_insert_with(|| openshell_core::proto::FilesystemPolicy { + include_workdir: true, + ..Default::default() + }); + add_baseline(&mut filesystem.read_only, &mut filesystem.read_write); + } + // An absent section converts to `include_workdir: true` with no paths. + let (read_only, mut read_write, include_workdir) = filesystem.map_or_else( + || (Vec::new(), Vec::new(), true), + |filesystem| { + ( + filesystem.read_only, + filesystem.read_write, + filesystem.include_workdir, + ) + }, + ); + if include_workdir && !read_write.iter().any(|path| path == workdir) { + read_write.push(workdir.to_string()); + } + if read_only.is_empty() && read_write.is_empty() { + // Landlock applies no ruleset at all. + return Grants::new(); + } + grants(&read_only, &read_write) +} + +/// The Landlock grants a Cedar sandbox applies: Cedar's grants, with the +/// baseline when a policy can permit `NetworkConnect` (as YAML adds it only +/// to a policy with network policies), and no workdir. +fn cedar_grants(policy: &SandboxPolicy) -> Grants { + let filesystem = openshell_policy::cedar_filesystem_grants(&policy.cedar_policy_source) + .expect("Cedar filesystem grants load"); + assert!(!filesystem.include_workdir); + let (mut read_only, mut read_write) = (filesystem.read_only, filesystem.read_write); + let has_network_permits = CedarOnlyEngine::from_proto(policy) + .expect("Cedar engine loads") + .has_network_permits() + .expect("engine lock"); + if has_network_permits { + add_baseline(&mut read_only, &mut read_write); + } + if read_only.is_empty() && read_write.is_empty() { + // Landlock applies no ruleset at all. + return Grants::new(); + } + grants(&read_only, &read_write) +} + +/// A comparison outside the probe table, reported in the same format. +fn row( + report: &mut String, + failures: &mut Vec, + name: &str, + case: &str, + (yaml, cedar): (String, String), + known: Option<&str>, +) { + let outcome = match (known, yaml == cedar) { + (None, true) => "same".to_string(), + (Some(reason), false) => format!("known difference: {reason}"), + (None, false) => { + failures.push(format!("{name} / {case}: YAML {yaml} but Cedar {cedar}")); + "MISMATCH".to_string() + } + (Some(reason), true) => { + failures.push(format!( + "{name} / {case}: expected a difference ({reason}) but both {yaml}" + )); + "UNEXPECTEDLY SAME".to_string() + } + }; + let _ = writeln!(report, "| {name} | {case} | {yaml} | {cedar} | {outcome} |"); +} + +/// The middleware chain an engine selects for `host`, as names and settings. +fn chain(engine: crate::policy_engine::PolicyEngine, host: &str, port: u16) -> String { + match engine.query_middleware_chain_with_generation(&network(host, port, BINARY)) { + Ok((entries, _)) if entries.is_empty() => "none".to_string(), + Ok((entries, _)) => entries + .iter() + .map(|entry| { + format!( + "{}/{}/{}/{:?}/{:?}", + entry.name, entry.implementation, entry.order, entry.on_error, entry.config + ) + }) + .collect::>() + .join(";"), + Err(error) => format!("error: {}", error_line(&error)), + } +} + +/// Compares filesystem, Landlock, process, and middleware configuration. +fn configuration_rows( + example: &Example, + loaded: &Loaded, + endpoints: &[Endpoint], + known: &dyn Fn(&str) -> Option<&'static str>, +) -> (String, Vec) { + let (mut report, mut failures) = (String::new(), Vec::new()); + let name = example.name; + let join = |grants: Grants| { + if grants.is_empty() { + "no Landlock ruleset".to_string() + } else { + grants.into_iter().collect::>().join(",") + } + }; + let case = "Landlock grants"; + row( + &mut report, + &mut failures, + name, + case, + ( + join(yaml_grants(&loaded.yaml, example.workdir)), + join(cedar_grants(&loaded.cedar)), + ), + known(case), + ); + let compatibility = |policy: &SandboxPolicy| { + let value = policy + .landlock + .as_ref() + .map_or("", |landlock| landlock.compatibility.as_str()); + if value.is_empty() { + "best_effort" + } else { + value + } + .to_string() + }; + let case = "Landlock compatibility"; + row( + &mut report, + &mut failures, + name, + case, + (compatibility(&loaded.yaml), compatibility(&loaded.cedar)), + known(case), + ); + let process = |policy: &SandboxPolicy| { + policy.process.as_ref().map_or_else( + || "driver default".to_string(), + |process| format!("{}:{}", process.run_as_user, process.run_as_group), + ) + }; + let case = "process identity"; + let identity = (process(&loaded.yaml), process(&loaded.cedar)); + let reason = (identity.0 != identity.1).then_some(PROCESS_IDENTITY); + row(&mut report, &mut failures, name, case, identity, reason); + if example.middleware.is_some() || !loaded.yaml.network_middlewares.is_empty() { + let mut targets: Vec<(String, u16)> = endpoints + .iter() + .flat_map(|endpoint| { + endpoint + .ports + .iter() + .map(|port| (endpoint.host.clone(), *port)) + }) + .collect(); + targets.push((UNLISTED_HOST.to_string(), 443)); + for (host, port) in targets { + let case = format!("middleware chain for {host}:{port}"); + row( + &mut report, + &mut failures, + name, + &case, + ( + chain(Arc::clone(&loaded.engines.opa).into(), &host, port), + chain(Arc::clone(&loaded.engines.cedar).into(), &host, port), + ), + known(&case), + ); + } + } + (report, failures) +} + +/// Runs every comparison for one example. +/// +/// `known` maps a case name to its known difference. +fn check(example: &Example, known: &dyn Fn(&str) -> Option<&'static str>) { + let loaded = Loaded::new(example); + // Probe the composed policy, which includes provider endpoints. + let composed = openshell_policy::serialize_sandbox_policy(&loaded.yaml) + .unwrap_or_else(|error| panic!("{}: YAML policy serializes: {error:?}", example.name)); + let endpoints = endpoints(example.name, &composed); + let mut cases = Cases { + known, + list: Vec::new(), + }; + for endpoint in &endpoints { + endpoint_cases(endpoint, &mut cases); + } + cases.add("policy DNS records".to_string(), Probe::DnsRecords); + let host = endpoints + .first() + .map_or(UNLISTED_HOST, |endpoint| endpoint.host.as_str()); + let (mut report, mut failures) = run(example.name, host, &loaded.engines, &cases.list); + let (rows, row_failures) = configuration_rows(example, &loaded, &endpoints, known); + report.push_str(&rows); + failures.extend(row_failures); + let known_count = report.matches("| known difference:").count(); + let total = report.lines().count(); + println!( + "{}: {total} cases, {} same, {known_count} known differences", + example.name, + total - known_count - failures.len() + ); + report_parity((report, failures)); +} + +/// No known differences beyond the generated ones. +fn none(_: &str) -> Option<&'static str> { + None +} + +#[test] +fn example_sandbox_policy_quickstart() { + check( + &Example::new( + "sandbox-policy-quickstart", + include_str!("../../../../examples/sandbox-policy-quickstart/policy.yaml"), + include_str!("../../../../examples/sandbox-policy-quickstart/policy.cedar"), + ), + &none, + ); +} + +#[test] +fn example_governance_interceptor() { + check( + &Example::new( + "governance-interceptor", + include_str!("../../../../examples/governance-interceptor/policy.yaml"), + include_str!("../../../../examples/governance-interceptor/policy.cedar"), + ), + &none, + ); +} + +/// The example always attaches its `nvidia-inference` provider. With YAML +/// the provider adds the NVIDIA endpoint; the Cedar policy grants it itself. +#[test] +fn example_local_inference() { + check( + &Example { + provider: Some(include_str!( + "../../../../examples/local-inference/nvidia-inference.yaml" + )), + ..Example::new( + "local-inference", + include_str!("../../../../examples/local-inference/sandbox-policy.yaml"), + include_str!("../../../../examples/local-inference/sandbox-policy.cedar"), + ) + }, + &none, + ); +} + +#[test] +fn example_policy_advisor() { + check( + &Example::new( + "policy-advisor", + include_str!("../../../../examples/policy-advisor/sandbox-policy.yaml"), + include_str!("../../../../examples/policy-advisor/sandbox-policy.cedar"), + ), + &none, + ); +} + +#[test] +fn example_supervisor_middleware_content_guard() { + check( + &Example::new( + "supervisor-middleware-content-guard", + include_str!("../../../../examples/supervisor-middleware-content-guard/policy.yaml"), + include_str!("../../../../examples/supervisor-middleware-content-guard/policy.cedar"), + ) + .with_middleware(include_str!( + "../../../../examples/supervisor-middleware-content-guard/middleware.yaml" + )), + &none, + ); +} + +#[test] +fn example_transparent_tcp_redis() { + check( + &Example::new( + "transparent-tcp-redis", + include_str!("../../../../examples/transparent-tcp-redis/policy.yaml"), + include_str!("../../../../examples/transparent-tcp-redis/policy.cedar"), + ), + &none, + ); +} + +#[test] +fn example_agent_driven_policy_management() { + check( + &Example::new( + "agent-driven-policy-management", + include_str!( + "../../../../examples/agent-driven-policy-management/policy.template.yaml" + ), + include_str!( + "../../../../examples/agent-driven-policy-management/policy.template.cedar" + ), + ), + &none, + ); +} + +#[test] +fn example_multi_agent_notepad() { + check( + &Example::new( + "multi-agent-notepad", + include_str!("../../../../examples/multi-agent-notepad/policy.template.yaml"), + include_str!("../../../../examples/multi-agent-notepad/policy.template.cedar"), + ) + .rendered(NOTEPAD_VALUES), + &none, + ); +} + +#[test] +fn example_e2e_policy_advisor() { + check( + &Example::new( + "e2e/policy-advisor", + include_str!("../../../../e2e/policy-advisor/policy.template.yaml"), + include_str!("../../../../e2e/policy-advisor/policy.template.cedar"), + ) + .with_middleware(include_str!( + "../../../../e2e/policy-advisor/middleware.yaml" + )), + &none, + ); +} + +#[test] +fn example_gator() { + check( + &Example::new( + "gator", + include_str!("../../../../scripts/agents/gator/policy.yaml"), + include_str!("../../../../scripts/agents/gator/policy.cedar"), + ) + .with_middleware(include_str!( + "../../../../scripts/agents/gator/middleware.yaml" + )), + &none, + ); +} + +#[test] +fn example_mxc_fs_empty() { + check( + &Example::new( + "mxc fs-empty", + include_str!("../../../openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml"), + include_str!("../../../openshell-driver-mxc/examples/e2e-policies/fs-empty.cedar"), + ), + &none, + ); +} + +/// Values `e2e/mcp-conformance/render-policy.py` substitutes for a server URL +/// on `host`, port 41234, path `/mcp`, and the default MCP revision. +/// +/// Like Python's `string.Template`, `$$` escapes a `$`, so an escaped +/// placeholder is set aside before the others are replaced. +fn mcp_values(host: &str) -> Vec<(String, String)> { + let placeholder = |name: &str| format!("${{{name}}}"); + let escaped = ["$$", "{"].concat(); + let set_aside = "\u{0}{".to_string(); + vec![ + (escaped, set_aside.clone()), + (placeholder("host_spec"), format!("host: \"{host}\"")), + (placeholder("port_spec"), " port: 41234".to_string()), + (placeholder("host_json"), format!("\"{host}\"")), + (placeholder("host"), host.to_string()), + (placeholder("port"), "41234".to_string()), + (placeholder("path"), "\"/mcp\"".to_string()), + (placeholder("mcp_version"), "\"2025-11-25\"".to_string()), + (set_aside, ["$", "{"].concat()), + ] +} + +fn mcp_conformance(name: &'static str, host: &str) -> Example { + let values = mcp_values(host); + let values: Vec<(&str, &str)> = values + .iter() + .map(|(from, to)| (from.as_str(), to.as_str())) + .collect(); + Example { + // The client image's WORKDIR. + workdir: "/opt/mcp-conformance", + ..Example::new( + name, + include_str!("../../../../e2e/mcp-conformance/policy-template.yaml"), + include_str!("../../../../e2e/mcp-conformance/policy-template.cedar"), + ) + } + .with_middleware(include_str!( + "../../../../e2e/mcp-conformance/middleware-template.yaml" + )) + .rendered(&values) +} + +/// The client sandbox reaches a runner container by IP on the e2e network. +#[test] +fn example_mcp_conformance_runner_address() { + check( + &mcp_conformance("e2e/mcp-conformance (IP)", "172.18.0.3"), + &none, + ); +} + +/// A `localhost` server URL is rewritten to the host gateway alias. +#[test] +fn example_mcp_conformance_host_alias() { + check( + &mcp_conformance( + "e2e/mcp-conformance (host alias)", + "host.openshell.internal", + ), + &none, + ); +} + +/// `policy.yaml` does not load: its explicit `run_as_user: jovyan` fails +/// process identity validation, so the engines cannot be compared. The Cedar +/// version omits the identity, which lets the driver use the image user, and +/// grants the same paths with the image workdir in place of +/// `include_workdir`. +#[test] +fn example_jupyter_sandbox() { + const YAML: &str = include_str!("../../../../examples/jupyter-sandbox/policy.yaml"); + const CEDAR: &str = include_str!("../../../../examples/jupyter-sandbox/policy.cedar"); + let yaml = openshell_policy::parse_sandbox_policy(YAML).expect("YAML policy parses"); + assert!( + OpaEngine::from_proto(&yaml).is_err(), + "policy.yaml loads now; compare it with the engines like the other examples" + ); + assert!(yaml.network_policies.is_empty()); + let dir = tempfile::tempdir().expect("temporary directory"); + let path = dir.path().join("policy.cedar"); + std::fs::write(&path, CEDAR).expect("write Cedar policy"); + let cedar = + openshell_policy::parse_sandbox_policy_file_auto(&path).expect("Cedar policy loads"); + let engine = CedarOnlyEngine::from_proto(&cedar).expect("Cedar engine loads"); + let records = engine + .policy_dns_eligibility_snapshot() + .expect("DNS snapshot"); + assert!(records.endpoints.is_empty(), "no network access"); + assert_eq!( + yaml_grants(&yaml, "/home/jovyan"), + cedar_grants(&cedar), + "Landlock grants" + ); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/mod.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/mod.rs new file mode 100644 index 0000000000..8bc0b13e55 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/mod.rs @@ -0,0 +1,1582 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Decision parity between YAML policies evaluated by Rego and equivalent +//! Cedar policies. +//! +//! Each scenario pairs a YAML policy with a Cedar policy written to mean the +//! same thing, and runs every [`Probe`] through both engines using the calls +//! the proxy makes. A case marked [`Expect::Same`] fails if the outcomes +//! differ. A case marked [`Expect::Diverges`] records a known difference and +//! fails if the engines start to agree, so the list stays accurate. +//! +//! `scenarios` holds hand-written scenarios. The `ported_*` modules port the +//! existing YAML decision tests that Cedar can express; each case names the +//! YAML test it ports. +//! +//! The harness measures one thing: can a Cedar policy be written to mean what +//! the YAML meant? It does not measure a mechanical translation. The Cedar +//! side of each scenario deliberately absorbs YAML quirks such as HEAD +//! standing in for GET, the stream permits MCP endpoints imply, and audit +//! annotations, so a `same` case says the meaning is expressible, not that the +//! two texts correspond line for line. A YAML binary glob, which also matches +//! every process ancestor, is written as `context.binary_globs.contains`, +//! since Cedar has no way to apply `like` across the `context.ancestors` set. +//! +//! An evaluation error on either side fails a case even when the decision it +//! collapses to (a denial) matches, unless the case opts in with +//! [`Case::allowing_error`]. Each report ends with its counts and the +//! cumulative `parity tally` for the process; with `--nocapture`, the last +//! tally printed gives the totals (cases, same, known differences) for the +//! whole run. + +mod examples; +mod ported_opa_a; +mod ported_opa_b; +mod ported_phase1; +mod ported_phase2; +mod ported_phase3; +mod ported_phase4; +mod ported_phase5; +mod ported_phase6; +// Resolves real symlinks; skips where `/proc//root` does not (non-Linux). +#[cfg(unix)] +mod ported_phase7; +mod ported_proxy_dns; +mod ported_relay; +mod provider_rules; +mod scenarios; + +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt::Write as _; +use std::net::IpAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::cedar_only::CedarOnlyEngine; +use crate::l7::L7RequestInfo; +use crate::l7::graphql::{GraphqlOperationInfo, GraphqlRequestInfo}; +use crate::l7::jsonrpc::{JsonRpcCallInfo, JsonRpcRequestInfo, McpMethodClassification}; +use crate::l7::relay::L7EvalContext; +use crate::opa::{EgressAuthorization, NetworkAction, NetworkInput, OpaEngine}; + +const REGO: &str = include_str!("../../data/sandbox-policy.rego"); +const BINARY: &str = "/usr/bin/curl"; + +/// Whether both engines are expected to agree on a case. +#[derive(Debug, Clone, Copy)] +enum Expect { + Same, + /// A known difference, with the reason it exists. + Diverges(&'static str), +} + +/// What a case compares between the two engines. +enum Probe { + /// One L7 request through both engines' per-tunnel `evaluate_request`. + /// `ctx` defaults to the scenario host on port 443 and [`BINARY`]. + /// Outcome: `allow` or `deny`. + Request { + ctx: Option>, + request: Box, + }, + /// One connection through both engines' `authorize_egress`. + /// Outcome: `allow` or `deny`. + Connect(NetworkInput), + /// Whether an allowed connection's host counts as exactly declared, which + /// lets it resolve to a private address. + /// Outcome: `exact`, `not-exact`, or `deny`. + ExactHost(NetworkInput), + /// The L7 inspection selected for an allowed connection: the distinct + /// `protocol/enforcement` pairs of its endpoint configs. + /// Outcome: for example `Rest/Enforce`, `none`, or `deny`. + Inspection(NetworkInput), + /// Whether policy DNS would resolve `name` for `port`. + /// Outcome: `eligible` or `ineligible`. + DnsEligible { name: String, port: u16 }, + /// The policy DNS records an allowed connection's decision matched, as + /// `host:ports` with `/tcp` for a native TCP record, sorted. Each must + /// also be in the engine's DNS snapshot under the same name and index, + /// which the transparent TCP listener relies on to correlate them. + /// Outcome: the records joined by `;`, `none`, or `deny`, with + /// ` (not in DNS snapshot)` when a record is missing from the snapshot. + MatchedEndpoints(NetworkInput), + /// Every record of the policy DNS snapshot, as for + /// [`Probe::MatchedEndpoints`]. Record names and indices are not + /// compared, since YAML names records by policy and Cedar does not. + /// Outcome: the records joined by `;`, or `none`. + DnsRecords, + /// A transparent TCP open from `binary` to the address policy DNS + /// answers for `name`, staged as the boundary stages it. Each engine's + /// own policy DNS resolves `name`, with the trusted resolver answering + /// `answers` for every name. + /// Outcome: `dns refused: `, the denial, or + /// `RelayReady//`, where + /// `correlated` means the decision names an endpoint of the mapping, as + /// the transparent TCP listener requires. + TransparentOpen { + name: String, + port: u16, + binary: String, + answers: Vec, + }, + /// The addresses policy DNS pins for `name` on `port` when the trusted + /// resolver answers `answers`, after each eligible endpoint filters them. + /// Outcome: the pinned addresses, sorted, `no mapping for port`, or + /// `dns refused: `. + DnsAnswers { + name: String, + port: u16, + answers: Vec, + }, + /// One explicit proxy connection whose host resolved to `addresses`, + /// checked with the destination mode the decision selects, as CONNECT + /// and forward HTTP check it: every address must pass. + /// Outcome: `deny` (policy), `rejected` (destination check), or + /// `admitted`. + Destination { + input: NetworkInput, + addresses: Vec, + }, + /// The token-grant owners admitted for one L7 request, selected the way + /// the relay selects them (each JSON-RPC batch member must admit an + /// owner). `ctx` defaults as for [`Probe::Request`]. + /// Outcome: the sorted owners, comma-separated, or `none`. + Owners { + ctx: Option>, + request: Box, + }, + /// The settings of an allowed connection's L7 endpoint configs that do + /// not depend on how a policy scopes paths: protocol, enforcement, TLS + /// mode, MCP revisions and strict tool names, encoded slashes, and body + /// limits. + /// Outcome: the distinct per-config summaries, sorted, `none`, or `deny`. + Configs(NetworkInput), + /// The TLS mode the proxy reads for an allowed connection: the `tls` + /// field of its first endpoint config. + /// Outcome: `Skip`, `Auto`, or `deny`. + TlsMode(NetworkInput), + /// One L7 request's decision and deny reason. `ctx` defaults as for + /// [`Probe::Request`]. + /// Outcome: `allow`, or `deny: `. + DenyReason { + ctx: Option>, + request: Box, + }, + /// One HTTP/1 exchange through the relay the proxy runs for an allowed + /// connection, with the endpoint configs and tunnel engine each engine + /// supplies. See [`RelayExchange`]. + /// Outcome: `deny`, `passthrough` (no inspection), or + /// `/`, plus `/marker` or `/no-marker` when + /// the exchange names a response marker. + Relay(Box), + /// A WebSocket upgrade and one client text message through the relay the + /// proxy runs for an allowed connection. See [`WebSocketExchange`]. + /// Outcome: `deny`, `passthrough`, `/blocked` when the upgrade + /// is refused, or `101/message-forwarded` or `101/message-blocked`. + WebSocket(Box), +} + +/// One WebSocket session for [`Probe::WebSocket`]. +/// +/// The connection and relay mode follow [`RelayExchange`]. The upstream +/// accepts the upgrade once it receives it, then the client sends `message` +/// as one masked text frame. +struct WebSocketExchange { + ctx: L7EvalContext, + route_selected: bool, + /// The raw upgrade request. + upgrade: String, + /// The client text message sent after the upgrade. + message: String, +} + +/// One HTTP/1 exchange for [`Probe::Relay`]. +/// +/// The connection is `ctx.binary_path` to `ctx.host:ctx.port`. The relay +/// mode follows the proxy: route selection when the connection has more than +/// one endpoint config or `route_selected` is set, single-config inspection +/// otherwise. +struct RelayExchange { + /// The tunnel context. `policy_name` and `request_default_port` are + /// filled from the connection decision. + ctx: L7EvalContext, + route_selected: bool, + /// The raw request bytes. Use `Connection: close` so the relay ends. + request: String, + /// What the upstream answers once it receives a complete request. + upstream_response: String, + /// Text whose presence in the client response is part of the outcome. + marker: Option<&'static str>, +} + +/// An upstream reply with no body. +const NO_CONTENT: &str = + "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + +struct Case { + name: Cow<'static, str>, + probe: Probe, + expect: Expect, + /// The YAML outcome the ported test asserts, when the case checks it. + yaml: Option<&'static str>, + /// Whether an evaluation error on either side is an expected part of the + /// case. Off by default: an error is otherwise a failure even when the + /// decision it collapses to matches, so a mistranslated Cedar policy that + /// errors on every request cannot pass as "same" on the deny cases. + allow_error: bool, +} + +impl Case { + /// Also requires the YAML engine's outcome to be `outcome`. + /// + /// For a port whose YAML side loads differently from the original test, + /// this keeps the original's assertion. + fn asserting_yaml(self, outcome: &'static str) -> Self { + Self { + yaml: Some(outcome), + ..self + } + } + + /// Accepts an evaluation error on either side as part of the case. + /// + /// Use it only where the error is the expected outcome of the original + /// test, and say why at the call site. + fn allowing_error(self) -> Self { + Self { + allow_error: true, + ..self + } + } +} + +struct Scenario<'a> { + name: &'a str, + /// Default host for [`Probe::Request`] cases without a `ctx`. + host: &'a str, + yaml: &'a str, + cedar: &'a str, + cases: Vec, +} + +/// A request case the engines must agree on, in the scenario's default context. +fn same(name: impl Into>, request: L7RequestInfo) -> Case { + same_probe( + name, + Probe::Request { + ctx: None, + request: Box::new(request), + }, + ) +} + +/// A request case recorded as a known difference. +fn diverges( + name: impl Into>, + request: L7RequestInfo, + reason: &'static str, +) -> Case { + diverges_probe( + name, + Probe::Request { + ctx: None, + request: Box::new(request), + }, + reason, + ) +} + +fn same_probe(name: impl Into>, probe: Probe) -> Case { + Case { + name: name.into(), + probe, + expect: Expect::Same, + yaml: None, + allow_error: false, + } +} + +fn diverges_probe(name: impl Into>, probe: Probe, reason: &'static str) -> Case { + Case { + name: name.into(), + probe, + expect: Expect::Diverges(reason), + yaml: None, + allow_error: false, + } +} + +/// An L7 evaluation context for `binary` connecting to `host:port`. +fn l7_ctx(host: &str, port: u16, binary: &str) -> L7EvalContext { + L7EvalContext { + host: host.to_string(), + port, + binary_path: binary.to_string(), + ..Default::default() + } +} + +/// A request probe with an explicit context. +fn request_in(ctx: L7EvalContext, request: L7RequestInfo) -> Probe { + Probe::Request { + ctx: Some(Box::new(ctx)), + request: Box::new(request), + } +} + +/// A token-grant owner probe with an explicit context. +fn owners_in(ctx: L7EvalContext, request: L7RequestInfo) -> Probe { + Probe::Owners { + ctx: Some(Box::new(ctx)), + request: Box::new(request), + } +} + +/// A connection from `binary` to `host:port`, with no ancestors. +fn network(host: &str, port: u16, binary: &str) -> NetworkInput { + network_with_ancestors(host, port, binary, &[]) +} + +/// A connection from `binary`, started by `ancestors`, to `host:port`. +fn network_with_ancestors(host: &str, port: u16, binary: &str, ancestors: &[&str]) -> NetworkInput { + NetworkInput { + host: host.to_string(), + port, + binary_path: PathBuf::from(binary), + binary_sha256: String::new(), + ancestors: ancestors.iter().map(PathBuf::from).collect(), + cmdline_paths: Vec::new(), + } +} + +fn rest(method: &str, path: &str) -> L7RequestInfo { + L7RequestInfo { + action: method.to_string(), + target: path.to_string(), + query_params: HashMap::new(), + graphql: None, + jsonrpc: None, + } +} + +fn operation(operation_type: &str, name: Option<&str>, fields: &[&str]) -> GraphqlOperationInfo { + GraphqlOperationInfo { + operation_type: operation_type.to_string(), + operation_name: name.map(str::to_string), + fields: fields.iter().map(ToString::to_string).collect(), + persisted_query: false, + persisted_query_hash: None, + persisted_query_id: None, + } +} + +fn graphql(operations: Vec) -> L7RequestInfo { + L7RequestInfo { + graphql: Some(GraphqlRequestInfo { + operations, + error: None, + }), + ..rest("POST", "/graphql") + } +} + +fn jsonrpc_info(calls: Vec) -> JsonRpcRequestInfo { + JsonRpcRequestInfo { + calls, + is_batch: false, + receive_stream: false, + has_response: false, + mcp_revision: None, + mcp_http_metadata: None, + error: None, + } +} + +fn call( + method: &str, + tool: Option<&str>, + class: Option, +) -> JsonRpcCallInfo { + let mut params = HashMap::new(); + if let Some(tool) = tool { + params.insert("name".to_string(), tool.to_string()); + } + JsonRpcCallInfo { + method: method.to_string(), + params, + tool: tool.map(str::to_string), + mcp_classification: class, + is_notification: false, + } +} + +fn jsonrpc_request(path: &str, calls: Vec) -> L7RequestInfo { + L7RequestInfo { + jsonrpc: Some(jsonrpc_info(calls)), + ..rest("POST", path) + } +} + +fn mcp(method: &str, tool: Option<&str>, class: McpMethodClassification) -> L7RequestInfo { + jsonrpc_request("/mcp", vec![call(method, tool, Some(class))]) +} + +/// The first line of an evaluation error, for the report. +fn error_line(error: &miette::Report) -> String { + let text = error.to_string(); + text.lines() + .find(|line| line.contains("error:")) + .unwrap_or_else(|| text.lines().next().unwrap_or("evaluation error")) + .trim() + .to_string() +} + +fn allow_or_deny(allowed: bool) -> String { + if allowed { "allow" } else { "deny" }.to_string() +} + +/// A scenario whose network rules all come from attached providers. +/// +/// Each format receives `rules` the way the gateway composes provider rules +/// into it: a YAML sandbox enforces them as network policies, and a Cedar +/// sandbox receives them as `provider_credential_rules` beside `cedar`, which +/// alone decides access. Use it for token grants, which the gateway stamps on +/// provider endpoints. +struct ProviderScenario<'a> { + name: &'a str, + /// Default host for [`Probe::Request`] and [`Probe::Owners`] cases without a `ctx`. + host: &'a str, + rules: Vec, + cedar: &'a str, + cases: Vec, +} + +/// A scenario whose Cedar sandbox also has endpoint settings. +/// +/// `settings` is middleware file text with an `endpoint_settings` section, +/// parsed and validated as the CLI parses `--middleware`. The YAML policy +/// carries the same settings on its own endpoints. +struct SettingsScenario<'a> { + name: &'a str, + /// Default host for [`Probe::Request`] cases without a `ctx`. + host: &'a str, + yaml: &'a str, + cedar: &'a str, + settings: &'a str, + cases: Vec, +} + +/// Both engines for one scenario. +struct Engines { + opa: Arc, + cedar: Arc, +} + +/// Loads YAML policy text as the gateway delivers it to a sandbox. +/// +/// A sandbox never sees YAML text: the gateway delivers the typed policy, +/// and the supervisor renders each endpoint with `endpoint_policy_value`, +/// which leaves out an empty `protocol` the way a typed policy cannot tell +/// `protocol: ""` from no protocol. The scenario texts predate the typed +/// parser (most omit `version`, and two use fields it rejects), so the text +/// is loaded as written except for that one delivery step, which is the only +/// one whose result a scenario text can differ on. +fn load_delivered_yaml(name: &str, yaml: &str) -> OpaEngine { + OpaEngine::from_strings(REGO, &delivered_yaml(name, yaml)) + .unwrap_or_else(|error| panic!("{name}: YAML policy loads: {error}")) +} + +/// Returns `yaml` in its delivered form; see [`load_delivered_yaml`]. +fn delivered_yaml(name: &str, yaml: &str) -> String { + let mut data: serde_yml::Value = serde_yml::from_str(yaml) + .unwrap_or_else(|error| panic!("{name}: YAML policy parses: {error}")); + drop_empty_endpoint_protocols(&mut data); + serde_yml::to_string(&data).unwrap_or_else(|error| panic!("{name}: YAML renders: {error}")) +} + +/// Removes `protocol: ""` from every network policy endpoint, as the +/// gateway's delivered form has no such entry. +fn drop_empty_endpoint_protocols(data: &mut serde_yml::Value) { + let Some(policies) = data + .get_mut("network_policies") + .and_then(serde_yml::Value::as_mapping_mut) + else { + return; + }; + let endpoints = policies + .values_mut() + .filter_map(|policy| policy.get_mut("endpoints")) + .filter_map(serde_yml::Value::as_sequence_mut) + .flatten() + .filter_map(serde_yml::Value::as_mapping_mut); + for endpoint in endpoints { + if endpoint + .get("protocol") + .and_then(serde_yml::Value::as_str) + .is_some_and(str::is_empty) + { + endpoint.remove("protocol"); + } + } +} + +impl Engines { + fn load(scenario: &Scenario<'_>) -> Self { + let opa = load_delivered_yaml(scenario.name, scenario.yaml); + let cedar = CedarOnlyEngine::from_policy_str(scenario.cedar) + .unwrap_or_else(|error| panic!("{}: Cedar policy loads: {error}", scenario.name)); + Self::new(opa, cedar) + } + + fn load_providers(scenario: &ProviderScenario<'_>) -> Self { + let rules: HashMap<_, _> = scenario + .rules + .iter() + .map(|rule| (rule.name.clone(), rule.clone())) + .collect(); + assert_eq!(rules.len(), scenario.rules.len(), "rule names are unique"); + let yaml = openshell_core::proto::SandboxPolicy { + network_policies: rules.clone(), + ..Default::default() + }; + let cedar = openshell_core::proto::SandboxPolicy { + cedar_policy_source: scenario.cedar.to_string(), + provider_credential_rules: rules, + ..Default::default() + }; + let opa = OpaEngine::from_proto(&yaml) + .unwrap_or_else(|error| panic!("{}: YAML policy loads: {error}", scenario.name)); + let cedar = CedarOnlyEngine::from_proto(&cedar) + .unwrap_or_else(|error| panic!("{}: Cedar policy loads: {error}", scenario.name)); + Self::new(opa, cedar) + } + + fn load_settings(scenario: &SettingsScenario<'_>) -> Self { + let opa = load_delivered_yaml(scenario.name, scenario.yaml); + let file = openshell_policy::parse_middleware_yaml(scenario.settings) + .unwrap_or_else(|error| panic!("{}: settings parse: {error:?}", scenario.name)); + let policy = openshell_core::proto::SandboxPolicy { + cedar_policy_source: scenario.cedar.to_string(), + network_middlewares: file.network_middlewares, + endpoint_settings: file.endpoint_settings, + ..Default::default() + }; + openshell_policy::validate_sandbox_policy(&policy) + .unwrap_or_else(|errors| panic!("{}: policy validates: {errors:?}", scenario.name)); + let cedar = CedarOnlyEngine::from_proto(&policy) + .unwrap_or_else(|error| panic!("{}: Cedar policy loads: {error}", scenario.name)); + Self::new(opa, cedar) + } + + fn new(opa: OpaEngine, cedar: CedarOnlyEngine) -> Self { + Self { + opa: Arc::new(opa), + cedar: Arc::new(cedar), + } + } + + /// Returns the `(yaml, cedar)` outcomes. Evaluation errors count as a + /// denial, as they do in the proxy, and are noted in the report. + fn outcomes(&self, host: &str, probe: &Probe) -> (String, String) { + match probe { + Probe::Request { ctx, request } => { + let ctx = ctx + .as_deref() + .cloned() + .unwrap_or_else(|| l7_ctx(host, 443, BINARY)); + let yaml = self + .opa + .clone_engine_for_tunnel(self.opa.current_generation()) + .and_then(|tunnel| tunnel.evaluate_request(&ctx, request)); + let cedar = self + .cedar + .l7_handle(self.cedar.current_generation()) + .evaluate_request(&ctx, request); + ( + request_outcome(&yaml.map(|(allowed, _)| allowed)), + request_outcome(&cedar.map(|(allowed, _)| allowed)), + ) + } + Probe::Connect(input) => ( + connect_outcome(&self.opa.authorize_egress(input)), + connect_outcome(&self.cedar.authorize_egress(input)), + ), + Probe::ExactHost(input) => ( + exact_host_outcome(&self.opa.authorize_egress(input)), + exact_host_outcome(&self.cedar.authorize_egress(input)), + ), + Probe::Inspection(input) => ( + inspection_outcome(&self.opa.authorize_egress(input)), + inspection_outcome(&self.cedar.authorize_egress(input)), + ), + Probe::DnsEligible { name, port } => ( + dns_outcome(&self.opa.policy_dns_eligibility_snapshot(), name, *port), + dns_outcome(&self.cedar.policy_dns_eligibility_snapshot(), name, *port), + ), + Probe::MatchedEndpoints(input) => ( + matched_endpoints_outcome( + &self.opa.authorize_egress(input), + &self.opa.policy_dns_eligibility_snapshot(), + ), + matched_endpoints_outcome( + &self.cedar.authorize_egress(input), + &self.cedar.policy_dns_eligibility_snapshot(), + ), + ), + Probe::DnsRecords => ( + dns_records_outcome(&self.opa.policy_dns_eligibility_snapshot()), + dns_records_outcome(&self.cedar.policy_dns_eligibility_snapshot()), + ), + Probe::TransparentOpen { + name, + port, + binary, + answers, + } => { + let open = |engine: crate::policy_engine::PolicyEngine| { + test_runtime().block_on(transparent_open_outcome( + engine, name, *port, binary, answers, + )) + }; + ( + open(Arc::clone(&self.opa).into()), + open(Arc::clone(&self.cedar).into()), + ) + } + Probe::DnsAnswers { + name, + port, + answers, + } => { + let pinned = |engine: crate::policy_engine::PolicyEngine| { + test_runtime().block_on(dns_answers_outcome(engine, name, *port, answers)) + }; + ( + pinned(Arc::clone(&self.opa).into()), + pinned(Arc::clone(&self.cedar).into()), + ) + } + Probe::Destination { input, addresses } => { + let outcome = |engine: crate::policy_engine::PolicyEngine| { + crate::proxy::connect_destination_outcome_for_test(&engine, input, addresses) + }; + ( + outcome(Arc::clone(&self.opa).into()), + outcome(Arc::clone(&self.cedar).into()), + ) + } + Probe::Owners { ctx, request } => { + let ctx = ctx + .as_deref() + .cloned() + .unwrap_or_else(|| l7_ctx(host, 443, BINARY)); + let owners = |tunnel: miette::Result| { + tunnel.and_then(|tunnel| { + crate::l7::relay::admitted_token_grant_owners(&tunnel, &ctx, request) + }) + }; + let yaml = owners( + self.opa + .clone_engine_for_tunnel(self.opa.current_generation()), + ); + let cedar = owners( + crate::policy_engine::PolicyEngine::from(Arc::clone(&self.cedar)) + .tunnel_engine(self.cedar.current_generation()), + ); + (owners_outcome(&yaml), owners_outcome(&cedar)) + } + Probe::Configs(input) => ( + configs_outcome(&self.opa.authorize_egress(input)), + configs_outcome(&self.cedar.authorize_egress(input)), + ), + Probe::TlsMode(input) => ( + tls_mode_outcome(&self.opa.authorize_egress(input)), + tls_mode_outcome(&self.cedar.authorize_egress(input)), + ), + Probe::DenyReason { ctx, request } => { + let ctx = ctx + .as_deref() + .cloned() + .unwrap_or_else(|| l7_ctx(host, 443, BINARY)); + let yaml = self + .opa + .clone_engine_for_tunnel(self.opa.current_generation()) + .and_then(|tunnel| tunnel.evaluate_request(&ctx, request)); + let cedar = self + .cedar + .l7_handle(self.cedar.current_generation()) + .evaluate_request(&ctx, request); + (deny_reason_outcome(&yaml), deny_reason_outcome(&cedar)) + } + Probe::Relay(exchange) => self.relayed(&exchange.ctx, |relay| { + relay.run( + exchange.route_selected, + &exchange.request, + &exchange.upstream_response, + exchange.marker, + ) + }), + Probe::WebSocket(exchange) => self.relayed(&exchange.ctx, |relay| { + relay.run_websocket( + exchange.route_selected, + &exchange.upgrade, + &exchange.message, + ) + }), + } + } +} + +impl Engines { + /// Returns the `(yaml, cedar)` outcomes of `exchange` over a connection + /// from `ctx.binary_path` to `ctx.host:ctx.port`. + fn relayed( + &self, + ctx: &L7EvalContext, + exchange: impl Fn(PreparedRelay) -> String, + ) -> (String, String) { + let input = network(&ctx.host, ctx.port, &ctx.binary_path); + let yaml = PreparedRelay::prepare( + self.opa.authorize_egress(&input), + |generation| self.opa.clone_engine_for_tunnel(generation), + ctx, + ) + .map_or_else(|outcome| outcome, &exchange); + let cedar = PreparedRelay::prepare( + self.cedar.authorize_egress(&input), + |generation| { + crate::policy_engine::PolicyEngine::from(Arc::clone(&self.cedar)) + .tunnel_engine(generation) + }, + ctx, + ) + .map_or_else(|outcome| outcome, &exchange); + (yaml, cedar) + } +} + +fn configs_outcome(result: &miette::Result) -> String { + match result { + Ok(authorization) if matches!(authorization.action, NetworkAction::Allow { .. }) => { + let mut summaries: Vec = authorization + .endpoint_configs + .iter() + .filter_map(crate::l7::parse_l7_config) + .map(|config| { + let versions: Vec<&str> = config + .mcp_versions + .iter() + .map(|version| version.as_str()) + .collect(); + format!( + "{:?}/{:?}/tls={:?}/mcp=[{}]/strict={}/slash={}/jsonrpc={}/graphql={}", + config.protocol, + config.enforcement, + config.tls, + versions.join(","), + config.mcp_strict_tool_names, + config.allow_encoded_slash, + config.json_rpc_max_body_bytes, + config.graphql_max_body_bytes, + ) + }) + .collect(); + summaries.sort(); + summaries.dedup(); + if summaries.is_empty() { + "none".to_string() + } else { + summaries.join(";") + } + } + _ => "deny".to_string(), + } +} + +fn tls_mode_outcome(result: &miette::Result) -> String { + match result { + Ok(authorization) if matches!(authorization.action, NetworkAction::Allow { .. }) => { + let mode = authorization + .endpoint_configs + .first() + .map_or(crate::l7::TlsMode::Auto, crate::l7::parse_tls_mode); + format!("{mode:?}") + } + _ => "deny".to_string(), + } +} + +fn deny_reason_outcome(result: &miette::Result<(bool, String)>) -> String { + match result { + Ok((true, _)) => "allow".to_string(), + Ok((false, reason)) => format!("deny: {reason}"), + Err(error) => format!("deny (error: {})", error_line(error)), + } +} + +/// An allowed, inspected connection ready to relay one exchange. +struct PreparedRelay { + configs: Vec, + engine: crate::opa::TunnelPolicyEngine, + ctx: L7EvalContext, +} + +impl PreparedRelay { + /// Prepares the relay for one engine's connection decision, or returns + /// the outcome when the connection is denied or not inspected. + fn prepare( + authorization: miette::Result, + tunnel: impl FnOnce(u64) -> miette::Result, + ctx: &L7EvalContext, + ) -> Result { + let authorization = + authorization.map_err(|error| format!("deny (error: {})", error_line(&error)))?; + let NetworkAction::Allow { matched_policy } = &authorization.action else { + return Err("deny".to_string()); + }; + let configs: Vec<_> = authorization + .endpoint_configs + .iter() + .filter_map(crate::l7::parse_l7_config) + .collect(); + if configs.is_empty() { + return Err("passthrough".to_string()); + } + let engine = tunnel(authorization.generation) + .map_err(|error| format!("deny (error: {})", error_line(&error)))?; + Ok(Self { + configs, + engine, + ctx: L7EvalContext { + policy_name: matched_policy.clone().unwrap_or_default(), + request_default_port: Some(ctx.port), + ..ctx.clone() + }, + }) + } + + /// Runs one HTTP/1 exchange; see [`Probe::Relay`] for the outcome. + fn run( + self, + route_selected: bool, + request: &str, + upstream_response: &str, + marker: Option<&str>, + ) -> String { + let Some((response, forwarded)) = + test_runtime().block_on(run_relay(self, route_selected, request, upstream_response)) + else { + return "timeout".to_string(); + }; + let status = response.split(' ').nth(1).unwrap_or("none"); + let delivery = if forwarded.is_empty() { + "blocked" + } else { + "forwarded" + }; + match marker { + Some(marker) if response.contains(marker) => format!("{status}/{delivery}/marker"), + Some(_) => format!("{status}/{delivery}/no-marker"), + None => format!("{status}/{delivery}"), + } + } + + /// Runs one WebSocket session; see [`Probe::WebSocket`] for the outcome. + fn run_websocket(self, route_selected: bool, upgrade: &str, message: &str) -> String { + test_runtime() + .block_on(run_websocket(self, route_selected, upgrade, message)) + .unwrap_or_else(|| "timeout".to_string()) + } + + /// Spawns the relay between `client` and `upstream`, choosing the relay + /// mode as the proxy does. + fn spawn( + self, + route_selected: bool, + mut client: tokio::io::DuplexStream, + mut upstream: tokio::io::DuplexStream, + ) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + if route_selected || self.configs.len() > 1 { + crate::l7::relay::relay_with_route_selection( + &self.configs, + self.engine, + &mut client, + &mut upstream, + &self.ctx, + ) + .await + } else { + crate::l7::relay::relay_with_inspection( + &self.configs[0], + self.engine, + &mut client, + &mut upstream, + &self.ctx, + ) + .await + } + }) + } +} + +fn test_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime builds") +} + +/// How long one relay step may take before the exchange counts as stuck. +const RELAY_LIMIT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Relays `request` and returns the client response and the bytes the +/// upstream received, or `None` if the exchange did not finish. +async fn run_relay( + relay: PreparedRelay, + route_selected: bool, + request: &str, + upstream_response: &str, +) -> Option<(String, Vec)> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (mut app, relay_client) = tokio::io::duplex(65536); + let (relay_upstream, mut upstream) = tokio::io::duplex(65536); + let relay = relay.spawn(route_selected, relay_client, relay_upstream); + let upstream_response = upstream_response.to_string(); + let server = tokio::spawn(async move { + let mut forwarded = Vec::new(); + let mut bytes = [0; 4096]; + let mut answered = false; + loop { + let Ok(count) = upstream.read(&mut bytes).await else { + return forwarded; + }; + if count == 0 { + return forwarded; + } + forwarded.extend_from_slice(&bytes[..count]); + if !answered && http_message_complete(&forwarded) { + answered = true; + if upstream + .write_all(upstream_response.as_bytes()) + .await + .is_err() + { + return forwarded; + } + } + } + }); + app.write_all(request.as_bytes()).await.ok()?; + let mut response = Vec::new(); + let read = tokio::time::timeout(RELAY_LIMIT, async { + let mut bytes = [0; 4096]; + loop { + match app.read(&mut bytes).await { + Ok(0) | Err(_) => break, + Ok(count) => response.extend_from_slice(&bytes[..count]), + } + if http_message_complete(&response) { + break; + } + } + }) + .await; + drop(app); + read.ok()?; + let _ = tokio::time::timeout(RELAY_LIMIT, relay).await.ok()?; + let forwarded = tokio::time::timeout(RELAY_LIMIT, server).await.ok()?.ok()?; + Some((String::from_utf8_lossy(&response).into_owned(), forwarded)) +} + +/// Runs a WebSocket upgrade and one client text message through the relay. +/// +/// Returns `None` if the session did not finish. +async fn run_websocket( + relay: PreparedRelay, + route_selected: bool, + upgrade: &str, + message: &str, +) -> Option { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// The upstream's answer to the upgrade. The accept key is not checked. + const SWITCHING: &str = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; + /// What the upstream sends back once the message arrives, so the client + /// knows it was forwarded. + const ACK: &[u8] = b"\x81\x03ack"; + + let frame = masked_text_frame(message.as_bytes()); + let (mut app, relay_client) = tokio::io::duplex(65536); + let (relay_upstream, mut upstream) = tokio::io::duplex(65536); + let relay = relay.spawn(route_selected, relay_client, relay_upstream); + let expected = frame.clone(); + let server = tokio::spawn(async move { + let mut seen = Vec::new(); + let mut bytes = [0; 4096]; + let (mut upgraded, mut acked) = (false, false); + loop { + let Ok(count) = upstream.read(&mut bytes).await else { + return seen; + }; + if count == 0 { + return seen; + } + seen.extend_from_slice(&bytes[..count]); + if !upgraded && seen.windows(4).any(|window| window == b"\r\n\r\n") { + upgraded = true; + if upstream.write_all(SWITCHING.as_bytes()).await.is_err() { + return seen; + } + } + if !acked && contains(&seen, &expected) { + acked = true; + if upstream.write_all(ACK).await.is_err() { + return seen; + } + } + } + }); + + app.write_all(upgrade.as_bytes()).await.ok()?; + let mut response = Vec::new(); + let mut byte = [0; 1]; + while !response.ends_with(b"\r\n\r\n") { + match tokio::time::timeout(RELAY_LIMIT, app.read(&mut byte)).await { + Ok(Ok(1)) => response.push(byte[0]), + Ok(_) => break, + Err(_) => return None, + } + } + let response = String::from_utf8_lossy(&response).into_owned(); + let status = response.split(' ').nth(1).unwrap_or("none").to_string(); + if status == "101" { + app.write_all(&frame).await.ok()?; + // The upstream's acknowledgement, or the relay's close and EOF. + let mut reply = [0; 64]; + tokio::time::timeout(RELAY_LIMIT, app.read(&mut reply)) + .await + .ok()? + .ok()?; + } else { + // A refusal closes the connection after its body. + let mut rest = Vec::new(); + let _ = tokio::time::timeout(RELAY_LIMIT, app.read_to_end(&mut rest)).await; + } + drop(app); + let _ = tokio::time::timeout(RELAY_LIMIT, relay).await.ok()?; + let seen = tokio::time::timeout(RELAY_LIMIT, server).await.ok()?.ok()?; + Some(if status != "101" { + format!("{status}/blocked") + } else if contains(&seen, &frame) { + "101/message-forwarded".to_string() + } else { + "101/message-blocked".to_string() + }) +} + +/// Returns a client-to-server (masked) WebSocket text frame for `payload`. +fn masked_text_frame(payload: &[u8]) -> Vec { + const MASK: [u8; 4] = [0x37, 0xfa, 0x21, 0x3d]; + let mut frame = vec![0x81]; + let length = u16::try_from(payload.len()).expect("test messages fit in a 16-bit length"); + match u8::try_from(length) { + Ok(short) if short < 126 => frame.push(0x80 | short), + _ => { + frame.push(0x80 | 0x7e); + frame.extend_from_slice(&length.to_be_bytes()); + } + } + frame.extend_from_slice(&MASK); + frame.extend( + payload + .iter() + .zip(MASK.iter().cycle()) + .map(|(byte, mask)| byte ^ mask), + ); + frame +} + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +/// Returns whether `bytes` hold a complete HTTP/1 message with a +/// `Content-Length` body (or none). +fn http_message_complete(bytes: &[u8]) -> bool { + let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let header = String::from_utf8_lossy(&bytes[..header_end]); + let length = header + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + bytes.len() >= header_end + 4 + length +} + +fn request_outcome(result: &miette::Result) -> String { + match result { + Ok(allowed) => allow_or_deny(*allowed), + Err(error) => format!("deny (error: {})", error_line(error)), + } +} + +fn connect_outcome(result: &miette::Result) -> String { + match result { + Ok(authorization) => { + allow_or_deny(matches!(authorization.action, NetworkAction::Allow { .. })) + } + Err(error) => format!("deny (error: {})", error_line(error)), + } +} + +fn exact_host_outcome(result: &miette::Result) -> String { + match result { + Ok(authorization) if matches!(authorization.action, NetworkAction::Allow { .. }) => { + if authorization.exact_declared_endpoint_host { + "exact".to_string() + } else { + "not-exact".to_string() + } + } + _ => "deny".to_string(), + } +} + +fn inspection_outcome(result: &miette::Result) -> String { + match result { + Ok(authorization) if matches!(authorization.action, NetworkAction::Allow { .. }) => { + let mut pairs: Vec = authorization + .endpoint_configs + .iter() + .filter_map(crate::l7::parse_l7_config) + .map(|config| format!("{:?}/{:?}", config.protocol, config.enforcement)) + .collect(); + pairs.sort(); + pairs.dedup(); + if pairs.is_empty() { + "none".to_string() + } else { + pairs.join(",") + } + } + _ => "deny".to_string(), + } +} + +fn owners_outcome(result: &miette::Result>) -> String { + match result { + Ok(owners) if owners.is_empty() => "none".to_string(), + Ok(owners) => { + let mut owners: Vec<_> = owners.iter().map(String::as_str).collect(); + owners.sort_unstable(); + owners.join(",") + } + Err(error) => format!("none (error: {})", error_line(error)), + } +} + +fn dns_outcome( + snapshot: &miette::Result, + name: &str, + port: u16, +) -> String { + let Ok(snapshot) = snapshot else { + return "ineligible (error)".to_string(); + }; + let eligible = !snapshot.fail_closed + && snapshot.endpoints.iter().any(|matched| { + let endpoint = &matched.endpoint; + let host = endpoint.host.trim_end_matches('.').to_ascii_lowercase(); + let ports_match = endpoint.ports.contains(&port); + ports_match + && !host.is_empty() + && openshell_core::host_pattern::HostSelector::new(&[host], &[]) + .is_ok_and(|selector| selector.matches(name)) + }); + if eligible { "eligible" } else { "ineligible" }.to_string() +} + +fn dns_records_outcome( + snapshot: &miette::Result, +) -> String { + match snapshot { + Ok(snapshot) => records_outcome(&snapshot.endpoints), + Err(error) => format!("none (error: {})", error_line(error)), + } +} + +/// Summarizes endpoint records as sorted `host:ports` entries, with `/tcp` +/// for a native TCP record, joined by `;`, or `none`. +fn records_outcome(records: &[crate::opa::MatchedEndpoint]) -> String { + let mut summaries: Vec = records + .iter() + .map(|record| { + let endpoint = &record.endpoint; + let ports: Vec = endpoint.ports.iter().map(ToString::to_string).collect(); + let tcp = endpoint.protocol == crate::policy_types::EndpointProtocol::Tcp; + format!( + "{}:{}{}", + endpoint.host, + ports.join(","), + if tcp { "/tcp" } else { "" } + ) + }) + .collect(); + summaries.sort(); + if summaries.is_empty() { + "none".to_string() + } else { + summaries.join(";") + } +} + +fn matched_endpoints_outcome( + result: &miette::Result, + snapshot: &miette::Result, +) -> String { + let authorization = match result { + Ok(authorization) if matches!(authorization.action, NetworkAction::Allow { .. }) => { + authorization + } + Ok(_) => return "deny".to_string(), + Err(error) => return format!("deny (error: {})", error_line(error)), + }; + let records = records_outcome(&authorization.matched_endpoints); + let in_snapshot = snapshot.as_ref().is_ok_and(|snapshot| { + authorization.matched_endpoints.iter().all(|matched| { + snapshot.endpoints.iter().any(|published| { + published.policy_name == matched.policy_name + && published.endpoint_index == matched.endpoint_index + && published.endpoint == matched.endpoint + }) + }) + }); + if in_snapshot { + records + } else { + format!("{records} (not in DNS snapshot)") + } +} + +/// The upstream address every name resolves to for [`Probe::TransparentOpen`] +/// cases that do not name their answers. +const TRANSPARENT_UPSTREAM: std::net::Ipv4Addr = std::net::Ipv4Addr::new(203, 0, 113, 8); + +/// A trusted resolver that answers every name with the same addresses. +struct FixedResolver(Vec); + +impl crate::policy_dns::TrustedResolver for FixedResolver { + async fn resolve( + &self, + _name: &crate::policy_dns::NormalizedName, + _family: crate::policy_dns::AddressFamily, + ) -> Result { + Ok(crate::policy_dns::TrustedAnswer { + addresses: self.0.clone(), + ttl: std::time::Duration::from_secs(30), + }) + } +} + +/// Resolves `name` through `engine`'s policy DNS, with the trusted resolver +/// answering `answers`, and returns the store holding the mapping. +async fn policy_dns_answer( + engine: &crate::policy_engine::PolicyEngine, + name: &str, + answers: &[IpAddr], +) -> Result< + ( + crate::policy_dns::SyntheticAnswer, + Arc, + ), + String, +> { + use crate::policy_dns::{ + AddressFamily, PolicyDnsService, ResolvedEndpointStore, StoreConfig, SyntheticPools, + }; + use std::net::{Ipv4Addr, Ipv6Addr}; + + // Like the production pools, skip the reserved sandbox-local address. + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 2)..=Ipv4Addr::new(198, 18, 0, 9), + Ipv6Addr::new(0xfd00, 1, 0, 0, 0, 0, 0, 1)..=Ipv6Addr::new(0xfd00, 1, 0, 0, 0, 0, 0, 8), + ) + .expect("test pools are valid"); + let store = Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(pools, 16).expect("test store config is valid"), + )); + let dns = PolicyDnsService::new( + engine.clone(), + FixedResolver(answers.to_vec()), + Arc::clone(&store), + None, + ); + dns.answer_query(name, AddressFamily::Ipv4, std::time::Instant::now()) + .await + .map(|answer| (answer, store)) + .map_err(|error| format!("dns refused: {error}")) +} + +/// Returns the addresses policy DNS pins for `name` on `port`; see +/// [`Probe::DnsAnswers`]. +async fn dns_answers_outcome( + engine: crate::policy_engine::PolicyEngine, + name: &str, + port: u16, + answers: &[IpAddr], +) -> String { + let (answer, store) = match policy_dns_answer(&engine, name, answers).await { + Ok(resolved) => resolved, + Err(refusal) => return refusal, + }; + let Ok(mapping) = store.lookup( + answer.address, + port, + answer.policy_generation, + std::time::Instant::now(), + ) else { + return "no mapping for port".to_string(); + }; + let mut pinned: Vec = mapping + .pinned_addresses() + .iter() + .map(ToString::to_string) + .collect(); + pinned.sort(); + pinned.join(",") +} + +/// Resolves `name` through `engine`'s policy DNS and stages a transparent +/// open to the answer; see [`Probe::TransparentOpen`]. +async fn transparent_open_outcome( + engine: crate::policy_engine::PolicyEngine, + name: &str, + port: u16, + binary: &str, + answers: &[IpAddr], +) -> String { + let (answer, store) = match policy_dns_answer(&engine, name, answers).await { + Ok(resolved) => resolved, + Err(refusal) => return refusal, + }; + let destination = std::net::SocketAddr::new(answer.address, port); + let outcome = + crate::proxy::stage_transparent_open_for_test(&engine, &store, destination, binary).await; + if outcome.dial_addresses.is_empty() { + return format!("{:?}", outcome.decision); + } + let addresses: Vec = outcome + .dial_addresses + .iter() + .map(ToString::to_string) + .collect(); + format!( + "{:?}/{}/{}", + outcome.decision, + addresses.join(","), + if outcome.correlated { + "correlated" + } else { + "uncorrelated" + } + ) +} + +/// The marker an outcome carries when the engine reported an evaluation +/// error instead of deciding; see [`Engines::outcomes`]. +const ERROR_MARKER: &str = " (error"; + +/// Running counts of every report row printed so far in this test process. +/// +/// Scenario tests run in parallel, so each report prints the cumulative +/// tally; the last tally printed holds the totals for the whole run. +static TALLY: Tally = Tally::new(); + +struct Tally { + cases: AtomicUsize, + same: AtomicUsize, + known_differences: AtomicUsize, +} + +impl Tally { + const fn new() -> Self { + Self { + cases: AtomicUsize::new(0), + same: AtomicUsize::new(0), + known_differences: AtomicUsize::new(0), + } + } + + /// Adds one report's rows and returns its counts with the cumulative line. + /// + /// Every row is one case; a row ending in `same` agreed, and a row with + /// a `known difference:` result diverged as expected. The rest failed. + fn record(&self, report: &str) -> String { + let rows: Vec<&str> = report + .lines() + .filter(|line| line.starts_with("| ")) + .collect(); + let cases = rows.len(); + let same = rows.iter().filter(|row| row.ends_with("| same |")).count(); + let known_differences = rows + .iter() + .filter(|row| row.contains("| known difference:")) + .count(); + let total_cases = self.cases.fetch_add(cases, Ordering::Relaxed) + cases; + let total_same = self.same.fetch_add(same, Ordering::Relaxed) + same; + let total_known = self + .known_differences + .fetch_add(known_differences, Ordering::Relaxed) + + known_differences; + format!( + "this report: {cases} cases, {same} same, {known_differences} known differences\n\ + parity tally: {total_cases} cases, {total_same} same, {total_known} known \ + differences (cumulative across the reports printed so far in this process)" + ) + } +} + +/// Runs a scenario's cases and returns its report rows and any failures. +fn run(name: &str, host: &str, engines: &Engines, cases: &[Case]) -> (String, Vec) { + let mut report = String::new(); + let mut failures = Vec::new(); + for case in cases { + let (yaml, cedar) = engines.outcomes(host, &case.probe); + // The outcome collapses an evaluation error into the decision the + // proxy makes on it (a denial), with the error noted after a marker. + let decision = |outcome: &str| { + outcome + .split(ERROR_MARKER) + .next() + .unwrap_or_default() + .to_string() + }; + // Compare decisions, but an error on either side is a failure in its + // own right unless the case expects one: an erroring Cedar policy + // must not pass as agreeing with YAML on the deny cases. + if !case.allow_error && (yaml.contains(ERROR_MARKER) || cedar.contains(ERROR_MARKER)) { + failures.push(format!( + "{name} / {}: evaluation error: YAML {yaml} / Cedar {cedar}", + case.name + )); + } + if let Some(expected) = case.yaml + && decision(&yaml) != expected + { + failures.push(format!( + "{name} / {}: the ported test asserts YAML {expected} but YAML {yaml}", + case.name + )); + } + let outcome = match (case.expect, decision(&yaml) == decision(&cedar)) { + (Expect::Same, true) => "same".to_string(), + (Expect::Diverges(reason), false) => format!("known difference: {reason}"), + (Expect::Same, false) => { + failures.push(format!( + "{name} / {}: YAML {yaml} but Cedar {cedar}", + case.name + )); + "MISMATCH".to_string() + } + (Expect::Diverges(reason), true) => { + failures.push(format!( + "{name} / {}: expected a difference ({reason}) but both {yaml}", + case.name + )); + "UNEXPECTEDLY SAME".to_string() + } + }; + let _ = writeln!( + report, + "| {name} | {} | {yaml} | {cedar} | {outcome} |", + case.name + ); + } + (report, failures) +} + +/// Asserts that Cedar refuses `cedar` at load with an error naming `reason`. +/// +/// For a Cedar form whose YAML meaning differs from its Cedar meaning, which +/// Cedar rejects so no sandbox runs it. Pair it with a scenario whose Cedar +/// policy uses the accepted form. +fn assert_cedar_rejects(name: &str, cedar: &str, reason: &str) { + // The supervisor's engine redacts load errors; the policy engine it + // wraps names the rejected form. + let error = openshell_policy_cedar::CedarEngine::from_policy_str(cedar) + .err() + .unwrap_or_else(|| panic!("{name}: Cedar rejects the policy at load")); + let text = error.to_string(); + assert!( + text.contains(reason), + "{name}: the rejection names {reason:?}: {text}" + ); + assert!( + CedarOnlyEngine::from_policy_str(cedar).is_err(), + "{name}: the supervisor engine rejects the policy at load" + ); + println!("{name}: Cedar rejects the policy at load: {reason}"); +} + +fn assert_parity(scenario: &Scenario<'_>) { + let engines = Engines::load(scenario); + report_parity(run(scenario.name, scenario.host, &engines, &scenario.cases)); +} + +/// Runs a scenario's cases at startup, and again after each engine reloads +/// the same policy, for tests that check decisions survive a reload. +/// +/// Reloading an unchanged Cedar source keeps the loaded engine, while YAML +/// rebuilds its engine; both must still agree. +fn assert_parity_across_reload(scenario: &Scenario<'_>) { + let engines = Engines::load(scenario); + let startup = format!("{} (startup)", scenario.name); + let (mut report, mut failures) = run(&startup, scenario.host, &engines, &scenario.cases); + engines + .opa + .reload(REGO, &delivered_yaml(scenario.name, scenario.yaml)) + .unwrap_or_else(|error| panic!("{}: YAML policy reloads: {error}", scenario.name)); + engines + .cedar + .reload_from_policy_str(scenario.cedar) + .unwrap_or_else(|error| panic!("{}: Cedar policy reloads: {error}", scenario.name)); + let reload = format!("{} (reload)", scenario.name); + let (reloaded, reload_failures) = run(&reload, scenario.host, &engines, &scenario.cases); + report.push_str(&reloaded); + failures.extend(reload_failures); + report_parity((report, failures)); +} + +fn assert_settings_parity(scenario: &SettingsScenario<'_>) { + let engines = Engines::load_settings(scenario); + report_parity(run(scenario.name, scenario.host, &engines, &scenario.cases)); +} + +fn assert_provider_parity(scenario: &ProviderScenario<'_>) { + let engines = Engines::load_providers(scenario); + report_parity(run(scenario.name, scenario.host, &engines, &scenario.cases)); +} + +/// Prints a report with its counts and the cumulative tally, then fails on +/// any failure. +fn report_parity((report, failures): (String, Vec)) { + let tally = TALLY.record(&report); + println!( + "| Scenario | Case | YAML | Cedar | Result |\n|---|---|---|---|---|\n{report}\n{tally}\n" + ); + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_a.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_a.rs new file mode 100644 index 0000000000..99feabb66f --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_a.rs @@ -0,0 +1,1662 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML decision tests from `opa.rs`, first half. +//! +//! Each case is named after the `opa.rs` test it ports. A YAML binary entry +//! also matches an ancestor of the calling process, so an exact binary is +//! translated as `context.binary_path == B || context.ancestors.contains(B)`. +//! Cedar cannot glob over the members of a set, so a glob binary only checks +//! `context.binary_path`; no ported glob case sets ancestors. +//! +//! A YAML MCP endpoint also allows its receive stream and client response +//! frames on the endpoint path, so every MCP translation adds the explicit +//! permit the Cedar docs require for those. + +use std::fmt::Write as _; + +use super::*; + +/// A request probe for `host:port` from [`BINARY`]. +fn at(host: &str, port: u16, request: L7RequestInfo) -> Probe { + request_in(l7_ctx(host, port, BINARY), request) +} + +/// A connection that also carries `/proc//cmdline` paths. +fn with_cmdline(mut input: NetworkInput, cmdline_paths: &[&str]) -> NetworkInput { + input.cmdline_paths = cmdline_paths.iter().map(PathBuf::from).collect(); + input +} + +/// A single generic JSON-RPC call posted to `path`. +fn rpc(path: &str, method: &str) -> L7RequestInfo { + jsonrpc_request(path, vec![call(method, None, None)]) +} + +/// A generic JSON-RPC call whose params are visible to policy. +fn rpc_with_params(path: &str, method: &str, params: &[(&str, &str)]) -> L7RequestInfo { + let mut call = call(method, None, None); + call.params = params + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(); + jsonrpc_request(path, vec![call]) +} + +/// An MCP `tools/call` whose params carry `name` plus extra arguments. +fn mcp_tool_call(tool: &str, arguments: &[(&str, &str)]) -> L7RequestInfo { + let mut call = call( + "tools/call", + Some(tool), + Some(McpMethodClassification::Available), + ); + for (key, value) in arguments { + call.params.insert((*key).to_string(), (*value).to_string()); + } + jsonrpc_request("/mcp", vec![call]) +} + +/// A bodyless `method` request that carries JSON-RPC metadata with no call. +fn jsonrpc_frame( + method: &str, + path: &str, + receive_stream: bool, + has_response: bool, +) -> L7RequestInfo { + L7RequestInfo { + jsonrpc: Some(JsonRpcRequestInfo { + receive_stream, + has_response, + ..jsonrpc_info(Vec::new()) + }), + ..rest(method, path) + } +} + +/// An MCP request the parser rejected with an inspection error. +/// +/// The parser rejects a method that is unavailable in the selected revision +/// with an inspection error before policy runs, which is how both engines +/// receive it. That error cannot be built outside the parser, so this uses +/// the error the parser returns for a call sent before a revision is +/// selected; both engines treat every inspection error alike. +fn mcp_rejected(method: &str) -> L7RequestInfo { + let body = format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#); + let info = crate::l7::jsonrpc::parse_jsonrpc_body( + body.as_bytes(), + crate::l7::jsonrpc::JsonRpcInspectionMode::Mcp, + ); + assert!(info.error.is_some(), "the parser rejects `{method}`"); + L7RequestInfo { + jsonrpc: Some(info), + ..rest("POST", "/mcp") + } +} + +/// The Cedar condition for a YAML binary entry with an exact path. +fn binary(path: &str) -> String { + format!("(context.binary_path == \"{path}\" || context.ancestors.contains(\"{path}\"))") +} + +/// The Cedar condition for a YAML binary entry with a glob path. +fn binary_glob(pattern: &str) -> String { + format!("context.binary_path like(\"{pattern}\", \"/\")") +} + +/// A `NetworkConnect` permit for `host:port` under `condition`. +fn connect_permit(cedar: &mut String, endpoint: &str, condition: &str) { + let _ = write!( + cedar, + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{endpoint}") +when {{ {condition} }}; +"# + ); +} + +/// The MCP receive-stream and response-frame permit for `endpoint` and `path`. +fn mcp_frames_permit(endpoint: &str, path: &str, condition: &str) -> String { + format!( + r#" +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{endpoint}") +when {{ + {condition} + && context.path == "{path}" + && ((context.method == "GET" && context.jsonrpc_receive_stream) || context.jsonrpc_response) +}}; +"# + ) +} + +// --------------------------------------------------------------------------- +// testdata/sandbox-policy.yaml (`test_engine()`) +// --------------------------------------------------------------------------- + +const DEFAULT_FIXTURE_YAML: &str = include_str!("../../testdata/sandbox-policy.yaml"); + +/// The distinct `host:443` endpoints of the default fixture (15 endpoints, +/// `github.com` declared twice). +const DEFAULT_FIXTURE_HOSTS: [(&str, &str); 14] = [ + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/api.anthropic.com", + "api.anthropic.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/statsig.anthropic.com", + "statsig.anthropic.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/github.com", + "github.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/api.github.com", + "api.github.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/api.githubcopilot.com", + "api.githubcopilot.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/api.individual.githubcopilot.com", + "api.individual.githubcopilot.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/api.business.githubcopilot.com", + "api.business.githubcopilot.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/api.enterprise.githubcopilot.com", + "api.enterprise.githubcopilot.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/copilot-proxy.githubusercontent.com", + "copilot-proxy.githubusercontent.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/copilot-telemetry.githubusercontent.com", + "copilot-telemetry.githubusercontent.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/default.exp-tas.com", + "default.exp-tas.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/origin-tracker.githubusercontent.com", + "origin-tracker.githubusercontent.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/release-assets.githubusercontent.com", + "release-assets.githubusercontent.com", + ), + ( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/gitlab.com", + "gitlab.com", + ), +]; + +/// The Cedar translation of `testdata/sandbox-policy.yaml`. +fn default_fixture_cedar() -> String { + let mut cedar = String::new(); + + // claude_code + let claude_code = format!( + "{} || {}", + binary("/usr/local/bin/claude"), + binary("/usr/bin/node") + ); + for endpoint in ["api.anthropic.com:443", "statsig.anthropic.com:443"] { + connect_permit(&mut cedar, endpoint, &claude_code); + } + + // github_ssh_over_https: a REST endpoint; a YAML GET rule also allows HEAD. + let git = binary("/usr/bin/git"); + connect_permit(&mut cedar, "github.com:443", &git); + let _ = write!( + cedar, + r#" +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"github.com:443") +when {{ + {git} + && ((["GET", "HEAD"].contains(context.method) && context.path like("/**/info/refs*", "/")) + || (context.method == "POST" && context.path like("/**/git-upload-pack", "/"))) +}}; +"# + ); + + // copilot + let copilot = [ + binary_glob("/usr/lib/node_modules/@github/copilot/node_modules/@github/**/copilot"), + binary("/usr/local/bin/copilot"), + binary_glob("/home/*/.local/bin/copilot"), + binary("/usr/bin/node"), + ] + .join(" || "); + for (_, host) in &DEFAULT_FIXTURE_HOSTS[2..13] { + connect_permit(&mut cedar, &format!("{host}:443"), &copilot); + } + + // gitlab + connect_permit(&mut cedar, "gitlab.com:443", &binary("/usr/bin/glab")); + cedar +} + +#[test] +fn ported_default_fixture_connections() { + let cedar = default_fixture_cedar(); + let mut cases = vec![ + same_probe( + "opa.rs::allowed_binary_and_endpoint", + Probe::Connect(with_cmdline( + network("api.anthropic.com", 443, "/usr/bin/node"), + &["/usr/local/bin/claude"], + )), + ), + same_probe( + "opa.rs::from_strings_and_from_files_produce_same_results", + Probe::Connect(with_cmdline( + network("api.anthropic.com", 443, "/usr/bin/node"), + &["/usr/local/bin/claude"], + )), + ), + same_probe( + "opa.rs::wrong_binary_denied", + Probe::Connect(network("api.anthropic.com", 443, "/usr/bin/python3")), + ), + same_probe( + "opa.rs::wrong_endpoint_denied", + Probe::Connect(network("evil.example.com", 443, "/usr/bin/node")), + ), + same_probe( + "opa.rs::unknown_binary_default_deny", + Probe::Connect(network("api.anthropic.com", 443, "/tmp/malicious")), + ), + same_probe( + "opa.rs::github_policy_allows_git", + Probe::Connect(network("github.com", 443, "/usr/bin/git")), + ), + same_probe( + "opa.rs::case_insensitive_host_matching", + Probe::Connect(with_cmdline( + network("API.ANTHROPIC.COM", 443, "/usr/bin/node"), + &["/usr/local/bin/claude"], + )), + ), + same_probe( + "opa.rs::wrong_port_denied", + Probe::Connect(network("api.anthropic.com", 80, "/usr/bin/node")), + ), + same_probe( + "opa.rs::ancestor_binary_allowed", + Probe::Connect(network_with_ancestors( + "github.com", + 443, + "/usr/bin/python3", + &["/usr/bin/git"], + )), + ), + same_probe( + "opa.rs::no_ancestor_match_denied", + Probe::Connect(network_with_ancestors( + "github.com", + 443, + "/usr/bin/python3", + &["/usr/bin/bash"], + )), + ), + same_probe( + "opa.rs::deep_ancestor_chain_matches", + Probe::Connect(network_with_ancestors( + "github.com", + 443, + "/usr/bin/python3", + &["/usr/bin/sh", "/usr/bin/git"], + )), + ), + same_probe( + "opa.rs::empty_ancestors_falls_back_to_direct", + Probe::Connect(network("api.anthropic.com", 443, "/usr/local/bin/claude")), + ), + ]; + // The YAML test counts 15 snapshot endpoints; this checks that each + // declared host is eligible, and that an undeclared one is not. + cases.extend(DEFAULT_FIXTURE_HOSTS.iter().map(|(name, host)| { + same_probe( + *name, + Probe::DnsEligible { + name: (*host).to_string(), + port: 443, + }, + ) + })); + cases.push(same_probe( + "opa.rs::policy_dns_snapshot_accepts_the_default_multi_policy_shape/undeclared", + Probe::DnsEligible { + name: "evil.example.com".to_string(), + port: 443, + }, + )); + assert_parity(&Scenario { + name: "opa.rs default fixture", + host: "github.com", + yaml: DEFAULT_FIXTURE_YAML, + cedar: &cedar, + cases, + }); +} + +/// The network policies of `opa.rs::test_proto()`, as YAML. +#[test] +fn ported_from_proto_fixture() { + let claude = binary("/usr/local/bin/claude"); + let mut cedar = String::new(); + connect_permit(&mut cedar, "api.anthropic.com:443", &claude); + connect_permit(&mut cedar, "statsig.anthropic.com:443", &claude); + connect_permit(&mut cedar, "gitlab.com:443", &binary("/usr/bin/glab")); + assert_parity(&Scenario { + name: "opa.rs test_proto", + host: "api.anthropic.com", + yaml: r" +network_policies: + claude_code: + name: claude_code + endpoints: + - { host: api.anthropic.com, port: 443 } + - { host: statsig.anthropic.com, port: 443 } + binaries: + - { path: /usr/local/bin/claude } + gitlab: + name: gitlab + endpoints: + - { host: gitlab.com, port: 443 } + binaries: + - { path: /usr/bin/glab } +", + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::from_proto_allows_matching_request", + Probe::Connect(network("api.anthropic.com", 443, "/usr/local/bin/claude")), + ), + same_probe( + "opa.rs::from_proto_denies_unmatched_request", + Probe::Connect(network("evil.example.com", 443, "/usr/bin/curl")), + ), + ], + }); +} + +// --------------------------------------------------------------------------- +// Binary globs and cmdline paths +// --------------------------------------------------------------------------- + +#[test] +fn ported_binary_glob_usr_bin() { + let mut cedar = String::new(); + connect_permit(&mut cedar, "example.com:443", &binary_glob("/usr/bin/*")); + assert_parity(&Scenario { + name: "opa.rs glob /usr/bin/*", + host: "example.com", + yaml: r#" +network_policies: + glob_test: + name: glob_test + endpoints: + - { host: example.com, port: 443 } + binaries: + - { path: "/usr/bin/*" } +"#, + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::glob_pattern_matches_binary", + Probe::Connect(network("example.com", 443, "/usr/bin/node")), + ), + same_probe( + "opa.rs::glob_pattern_no_cross_segment", + Probe::Connect(network("example.com", 443, "/usr/bin/subdir/node")), + ), + ], + }); +} + +#[test] +fn ported_binary_glob_usr_local_bin() { + let mut cedar = String::new(); + connect_permit( + &mut cedar, + "example.com:443", + &binary_glob("/usr/local/bin/*"), + ); + assert_parity(&Scenario { + name: "opa.rs glob /usr/local/bin/*", + host: "example.com", + yaml: r#" +network_policies: + glob_test: + name: glob_test + endpoints: + - { host: example.com, port: 443 } + binaries: + - { path: "/usr/local/bin/*" } +"#, + cedar: &cedar, + cases: vec![same_probe( + "opa.rs::cmdline_glob_pattern_does_not_grant_access", + Probe::Connect(with_cmdline( + network("example.com", 443, "/usr/bin/node"), + &["/usr/local/bin/claude"], + )), + )], + }); +} + +#[test] +fn ported_cmdline_paths() { + let mut cedar = String::new(); + connect_permit( + &mut cedar, + "example.com:443", + &binary("/usr/local/bin/my-tool"), + ); + assert_parity(&Scenario { + name: "opa.rs cmdline paths", + host: "example.com", + yaml: r" +network_policies: + script_test: + name: script_test + endpoints: + - { host: example.com, port: 443 } + binaries: + - { path: /usr/local/bin/my-tool } +", + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::cmdline_path_does_not_grant_access", + Probe::Connect(with_cmdline( + network_with_ancestors("example.com", 443, "/usr/bin/node", &["/usr/bin/bash"]), + &["/usr/local/bin/my-tool"], + )), + ), + same_probe( + "control: declared binary connects", + Probe::Connect(network("example.com", 443, "/usr/local/bin/my-tool")), + ), + same_probe( + "opa.rs::cmdline_path_no_match_denied", + Probe::Connect(with_cmdline( + network_with_ancestors("example.com", 443, "/usr/bin/node", &["/usr/bin/bash"]), + &["/usr/bin/node", "/tmp/script.js"], + )), + ), + ], + }); +} + +#[test] +fn ported_wildcard_host() { + assert_parity(&Scenario { + name: "opa.rs wildcard host", + host: "sub.example.com", + yaml: r#" +network_policies: + wildcard_test: + name: wildcard_test + endpoints: + - host: "*.example.com" + port: 443 + binaries: + - path: /usr/bin/test +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.example.com", ".") + && resource.port == 443 + && (context.binary_path == "/usr/bin/test" || context.ancestors.contains("/usr/bin/test")) +}; +"#, + cases: vec![same_probe( + "opa.rs::wildcard_host_percent_encoded_dot_no_match", + Probe::Connect(network("evil%2eexample.com", 443, "/usr/bin/test")), + )], + }); +} + +// --------------------------------------------------------------------------- +// L7_TEST_DATA (`l7_engine()`) +// --------------------------------------------------------------------------- + +pub(super) const L7_TEST_DATA: &str = r#" +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.com + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/repos/**" + - allow: + method: POST + path: "/repos/*/issues" + binaries: + - { path: /usr/bin/curl } + readonly_api: + name: readonly_api + endpoints: + - host: api.readonly.com + port: 8080 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - { path: /usr/bin/curl } + full_api: + name: full_api + endpoints: + - host: api.full.com + port: 8080 + protocol: rest + enforcement: audit + access: full + binaries: + - { path: /usr/bin/curl } + query_api: + name: query_api + endpoints: + - host: api.query.com + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/download" + query: + tag: "foo-*" + - allow: + method: GET + path: "/search" + query: + tag: + any: ["foo-*", "bar-*"] + binaries: + - { path: /usr/bin/curl } + graphql_api: + name: graphql_api + endpoints: + - host: api.graphql.com + port: 443 + protocol: graphql + enforcement: enforce + persisted_queries: allow_registered + graphql_persisted_queries: + abc123: + operation_type: query + operation_name: Viewer + fields: [viewer] + rules: + - allow: + operation_type: query + fields: [viewer, repository] + - allow: + operation_type: mutation + operation_name: Issue* + fields: [createIssue, deleteRepository] + deny_rules: + - operation_type: mutation + fields: [deleteRepository] + binaries: + - { path: /usr/bin/curl } + graphql_readonly: + name: graphql_readonly + endpoints: + - host: gql.readonly.com + port: 443 + protocol: graphql + enforcement: enforce + access: read-only + binaries: + - { path: /usr/bin/curl } + graphql_ws: + name: graphql_ws + endpoints: + - host: realtime.graphql.com + ports: [443] + path: "/graphql" + protocol: websocket + enforcement: enforce + rules: + - allow: + method: GET + path: "/graphql" + - allow: + operation_type: query + fields: [viewer] + - allow: + operation_type: subscription + fields: [messageAdded] + deny_rules: + - operation_type: mutation + binaries: + - { path: /usr/bin/curl } + l4_only: + name: l4_only + endpoints: + - { host: l4only.example.com, port: 443 } + - { host: explicit-tcp.example.com, port: 443, protocol: tcp } + binaries: + - { path: /usr/bin/curl } +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#; + +/// The Cedar translation of [`L7_TEST_DATA`]. +/// +/// `ported_phase4` translates `query_api` (query matchers) and +/// `ported_phase3` translates `graphql_ws` (GraphQL over WebSocket) +/// separately. The persisted-query registry of `graphql_api` is an endpoint +/// setting, not policy; `ported_phase2` supplies it with the middleware file. +pub(super) const L7_TEST_CEDAR: &str = r#" +// rest_api +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:8080") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:8080") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ((["GET", "HEAD"].contains(context.method) && context.path like("/repos/**", "/")) + || (context.method == "POST" && context.path like("/repos/*/issues", "/"))) +}; + +// readonly_api +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.readonly.com:8080") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.readonly.com:8080") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ["GET", "HEAD", "OPTIONS"].contains(context.method) +}; + +// full_api +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.full.com:8080") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +@enforcement("audit") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.full.com:8080") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; + +// graphql_api +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.graphql.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.graphql.com:443") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ((context.graphql_operation_type == "query" + && context.graphql_fields.containsAny(["viewer", "repository"]) + && ["viewer", "repository"].containsAll(context.graphql_fields)) + || (context.graphql_operation_type == "mutation" + && context.graphql_operation_name like "Issue*" + && context.graphql_fields.containsAny(["createIssue", "deleteRepository"]) + && ["createIssue", "deleteRepository"].containsAll(context.graphql_fields))) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.graphql.com:443") +when { + context.graphql_operation_type == "mutation" + && context.graphql_fields.containsAny(["deleteRepository"]) +}; + +// graphql_readonly +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"gql.readonly.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"gql.readonly.com:443") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && context.graphql_operation_type == "query" +}; + +// l4_only +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"l4only.example.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"explicit-tcp.example.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +"#; + +const FULL_PRESET_METHODS: [(&str, &str); 7] = [ + ("opa.rs::l7_full_preset_allows_everything/GET", "GET"), + ("opa.rs::l7_full_preset_allows_everything/POST", "POST"), + ("opa.rs::l7_full_preset_allows_everything/PUT", "PUT"), + ("opa.rs::l7_full_preset_allows_everything/DELETE", "DELETE"), + ("opa.rs::l7_full_preset_allows_everything/PATCH", "PATCH"), + ( + "opa.rs::l7_full_preset_allows_everything/OPTIONS", + "OPTIONS", + ), + ("opa.rs::l7_full_preset_allows_everything/HEAD", "HEAD"), +]; + +#[test] +fn ported_l7_test_data() { + let api = |method: &str, path: &str| at("api.example.com", 8080, rest(method, path)); + let readonly = |method: &str| at("api.readonly.com", 8080, rest(method, "/anything")); + let parse_error = L7RequestInfo { + graphql: Some(GraphqlRequestInfo { + operations: Vec::new(), + error: Some("GraphQL document parse error".to_string()), + }), + ..rest("POST", "/graphql") + }; + let mut cases = vec![ + same_probe( + "opa.rs::l7_get_allowed_by_rules", + api("GET", "/repos/myorg/foo"), + ), + same_probe( + "opa.rs::l7_post_allowed_by_rules", + api("POST", "/repos/myorg/issues"), + ), + same_probe( + "opa.rs::l7_delete_denied_by_rules", + api("DELETE", "/repos/myorg/foo"), + ), + same_probe( + "opa.rs::l7_deny_reason_populated", + api("DELETE", "/repos/myorg/foo"), + ), + same_probe( + "opa.rs::l7_get_wrong_path_denied", + api("GET", "/admin/settings"), + ), + same_probe( + "opa.rs::l7_method_matching_case_insensitive", + api("get", "/repos/myorg/foo"), + ), + same_probe( + "opa.rs::l7_path_glob_matching", + api("GET", "/repos/org/repo"), + ), + same_probe( + "opa.rs::l7_wrong_binary_denied_even_with_matching_rules", + request_in( + l7_ctx("api.example.com", 8080, "/usr/bin/python3"), + rest("GET", "/repos/myorg/foo"), + ), + ), + same_probe( + "opa.rs::l7_endpoint_config_returned_for_l7_endpoint", + Probe::Inspection(network("api.example.com", 8080, BINARY)), + ), + same_probe("opa.rs::l7_readonly_preset_allows_get", readonly("GET")), + same_probe("opa.rs::l7_readonly_preset_allows_head", readonly("HEAD")), + same_probe( + "opa.rs::l7_readonly_preset_allows_options", + readonly("OPTIONS"), + ), + same_probe("opa.rs::l7_readonly_preset_denies_post", readonly("POST")), + same_probe( + "opa.rs::l7_readonly_preset_denies_delete", + readonly("DELETE"), + ), + same( + "opa.rs::l7_graphql_query_allowed_by_field_rule", + graphql(vec![operation( + "query", + Some("RepoLookup"), + &["repository"], + )]), + ), + same( + "opa.rs::l7_graphql_unlisted_field_denied", + graphql(vec![operation("query", None, &["viewer", "adminAuditLog"])]), + ), + same( + "opa.rs::l7_graphql_batch_denied_if_any_operation_unallowed", + graphql(vec![ + operation("query", None, &["viewer"]), + operation("mutation", Some("DeleteRepo"), &["deleteRepository"]), + ]), + ), + same( + "opa.rs::l7_graphql_deny_rule_takes_precedence", + graphql(vec![operation( + "mutation", + Some("IssueDelete"), + &["deleteRepository"], + )]), + ), + // Rego reports conflicting deny reasons for a parse error; the + // original asserts only that the request is denied, and so does this. + same("opa.rs::l7_graphql_parse_error_denied", parse_error).allowing_error(), + same_probe( + "opa.rs::l7_graphql_readonly_access_allows_query_and_denies_mutation/query", + at( + "gql.readonly.com", + 443, + graphql(vec![operation("query", None, &["viewer"])]), + ), + ), + same_probe( + "opa.rs::l7_graphql_readonly_access_allows_query_and_denies_mutation/mutation", + at( + "gql.readonly.com", + 443, + graphql(vec![operation("mutation", None, &["createIssue"])]), + ), + ), + same_probe( + "opa.rs::l7_no_request_on_l4_only_endpoint", + at("l4only.example.com", 443, rest("GET", "/anything")), + ), + ]; + cases.extend(FULL_PRESET_METHODS.iter().map(|(name, method)| { + same_probe(*name, at("api.full.com", 8080, rest(method, "/any/path"))) + })); + assert_parity(&Scenario { + name: "opa.rs L7_TEST_DATA", + host: "api.graphql.com", + yaml: L7_TEST_DATA, + cedar: L7_TEST_CEDAR, + cases, + }); +} + +// --------------------------------------------------------------------------- +// Single-policy REST fixtures +// --------------------------------------------------------------------------- + +#[test] +fn ported_relaxed_binary_identity_proto() { + // The YAML test loads the policy with binary identity relaxed and an empty + // binary path. The harness loads both engines with binary identity + // required, and Cedar has no relaxed mode, so this ports the policy's + // decisions for the declared binary. + assert_parity(&Scenario { + name: "opa.rs relaxed identity proto", + host: "host.k3d.internal", + yaml: r#" +network_policies: + test_l7: + name: test_l7 + endpoints: + - host: host.k3d.internal + port: 56123 + protocol: rest + enforcement: enforce + allowed_ips: ["192.168.0.0/16"] + rules: + - allow: + method: GET + path: /allowed + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"host.k3d.internal:56123") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"host.k3d.internal:56123") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ["GET", "HEAD"].contains(context.method) + && context.path == "/allowed" +}; +"#, + cases: vec![ + same_probe( + "opa.rs::relaxed_binary_identity_preserves_matched_policy_and_l7_for_proto/connect", + Probe::Connect(network("host.k3d.internal", 56123, BINARY)), + ), + same_probe( + "opa.rs::relaxed_binary_identity_preserves_matched_policy_and_l7_for_proto/request", + at("host.k3d.internal", 56123, rest("GET", "/allowed")), + ), + ], + }); +} + +#[test] +fn ported_github_git_transport() { + assert_parity(&Scenario { + name: "opa.rs github git transport", + host: "github.com", + yaml: r#" +network_policies: + github: + name: github + endpoints: + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "**" } + - allow: { method: HEAD, path: "**" } + - allow: { method: OPTIONS, path: "**" } + - allow: { method: POST, path: "/**/git-upload-pack" } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"github.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"github.com:443") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ((["GET", "HEAD", "OPTIONS"].contains(context.method) && context.path like("**", "/")) + || (context.method == "POST" && context.path like("/**/git-upload-pack", "/"))) +}; +"#, + cases: vec![ + same( + "opa.rs::l7_github_git_transport_allows_clone_blocks_push/info-refs", + rest("GET", "/NVIDIA/OpenShell.git/info/refs"), + ), + same( + "opa.rs::l7_github_git_transport_allows_clone_blocks_push/upload-pack", + rest("POST", "/NVIDIA/OpenShell.git/git-upload-pack"), + ), + same( + "opa.rs::l7_github_git_transport_allows_clone_blocks_push/receive-pack", + rest("POST", "/NVIDIA/OpenShell.git/git-receive-pack"), + ), + ], + }); +} + +// --------------------------------------------------------------------------- +// JSON-RPC fixtures +// --------------------------------------------------------------------------- + +/// A YAML policy for one JSON-RPC endpoint at `host:8000` on `/rpc`. +fn jsonrpc_yaml(name: &str, host: &str, rules: &str) -> String { + format!( + r" +network_policies: + {name}: + name: {name} + endpoints: + - host: {host} + port: 8000 + path: /rpc + protocol: json-rpc + enforcement: enforce +{rules} + binaries: + - {{ path: /usr/bin/curl }} +" + ) +} + +/// The Cedar translation of a JSON-RPC endpoint at `host:8000` on `/rpc`. +/// +/// `allow` is the condition on the call; `deny`, when present, is the +/// condition of a `forbid`. +fn jsonrpc_cedar(host: &str, allow: &str, deny: Option<&str>) -> String { + let curl = binary("/usr/bin/curl"); + let mut cedar = String::new(); + connect_permit(&mut cedar, &format!("{host}:8000"), &curl); + let _ = write!( + cedar, + r#" +@protocol("json-rpc") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:8000") +when {{ + {curl} + && context.path == "/rpc" + && !context.jsonrpc_response + && {allow} +}}; +"# + ); + if let Some(deny) = deny { + let _ = write!( + cedar, + r#" +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:8000") +when {{ {deny} }}; +"# + ); + } + cedar +} + +const ALLOW_INITIALIZE: &str = + " rules:\n - allow:\n method: initialize"; + +#[test] +fn ported_jsonrpc_method_from_proto() { + let host = "jsonrpc.proto.com"; + assert_parity(&Scenario { + name: "opa.rs jsonrpc_proto", + host, + yaml: &jsonrpc_yaml("jsonrpc_proto", host, ALLOW_INITIALIZE), + cedar: &jsonrpc_cedar(host, r#"context.jsonrpc_method == "initialize""#, None), + cases: vec![ + same_probe( + "opa.rs::l7_method_from_proto_is_enforced/initialize", + at(host, 8000, rpc("/rpc", "initialize")), + ), + same_probe( + "opa.rs::l7_method_from_proto_is_enforced/reports.list", + at(host, 8000, rpc("/rpc", "reports.list")), + ), + ], + }); +} + +#[test] +fn ported_jsonrpc_receive_stream() { + let host = "jsonrpc.stream.test"; + assert_parity(&Scenario { + name: "opa.rs jsonrpc_stream", + host, + yaml: &jsonrpc_yaml("jsonrpc_stream", host, ALLOW_INITIALIZE), + cedar: &jsonrpc_cedar(host, r#"context.jsonrpc_method == "initialize""#, None), + cases: vec![ + same_probe( + "opa.rs::l7_jsonrpc_receive_stream_get_is_denied_for_matching_endpoint/rpc", + at(host, 8000, jsonrpc_frame("GET", "/rpc", true, false)), + ), + same_probe( + "opa.rs::l7_jsonrpc_receive_stream_get_is_denied_for_matching_endpoint/other", + at(host, 8000, jsonrpc_frame("GET", "/other", true, false)), + ), + same_probe( + "opa.rs::l7_jsonrpc_receive_stream_get_is_denied_for_matching_endpoint/bodyless", + at(host, 8000, jsonrpc_frame("GET", "/rpc", false, false)), + ), + same_probe( + "opa.rs::l7_jsonrpc_receive_stream_get_is_denied_for_matching_endpoint/null-metadata", + at("mcp.stream.test", 8000, rest("GET", "/mcp")), + ), + same_probe( + "control: listed call is allowed", + at(host, 8000, rpc("/rpc", "initialize")), + ), + ], + }); +} + +#[test] +fn ported_jsonrpc_response_post() { + let host = "jsonrpc.response.test"; + let mixed = L7RequestInfo { + jsonrpc: Some(JsonRpcRequestInfo { + has_response: true, + ..jsonrpc_info(vec![call("initialize", None, None)]) + }), + ..rest("POST", "/rpc") + }; + assert_parity(&Scenario { + name: "opa.rs jsonrpc_response", + host, + yaml: &jsonrpc_yaml("jsonrpc_response", host, ALLOW_INITIALIZE), + cedar: &jsonrpc_cedar(host, r#"context.jsonrpc_method == "initialize""#, None), + cases: vec![ + same_probe( + "opa.rs::l7_jsonrpc_response_post_is_denied_for_matching_endpoint/response", + at(host, 8000, jsonrpc_frame("POST", "/rpc", false, true)), + ), + same_probe( + "opa.rs::l7_jsonrpc_response_post_is_denied_for_matching_endpoint/mixed", + at(host, 8000, mixed), + ), + same_probe( + "opa.rs::l7_jsonrpc_response_post_is_denied_for_matching_endpoint/other", + at(host, 8000, jsonrpc_frame("POST", "/other", false, true)), + ), + same_probe( + "control: listed call is allowed", + at(host, 8000, rpc("/rpc", "initialize")), + ), + ], + }); +} + +#[test] +fn ported_jsonrpc_unlisted_method() { + let host = "jsonrpc.methods.test"; + assert_parity(&Scenario { + name: "opa.rs jsonrpc_methods", + host, + yaml: &jsonrpc_yaml("jsonrpc_methods", host, ALLOW_INITIALIZE), + cedar: &jsonrpc_cedar(host, r#"context.jsonrpc_method == "initialize""#, None), + cases: vec![same_probe( + "opa.rs::l7_jsonrpc_unlisted_method_is_denied", + at(host, 8000, rpc("/rpc", "reports.progress")), + )], + }); +} + +#[test] +fn ported_jsonrpc_method_rules_require_post() { + let host = "jsonrpc.post.test"; + let with_method = |method: &str| L7RequestInfo { + action: method.to_string(), + ..rpc("/rpc", "initialize") + }; + assert_parity(&Scenario { + name: "opa.rs jsonrpc_post", + host, + yaml: &jsonrpc_yaml( + "jsonrpc_post", + host, + " rules:\n - allow:\n method: initialize\n deny_rules:\n - method: reports.archive", + ), + cedar: &jsonrpc_cedar( + host, + r#"context.jsonrpc_method == "initialize""#, + Some(r#"context.jsonrpc_method == "reports.archive""#), + ), + cases: vec![ + same_probe( + "opa.rs::l7_method_rules_require_post/POST", + at(host, 8000, with_method("POST")), + ), + same_probe( + "opa.rs::l7_method_rules_require_post/PUT", + at(host, 8000, with_method("PUT")), + ), + same_probe( + "opa.rs::l7_method_rules_require_post/GET", + at(host, 8000, with_method("GET")), + ), + ], + }); +} + +#[test] +fn ported_jsonrpc_request_params() { + let host = "jsonrpc.params.test"; + let search = |params: &[(&str, &str)]| { + at( + host, + 8000, + rpc_with_params("/rpc", "reports.search", params), + ) + }; + assert_parity(&Scenario { + name: "opa.rs jsonrpc_params", + host, + yaml: &jsonrpc_yaml( + "jsonrpc_params", + host, + " rules:\n - allow:\n method: reports.search\n deny_rules:\n - method: reports.archive", + ), + cedar: &jsonrpc_cedar( + host, + r#"context.jsonrpc_method == "reports.search""#, + Some(r#"context.jsonrpc_method == "reports.archive""#), + ), + cases: vec![ + same_probe( + "opa.rs::l7_jsonrpc_request_params_do_not_affect_method_policy/query", + search(&[("query", "quarterly")]), + ), + same_probe( + "opa.rs::l7_jsonrpc_request_params_do_not_affect_method_policy/scope", + search(&[("query", "quarterly"), ("filters.scope", "workspace/main")]), + ), + same_probe( + "opa.rs::l7_jsonrpc_request_params_do_not_affect_method_policy/blocked", + search(&[("query", "blocked")]), + ), + same_probe( + "opa.rs::l7_jsonrpc_request_params_do_not_affect_method_policy/blocked-args", + search(&[("query", "blocked"), ("filters.reason", "test")]), + ), + ], + }); +} + +#[test] +fn ported_jsonrpc_allow_all() { + let host = "jsonrpc.allow-all.test"; + assert_parity(&Scenario { + name: "opa.rs jsonrpc_allow_all", + host, + yaml: &jsonrpc_yaml( + "jsonrpc_allow_all", + host, + " rules:\n - allow:\n method: \"*\"", + ), + cedar: &jsonrpc_cedar(host, "true", None), + cases: vec![ + same_probe( + "opa.rs::l7_jsonrpc_allow_all_still_allows_any_method/initialize", + at(host, 8000, rpc("/rpc", "initialize")), + ), + same_probe( + "opa.rs::l7_jsonrpc_allow_all_still_allows_any_method/reports.archive", + at(host, 8000, rpc("/rpc", "reports.archive")), + ), + ], + }); +} + +#[test] +fn ported_jsonrpc_null_metadata() { + let host = "jsonrpc.null.test"; + assert_parity(&Scenario { + name: "opa.rs jsonrpc_null", + host, + yaml: &jsonrpc_yaml( + "jsonrpc_null", + host, + " rules:\n - allow:\n method: reports.list", + ), + cedar: &jsonrpc_cedar(host, r#"context.jsonrpc_method == "reports.list""#, None), + cases: vec![same_probe( + "opa.rs::l7_jsonrpc_null_metadata_non_matches_without_opa_error", + at(host, 8000, rest("POST", "/rpc")), + )], + }); +} + +// --------------------------------------------------------------------------- +// MCP fixtures +// --------------------------------------------------------------------------- + +/// A YAML MCP endpoint at `host:8000` on `/mcp`, as a list item. +fn mcp_endpoint_yaml(host: &str, body: &str) -> String { + format!( + r" - host: {host} + port: 8000 + path: /mcp + protocol: mcp + enforcement: enforce +{body} +" + ) +} + +/// A YAML policy for one MCP endpoint at `host:8000` on `/mcp`. +fn mcp_yaml(name: &str, host: &str, body: &str) -> String { + format!( + r" +network_policies: + {name}: + name: {name} + endpoints: +{endpoint} binaries: + - {{ path: /usr/bin/curl }} +", + endpoint = mcp_endpoint_yaml(host, body) + ) +} + +/// The Cedar translation of an MCP endpoint at `host:8000` on `/mcp`. +/// +/// `allow` is the condition on the call; `deny`, when present, is the +/// condition of a `forbid`. +fn mcp_cedar(host: &str, allow: &str, deny: Option<&str>) -> String { + let curl = binary("/usr/bin/curl"); + let endpoint = format!("{host}:8000"); + let mut cedar = String::new(); + connect_permit(&mut cedar, &endpoint, &curl); + let _ = write!( + cedar, + r#" +@protocol("mcp") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{endpoint}") +when {{ + {curl} + && context.path == "/mcp" + && !context.jsonrpc_response + && {allow} +}}; +"# + ); + cedar.push_str(&mcp_frames_permit(&endpoint, "/mcp", &curl)); + if let Some(deny) = deny { + let _ = write!( + cedar, + r#" +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{endpoint}") +when {{ {deny} }}; +"# + ); + } + cedar +} + +const MCP_ALLOW_INITIALIZE: &str = + r#"context.mcp_method_class == "available" && context.jsonrpc_method == "initialize""#; + +#[test] +fn ported_mcp_tool_params_from_proto() { + let host = "mcp.proto.com"; + assert_parity(&Scenario { + name: "opa.rs mcp_proto", + host, + yaml: &mcp_yaml( + "mcp_proto", + host, + " rules:\n - allow:\n method: tools/call\n tool:\n any: [read_status, \"submit_*\"]", + ), + cedar: &mcp_cedar( + host, + r#"context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" + && (context.mcp_tool == "read_status" || context.mcp_tool like("submit_*", "."))"#, + None, + ), + cases: vec![ + same_probe( + "opa.rs::l7_mcp_tool_params_from_proto_are_enforced/read_status", + at(host, 8000, mcp_tool_call("read_status", &[])), + ), + same_probe( + "opa.rs::l7_mcp_tool_params_from_proto_are_enforced/blocked_action", + at(host, 8000, mcp_tool_call("blocked_action", &[])), + ), + ], + }); +} + +#[test] +fn ported_mcp_receive_stream() { + let host = "mcp.stream.test"; + assert_parity(&Scenario { + name: "opa.rs mcp_stream", + host, + yaml: &mcp_yaml("mcp_stream", host, ALLOW_INITIALIZE), + cedar: &mcp_cedar(host, MCP_ALLOW_INITIALIZE, None), + cases: vec![ + same_probe( + "opa.rs::l7_mcp_receive_stream_get_is_allowed_for_matching_endpoint/mcp", + at(host, 8000, jsonrpc_frame("GET", "/mcp", true, false)), + ), + same_probe( + "opa.rs::l7_mcp_receive_stream_get_is_allowed_for_matching_endpoint/other", + at(host, 8000, jsonrpc_frame("GET", "/other", true, false)), + ), + ], + }); +} + +#[test] +fn ported_mcp_response_post() { + let host = "mcp.response.test"; + assert_parity(&Scenario { + name: "opa.rs mcp_response", + host, + yaml: &mcp_yaml("mcp_response", host, ALLOW_INITIALIZE), + cedar: &mcp_cedar(host, MCP_ALLOW_INITIALIZE, None), + cases: vec![ + same_probe( + "opa.rs::l7_mcp_response_post_is_allowed_for_matching_endpoint/mcp", + at(host, 8000, jsonrpc_frame("POST", "/mcp", false, true)), + ), + same_probe( + "opa.rs::l7_mcp_response_post_is_allowed_for_matching_endpoint/other", + at(host, 8000, jsonrpc_frame("POST", "/other", false, true)), + ), + ], + }); +} + +#[test] +fn ported_mcp_rules_filter_tools_call() { + let host = "mcp.params.test"; + assert_parity(&Scenario { + name: "opa.rs mcp_params", + host, + yaml: &mcp_yaml( + "mcp_params", + host, + " mcp:\n max_body_bytes: 131072\n rules:\n - allow:\n method: tools/call\n tool:\n any: [read_status, submit_*]\n deny_rules:\n - method: tools/call\n tool: blocked_action", + ), + cedar: &mcp_cedar( + host, + r#"context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" + && (context.mcp_tool == "read_status" || context.mcp_tool like("submit_*", "."))"#, + Some( + r#"context.jsonrpc_method == "tools/call" && context.mcp_tool == "blocked_action""#, + ), + ), + cases: vec![ + same_probe( + "opa.rs::l7_mcp_rules_filter_tools_call/read_status", + at( + host, + 8000, + mcp_tool_call("read_status", &[("arguments.scope", "workspace/main")]), + ), + ), + same_probe( + "opa.rs::l7_mcp_rules_filter_tools_call/read_status-other-args", + at( + host, + 8000, + mcp_tool_call("read_status", &[("arguments.scope", "workspace/other")]), + ), + ), + same_probe( + "opa.rs::l7_mcp_rules_filter_tools_call/submit_report", + at(host, 8000, mcp_tool_call("submit_report", &[])), + ), + same_probe( + "opa.rs::l7_mcp_rules_filter_tools_call/blocked_action", + at(host, 8000, mcp_tool_call("blocked_action", &[])), + ), + same_probe( + "opa.rs::l7_mcp_rules_filter_tools_call/unmatched-tool", + at( + host, + 8000, + mcp_tool_call( + "private_tool_name", + &[("arguments.secret", "private-value")], + ), + ), + ), + same_probe( + "opa.rs::l7_mcp_rules_filter_tools_call/tools-list", + at( + host, + 8000, + mcp("tools/list", None, McpMethodClassification::Available), + ), + ), + ], + }); +} + +#[test] +fn ported_mcp_method_profile_allows_all_tools() { + let host = "mcp.default.test"; + assert_parity(&Scenario { + name: "opa.rs mcp_default", + host, + yaml: &mcp_yaml( + "mcp_default", + host, + " mcp:\n allow_all_known_mcp_methods: true", + ), + cedar: &mcp_cedar(host, r#"context.mcp_method_class == "available""#, None), + cases: vec![ + same_probe( + "opa.rs::l7_mcp_method_profile_allows_all_tools/tools-call", + at( + host, + 8000, + mcp_tool_call("any_tool", &[("arguments.scope", "workspace/other")]), + ), + ), + same_probe( + "opa.rs::l7_mcp_method_profile_allows_all_tools/tools-list", + at( + host, + 8000, + mcp("tools/list", None, McpMethodClassification::Available), + ), + ), + same_probe( + "opa.rs::l7_mcp_method_profile_allows_all_tools/extension", + at( + host, + 8000, + mcp( + "vendor/private_method_name", + None, + McpMethodClassification::Extension, + ), + ), + ), + ], + }); +} + +#[test] +fn ported_mcp_extension_requires_an_exact_method_literal() { + let exact = "mcp.extension-exact.test"; + let wildcard = "mcp.extension-wildcard.test"; + let denied = "mcp.extension-denied.test"; + let yaml = format!( + r" +network_policies: + exact_extension: + name: exact_extension + endpoints: +{exact_endpoint} binaries: + - {{ path: /usr/bin/curl }} + wildcard_extension: + name: wildcard_extension + endpoints: +{wildcard_endpoint} binaries: + - {{ path: /usr/bin/curl }} + denied_extension: + name: denied_extension + endpoints: +{denied_endpoint} binaries: + - {{ path: /usr/bin/curl }} +", + exact_endpoint = mcp_endpoint_yaml( + exact, + " rules:\n - allow:\n method: tools/vendor" + ), + wildcard_endpoint = mcp_endpoint_yaml( + wildcard, + " rules:\n - allow:\n method: tools/*" + ), + denied_endpoint = mcp_endpoint_yaml( + denied, + " rules:\n - allow:\n method: tools/vendor\n deny_rules:\n - method: tools/*" + ), + ); + // An exact method allows that method whatever its class; a method glob + // only matches core methods of the selected revision. + let cedar = [ + mcp_cedar(exact, r#"context.jsonrpc_method == "tools/vendor""#, None), + mcp_cedar( + wildcard, + r#"context.mcp_method_class == "available" && context.jsonrpc_method like("tools/*", "/")"#, + None, + ), + mcp_cedar( + denied, + r#"context.jsonrpc_method == "tools/vendor""#, + Some(r#"context.jsonrpc_method like("tools/*", "/")"#), + ), + ] + .concat(); + let extension = || mcp("tools/vendor", None, McpMethodClassification::Extension); + assert_parity(&Scenario { + name: "opa.rs mcp extensions", + host: exact, + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::l7_mcp_extension_requires_an_exact_method_literal/exact", + at(exact, 8000, extension()), + ), + same_probe( + "opa.rs::l7_mcp_extension_requires_an_exact_method_literal/unavailable", + at(exact, 8000, mcp_rejected("tools/vendor")), + ), + same_probe( + "opa.rs::l7_mcp_extension_requires_an_exact_method_literal/wildcard", + at(wildcard, 8000, extension()), + ), + same_probe( + "opa.rs::l7_mcp_extension_requires_an_exact_method_literal/denied", + at(denied, 8000, extension()), + ), + ], + }); +} + +#[test] +fn ported_mcp_null_params() { + let host = "mcp.null-params.test"; + assert_parity(&Scenario { + name: "opa.rs mcp_null_params", + host, + yaml: &mcp_yaml( + "mcp_null_params", + host, + " rules:\n - allow:\n method: tools/call\n tool: read_status", + ), + cedar: &mcp_cedar( + host, + r#"context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" + && context.mcp_tool == "read_status""#, + None, + ), + cases: vec![same_probe( + "opa.rs::l7_mcp_null_params_non_matches_without_opa_error", + at( + host, + 8000, + mcp("tools/call", None, McpMethodClassification::Available), + ), + )], + }); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_b.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_b.rs new file mode 100644 index 0000000000..b03c818eb5 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_b.rs @@ -0,0 +1,1200 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML decision tests from `opa.rs`: network actions, L7 deny rules, +//! multi-port and wildcard hosts, symlink-expanded binaries, and HEAD +//! handling. +//! +//! YAML binary rules also match a process's ancestors, so each Cedar +//! translation of an exact binary path checks `context.ancestors` too. +//! A YAML `GET` rule also allows `HEAD`, so the Cedar translation lists +//! `HEAD` explicitly. + +use super::*; + +/// A Cedar condition matching YAML exact `binaries` against the calling +/// binary or any of its ancestors. +fn binaries(paths: &[&str]) -> String { + let list = paths + .iter() + .map(|path| format!("\"{path}\"")) + .collect::>() + .join(", "); + format!("([{list}].contains(context.binary_path) || context.ancestors.containsAny([{list}]))") +} + +/// A `NetworkConnect` permit for one exact endpoint. +fn connect(endpoint: &str, condition: &str) -> String { + format!( + "permit (principal, action == Sandbox::Action::\"NetworkConnect\",\n \ + resource == Sandbox::NetworkEndpoint::\"{endpoint}\")\nwhen {{ {condition} }};\n" + ) +} + +/// `PROVIDER_ENDPOINT_TEST_DATA` from `opa.rs`. +const PROVIDER_ENDPOINT_YAML: &str = r" +network_policies: + claude_code: + name: claude_code + endpoints: + - { host: api.anthropic.com, port: 443 } + binaries: + - { path: /usr/local/bin/claude } + gitlab: + name: gitlab + endpoints: + - { host: gitlab.com, port: 443 } + binaries: + - { path: /usr/bin/glab } +"; + +#[test] +fn ported_provider_endpoint_network_actions() { + let cedar = connect( + "api.anthropic.com:443", + &binaries(&["/usr/local/bin/claude"]), + ) + &connect("gitlab.com:443", &binaries(&["/usr/bin/glab"])); + assert_parity(&Scenario { + name: "provider endpoints", + host: "api.anthropic.com", + yaml: PROVIDER_ENDPOINT_YAML, + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::explicitly_allowed_endpoint_binary_returns_allow", + Probe::Connect(network("api.anthropic.com", 443, "/usr/local/bin/claude")), + ), + same_probe( + "opa.rs::unknown_endpoint_returns_deny", + Probe::Connect(network("api.openai.com", 443, "/usr/bin/python3")), + ), + same_probe( + "opa.rs::endpoint_in_policy_binary_not_allowed_returns_deny", + Probe::Connect(network("api.anthropic.com", 443, "/usr/bin/python3")), + ), + ], + }); +} + +#[test] +fn ported_other_endpoint_network_actions() { + let cedar = connect("gitlab.com:443", &binaries(&["/usr/bin/glab"])); + assert_parity(&Scenario { + name: "other endpoint", + host: "gitlab.com", + yaml: r" +network_policies: + gitlab: + name: gitlab + endpoints: + - { host: gitlab.com, port: 443 } + binaries: + - { path: /usr/bin/glab } +", + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::unknown_endpoint_with_other_policy_returns_deny", + Probe::Connect(network("api.openai.com", 443, "/usr/bin/python3")), + ), + same_probe( + "opa.rs::endpoint_in_policy_binary_not_allowed_with_other_policy_returns_deny", + Probe::Connect(network("gitlab.com", 443, "/usr/bin/python3")), + ), + same_probe( + "control: declared binary connects", + Probe::Connect(network("gitlab.com", 443, "/usr/bin/glab")), + ), + ], + }); +} + +/// The original test loads `PROVIDER_ENDPOINT_TEST_DATA` with binary +/// identity not required. The harness always requires it, so the YAML here +/// matches every binary with the `/**` glob instead, which makes the same +/// decisions. The Cedar policy has no binary condition. +#[test] +fn ported_relaxed_binary_identity() { + assert_parity(&Scenario { + name: "relaxed binary identity", + host: "api.anthropic.com", + yaml: r#" +network_policies: + claude_code: + name: claude_code + endpoints: + - { host: api.anthropic.com, port: 443 } + binaries: + - { path: "/**" } + gitlab: + name: gitlab + endpoints: + - { host: gitlab.com, port: 443 } + binaries: + - { path: "/**" } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.anthropic.com:443"); +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"gitlab.com:443"); +"#, + cases: vec![ + same_probe( + "opa.rs::relaxed_binary_identity_allows_declared_endpoint_without_binary_match", + Probe::Connect(network("api.anthropic.com", 443, "/tmp/unlisted-agent")), + ), + same_probe( + "opa.rs::relaxed_binary_identity_allows_declared_endpoint_without_binary_match / exact host", + Probe::ExactHost(network("api.anthropic.com", 443, "/tmp/unlisted-agent")), + ), + same_probe( + "opa.rs::relaxed_binary_identity_allows_declared_endpoint_without_binary_match / undeclared", + Probe::Connect(network("api.openai.com", 443, "/tmp/unlisted-agent")), + ), + ], + }); +} + +/// `test_proto()` from `opa.rs`, written as YAML. +#[test] +fn ported_from_proto_network_actions() { + let claude = binaries(&["/usr/local/bin/claude"]); + let cedar = connect("api.anthropic.com:443", &claude) + + &connect("statsig.anthropic.com:443", &claude) + + &connect("gitlab.com:443", &binaries(&["/usr/bin/glab"])); + assert_parity(&Scenario { + name: "test_proto", + host: "api.anthropic.com", + yaml: r" +network_policies: + claude_code: + name: claude_code + endpoints: + - { host: api.anthropic.com, port: 443 } + - { host: statsig.anthropic.com, port: 443 } + binaries: + - { path: /usr/local/bin/claude } + gitlab: + name: gitlab + endpoints: + - { host: gitlab.com, port: 443 } + binaries: + - { path: /usr/bin/glab } +", + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::from_proto_explicitly_allowed_returns_allow", + Probe::Connect(network("api.anthropic.com", 443, "/usr/local/bin/claude")), + ), + same_probe( + "opa.rs::from_proto_unknown_endpoint_returns_deny", + Probe::Connect(network("api.openai.com", 443, "/usr/bin/python3")), + ), + ], + }); +} + +/// The network policies of `testdata/sandbox-policy.yaml`. +const DEV_POLICY_YAML: &str = r#" +network_policies: + claude_code: + name: claude_code + endpoints: + - { host: api.anthropic.com, port: 443 } + - { host: statsig.anthropic.com, port: 443 } + binaries: + - { path: /usr/local/bin/claude } + - { path: /usr/bin/node } + github_ssh_over_https: + name: github-ssh-over-https + endpoints: + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/**/info/refs*" + - allow: + method: POST + path: "/**/git-upload-pack" + binaries: + - { path: /usr/bin/git } + copilot: + name: copilot + endpoints: + - { host: github.com, port: 443 } + - { host: api.github.com, port: 443 } + - { host: api.githubcopilot.com, port: 443 } + - { host: api.individual.githubcopilot.com, port: 443 } + - { host: api.business.githubcopilot.com, port: 443 } + - { host: api.enterprise.githubcopilot.com, port: 443 } + - { host: copilot-proxy.githubusercontent.com, port: 443 } + - { host: copilot-telemetry.githubusercontent.com, port: 443 } + - { host: default.exp-tas.com, port: 443 } + - { host: origin-tracker.githubusercontent.com, port: 443 } + - { host: release-assets.githubusercontent.com, port: 443 } + binaries: + - { path: "/usr/lib/node_modules/@github/copilot/node_modules/@github/**/copilot" } + - { path: /usr/local/bin/copilot } + - { path: "/home/*/.local/bin/copilot" } + - { path: /usr/bin/node } + gitlab: + name: gitlab + endpoints: + - { host: gitlab.com, port: 443 } + binaries: + - { path: /usr/bin/glab } +"#; + +/// The Cedar translation of [`DEV_POLICY_YAML`]. Cedar has no quantifier +/// over set elements, so copilot's glob binaries match the calling binary +/// only, not its ancestors. +fn dev_policy_cedar() -> String { + let claude = binaries(&["/usr/local/bin/claude", "/usr/bin/node"]); + let git = binaries(&["/usr/bin/git"]); + let copilot = format!( + "context.binary_path like(\"/usr/lib/node_modules/@github/copilot/node_modules/@github/**/copilot\", \"/\") \ + || context.binary_path like(\"/home/*/.local/bin/copilot\", \"/\") || {}", + binaries(&["/usr/local/bin/copilot", "/usr/bin/node"]) + ); + let mut cedar = connect("api.anthropic.com:443", &claude) + + &connect("statsig.anthropic.com:443", &claude) + + &connect("github.com:443", &git) + + &format!( + r#"@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"github.com:443") +when {{ + {git} + && ((["GET", "HEAD"].contains(context.method) && context.path like("/**/info/refs*", "/")) + || (context.method == "POST" && context.path like("/**/git-upload-pack", "/"))) +}}; +"# + ); + for host in [ + "github.com", + "api.github.com", + "api.githubcopilot.com", + "api.individual.githubcopilot.com", + "api.business.githubcopilot.com", + "api.enterprise.githubcopilot.com", + "copilot-proxy.githubusercontent.com", + "copilot-telemetry.githubusercontent.com", + "default.exp-tas.com", + "origin-tracker.githubusercontent.com", + "release-assets.githubusercontent.com", + ] { + cedar += &connect(&format!("{host}:443"), &copilot); + } + cedar + &connect("gitlab.com:443", &binaries(&["/usr/bin/glab"])) +} + +#[test] +fn ported_dev_policy_network_actions() { + let cedar = dev_policy_cedar(); + assert_parity(&Scenario { + name: "dev policy", + host: "api.anthropic.com", + yaml: DEV_POLICY_YAML, + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::network_action_with_dev_policy / claude", + Probe::Connect(network("api.anthropic.com", 443, "/usr/local/bin/claude")), + ), + same_probe( + "opa.rs::network_action_with_dev_policy / git", + Probe::Connect(network("github.com", 443, "/usr/bin/git")), + ), + // The original also checks the Rego deny reason text, which is + // out of scope; only the denial is compared. + same_probe( + "opa.rs::deny_reason_includes_symlink_hint", + Probe::Connect(network("api.anthropic.com", 443, "/usr/bin/python3.11")), + ), + same_probe( + "opa.rs::deny_reason_collapses_endpoint_misses", + Probe::Connect(network( + "not-configured.example.com", + 443, + "/usr/local/bin/claude", + )), + ), + ], + }); +} + +/// `L7_DENY_TEST_DATA` from `opa.rs`, without the `deny_with_query` policy, +/// which `ported_phase4` ports. +#[test] +fn ported_l7_deny_rules() { + assert_parity(&Scenario { + name: "L7 deny rules", + host: "api.github.com", + yaml: r#" +network_policies: + github_api: + name: github_api + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + access: read-write + deny_rules: + - method: POST + path: "/repos/*/pulls/*/reviews" + - method: PUT + path: "/repos/*/branches/*/protection" + - method: "*" + path: "/repos/*/rulesets" + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"].contains(context.method) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context.method == "POST" && context.path like("/repos/*/pulls/*/reviews", "/") }; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context.method == "PUT" && context.path like("/repos/*/branches/*/protection", "/") }; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context.path like("/repos/*/rulesets", "/") }; +"#, + cases: vec![ + same( + "opa.rs::l7_deny_rule_blocks_allowed_method_path", + rest("POST", "/repos/myorg/pulls/123/reviews"), + ), + same( + "opa.rs::l7_deny_rule_allows_non_matching_requests", + rest("GET", "/repos/myorg/issues"), + ), + same( + "opa.rs::l7_deny_rule_allows_same_method_different_path", + rest("POST", "/repos/myorg/issues"), + ), + same( + "opa.rs::l7_deny_rule_blocks_wildcard_method", + rest("GET", "/repos/myorg/rulesets"), + ), + same( + "opa.rs::l7_deny_rule_blocks_put_protection", + rest("PUT", "/repos/myorg/branches/main/protection"), + ), + // The original checks the deny reason text; only the denial is + // compared. + same( + "opa.rs::l7_deny_reason_populated_when_deny_rule_matches", + rest("POST", "/repos/myorg/pulls/123/reviews"), + ), + ], + }); +} + +/// `OVERLAPPING_L7_TEST_DATA` from `opa.rs`. The second policy's `tls: skip` +/// and `allowed_ips` do not affect request decisions. +#[test] +fn ported_overlapping_l7_policies() { + let ctx = || l7_ctx("192.168.1.100", 8567, BINARY); + assert_parity(&Scenario { + name: "overlapping L7 policies", + host: "192.168.1.100", + yaml: r#" +network_policies: + test_server: + name: test_server + endpoints: + - host: 192.168.1.100 + port: 8567 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "**" + binaries: + - { path: /usr/bin/curl } + allow_192_168_1_100_8567: + name: allow_192_168_1_100_8567 + endpoints: + - host: 192.168.1.100 + port: 8567 + protocol: rest + enforcement: enforce + tls: skip + allowed_ips: + - 192.168.1.100 + rules: + - allow: + method: GET + path: "**" + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"192.168.1.100:8567") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"192.168.1.100:8567") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ["GET", "HEAD"].contains(context.method) +}; +"#, + cases: vec![ + same_probe( + "opa.rs::l7_overlapping_policies_allow_request_does_not_crash", + request_in(ctx(), rest("GET", "/test")), + ), + same_probe( + "opa.rs::l7_overlapping_policies_deny_request_does_not_crash", + request_in(ctx(), rest("DELETE", "/test")), + ), + ], + }); +} + +/// The `rest_api` and `l4_only` policies of `L7_TEST_DATA` from `opa.rs`. +/// The other policies there use query matchers, persisted GraphQL queries, +/// and WebSocket rules, which Cedar cannot express, and no case here +/// reaches them. +#[test] +fn ported_l7_test_data_rest_and_l4() { + let ctx = || l7_ctx("api.example.com", 8080, BINARY); + assert_parity(&Scenario { + name: "L7 test data", + host: "api.example.com", + yaml: r#" +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.com + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/repos/**" + - allow: + method: POST + path: "/repos/*/issues" + binaries: + - { path: /usr/bin/curl } + l4_only: + name: l4_only + endpoints: + - { host: l4only.example.com, port: 443 } + - { host: explicit-tcp.example.com, port: 443, protocol: tcp } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:8080") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:8080") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ((["GET", "HEAD"].contains(context.method) && context.path like("/repos/**", "/")) + || (context.method == "POST" && context.path like("/repos/*/issues", "/"))) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"l4only.example.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"explicit-tcp.example.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +"#, + cases: vec![ + same_probe( + "opa.rs::l7_endpoint_config_none_for_l4_only", + Probe::Inspection(network("l4only.example.com", 443, BINARY)), + ), + same_probe( + "opa.rs::l7_head_allowed_where_get_is_allowed", + request_in(ctx(), rest("HEAD", "/repos/myorg/foo")), + ), + same_probe( + "opa.rs::l7_options_not_implicitly_allowed_by_get", + request_in(ctx(), rest("OPTIONS", "/repos/myorg/foo")), + ), + ], + }); +} + +#[test] +fn ported_l7_head_denied_when_only_post_allowed() { + assert_parity(&Scenario { + name: "POST-only endpoint", + host: "h.test", + yaml: "network_policies:\n p:\n name: p\n endpoints:\n - host: h.test\n port: 80\n protocol: rest\n enforcement: enforce\n rules:\n - allow: {method: POST, path: \"/\"}\n binaries:\n - {path: /usr/bin/curl}\n", + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"h.test:80") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"h.test:80") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && context.method == "POST" && context.path == "/" +}; +"#, + cases: vec![same_probe( + "opa.rs::l7_head_denied_when_only_post_allowed", + request_in(l7_ctx("h.test", 80, BINARY), rest("HEAD", "/")), + )], + }); +} + +#[test] +fn ported_l7_head_blocked_by_deny_rule_targeting_get() { + assert_parity(&Scenario { + name: "deny rule on GET", + host: "h.test", + yaml: "network_policies:\n p:\n name: p\n endpoints:\n - host: h.test\n port: 80\n protocol: rest\n enforcement: enforce\n access: full\n deny_rules:\n - method: GET\n path: \"/protected\"\n binaries:\n - {path: /usr/bin/curl}\n", + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"h.test:80") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"h.test:80") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"h.test:80") +when { ["GET", "HEAD"].contains(context.method) && context.path == "/protected" }; +"#, + cases: vec![same_probe( + "opa.rs::l7_head_blocked_by_deny_rule_targeting_get", + request_in(l7_ctx("h.test", 80, BINARY), rest("HEAD", "/protected")), + )], + }); +} + +/// The `public_api` and `wildcard_api` policies of `ALLOWED_IPS_TEST_DATA` +/// from `opa.rs`. The other policies there use `allowed_ips`, which Cedar +/// cannot express, and no case here reaches them. +#[test] +fn ported_exact_declared_endpoint_host() { + assert_parity(&Scenario { + name: "exact declared host", + host: "api.github.com", + yaml: r#" +network_policies: + public_api: + name: public_api + endpoints: + - { host: api.github.com, port: 443 } + binaries: + - { path: /usr/bin/curl } + wildcard_api: + name: wildcard_api + endpoints: + - { host: "*.corp.net", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.corp.net", ".") + && resource.port == 443 + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) +}; +"#, + cases: vec![ + same_probe( + "opa.rs::exact_declared_endpoint_host_true_for_l4_host_only / no L7 config", + Probe::Inspection(network("api.github.com", 443, BINARY)), + ), + same_probe( + "opa.rs::exact_declared_endpoint_host_true_for_l4_host_only", + Probe::ExactHost(network("api.github.com", 443, BINARY)), + ), + same_probe( + "opa.rs::exact_declared_endpoint_host_false_for_wildcard_host / allowed", + Probe::Connect(network("api.corp.net", 443, BINARY)), + ), + same_probe( + "opa.rs::exact_declared_endpoint_host_false_for_wildcard_host", + Probe::ExactHost(network("api.corp.net", 443, BINARY)), + ), + ], + }); +} + +/// The multi-port YAML tests, and `from_proto_multi_port_allows_matching`, +/// whose proto policy is the same endpoint written as YAML. +#[test] +fn ported_multi_port_endpoint() { + assert_parity(&Scenario { + name: "multi-port endpoint", + host: "api.example.com", + yaml: r" +network_policies: + multi: + name: multi + endpoints: + - { host: api.example.com, ports: [443, 8443] } + binaries: + - { path: /usr/bin/curl } +", + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host == "api.example.com" + && [443, 8443].contains(resource.port) + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) +}; +"#, + cases: vec![ + same_probe( + "opa.rs::multi_port_endpoint_matches_first_port", + Probe::Connect(network("api.example.com", 443, BINARY)), + ), + same_probe( + "opa.rs::multi_port_endpoint_matches_second_port", + Probe::Connect(network("api.example.com", 8443, BINARY)), + ), + same_probe( + "opa.rs::multi_port_endpoint_rejects_unlisted_port", + Probe::Connect(network("api.example.com", 80, BINARY)), + ), + same_probe( + "opa.rs::from_proto_multi_port_allows_matching / 443", + Probe::Connect(network("api.example.com", 443, BINARY)), + ), + same_probe( + "opa.rs::from_proto_multi_port_allows_matching / 8443", + Probe::Connect(network("api.example.com", 8443, BINARY)), + ), + same_probe( + "opa.rs::from_proto_multi_port_allows_matching / 80", + Probe::Connect(network("api.example.com", 80, BINARY)), + ), + ], + }); +} + +#[test] +fn ported_single_port_backwards_compat() { + assert_parity(&Scenario { + name: "single port", + host: "api.example.com", + yaml: r" +network_policies: + compat: + name: compat + endpoints: + - { host: api.example.com, port: 443 } + binaries: + - { path: /usr/bin/curl } +", + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +"#, + cases: vec![ + same_probe( + "opa.rs::single_port_backwards_compat / 443", + Probe::Connect(network("api.example.com", 443, BINARY)), + ), + same_probe( + "opa.rs::single_port_backwards_compat / 80", + Probe::Connect(network("api.example.com", 80, BINARY)), + ), + ], + }); +} + +/// A scenario for the wildcard-host tests, whose request cases default to +/// `api.example.com`. +fn wildcard_scenario<'a>( + name: &'a str, + yaml: &'a str, + cedar: &'a str, + cases: Vec, +) -> Scenario<'a> { + Scenario { + name, + host: "api.example.com", + yaml, + cedar, + cases, + } +} + +const WILDCARD_CEDAR: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.example.com", ".") + && resource.port == 443 + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) +}; +"#; + +#[test] +fn ported_wildcard_host() { + assert_parity(&wildcard_scenario( + "wildcard host", + r#" +network_policies: + wildcard: + name: wildcard + endpoints: + - { host: "*.example.com", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#, + WILDCARD_CEDAR, + vec![ + same_probe( + "opa.rs::wildcard_host_matches_subdomain", + Probe::Connect(network("api.example.com", 443, BINARY)), + ), + same_probe( + "opa.rs::wildcard_host_rejects_deep_subdomain", + Probe::Connect(network("deep.sub.example.com", 443, BINARY)), + ), + same_probe( + "opa.rs::wildcard_host_rejects_exact_domain", + Probe::Connect(network("example.com", 443, BINARY)), + ), + same_probe( + "opa.rs::wildcard_host_plus_port", + Probe::Connect(network("api.example.com", 80, BINARY)), + ), + ], + )); +} + +/// Cedar endpoint hosts are lowercase, so the Cedar translation of +/// `*.EXAMPLE.COM` writes the pattern in lowercase. +#[test] +fn ported_wildcard_host_case_insensitive() { + assert_parity(&wildcard_scenario( + "uppercase wildcard host", + r#" +network_policies: + wildcard: + name: wildcard + endpoints: + - { host: "*.EXAMPLE.COM", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#, + WILDCARD_CEDAR, + vec![same_probe( + "opa.rs::wildcard_host_case_insensitive", + Probe::Connect(network("api.example.com", 443, BINARY)), + )], + )); +} + +#[test] +fn ported_wildcard_host_intra_label() { + assert_parity(&wildcard_scenario( + "intra-label wildcard", + r#" +network_policies: + intra_label: + name: intra_label + endpoints: + - { host: "*-aiplatform.googleapis.com", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#, + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*-aiplatform.googleapis.com", ".") + && resource.port == 443 + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) +}; +"#, + vec![ + same_probe( + "opa.rs::wildcard_host_intra_label_matches", + Probe::Connect(network( + "us-central1-aiplatform.googleapis.com", + 443, + BINARY, + )), + ), + same_probe( + "opa.rs::wildcard_host_intra_label_does_not_cross_dot", + Probe::Connect(network( + "us-central1.aiplatform.googleapis.com", + 443, + BINARY, + )), + ), + ], + )); +} + +#[test] +fn ported_wildcard_host_middle_label() { + assert_parity(&wildcard_scenario( + "middle-label wildcard", + r#" +network_policies: + s3: + name: s3 + endpoints: + - { host: "*.s3.*.amazonaws.com", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#, + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.s3.*.amazonaws.com", ".") + && resource.port == 443 + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) +}; +"#, + vec![ + same_probe( + "opa.rs::wildcard_host_middle_label_matches_one_region_label", + Probe::Connect(network("my-bucket.s3.us-east-1.amazonaws.com", 443, BINARY)), + ), + same_probe( + "opa.rs::wildcard_host_middle_label_does_not_match_missing_bucket_label", + Probe::Connect(network("s3.us-east-1.amazonaws.com", 443, BINARY)), + ), + same_probe( + "opa.rs::wildcard_host_middle_label_does_not_skip_dualstack_label", + Probe::Connect(network( + "my-bucket.s3.dualstack.us-east-1.amazonaws.com", + 443, + BINARY, + )), + ), + ], + )); +} + +#[test] +fn ported_wildcard_host_multi_port() { + assert_parity(&wildcard_scenario( + "wildcard host, multiple ports", + r#" +network_policies: + wildcard: + name: wildcard + endpoints: + - { host: "*.example.com", ports: [443, 8443] } + binaries: + - { path: /usr/bin/curl } +"#, + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.example.com", ".") + && [443, 8443].contains(resource.port) + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) +}; +"#, + vec![same_probe( + "opa.rs::wildcard_host_multi_port", + Probe::Connect(network("api.example.com", 8443, BINARY)), + )], + )); +} + +/// Cedar rejects an `HttpRequest` policy that does not name one endpoint in +/// its scope, so a YAML L7 rule on a wildcard host can only be translated +/// for the hosts the case uses. +const WILDCARD_L7_CEDAR_CONNECT: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.example.com", ".") + && resource.port == 8080 + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) +}; +"#; + +#[test] +fn ported_wildcard_host_l7_rules_apply() { + let cedar = WILDCARD_L7_CEDAR_CONNECT.to_string() + + r#" +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:8080") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ["GET", "HEAD"].contains(context.method) + && context.path like("/api/**", "/") +}; +"#; + let ctx = || l7_ctx("api.example.com", 8080, BINARY); + assert_parity(&wildcard_scenario( + "wildcard host with L7 rules", + r#" +network_policies: + wildcard_l7: + name: wildcard_l7 + endpoints: + - host: "*.example.com" + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/api/**" + binaries: + - { path: /usr/bin/curl } +"#, + &cedar, + vec![ + same_probe( + "opa.rs::wildcard_host_l7_rules_apply / GET", + request_in(ctx(), rest("GET", "/api/foo")), + ), + same_probe( + "opa.rs::wildcard_host_l7_rules_apply / DELETE", + request_in(ctx(), rest("DELETE", "/api/foo")), + ), + ], + )); +} + +/// The wildcard endpoint's request rules become an `HttpRequest` policy whose +/// endpoint is the same host glob and port in its conditions, which inspects +/// every host the glob matches. The exact endpoint beside it adds its own +/// path, as a YAML endpoint sharing the host and port does. +#[test] +fn ported_wildcard_host_l7_endpoint_config_returned() { + let cedar = WILDCARD_L7_CEDAR_CONNECT.to_string() + + r#" +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource is Sandbox::NetworkEndpoint) +when { + resource.host like("*.example.com", ".") + && resource.port == 8080 + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ["GET", "HEAD"].contains(context.method) +}; +@protocol("rest") +@path("/api/**") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:8080") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && context.method == "POST" + && context.path like("/api/**", "/") +}; +"#; + assert_parity(&wildcard_scenario( + "wildcard host endpoint config", + r#" +network_policies: + wildcard_l7: + name: wildcard_l7 + endpoints: + - host: "*.example.com" + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "**" + - host: api.example.com + port: 8080 + path: "/api/**" + protocol: rest + enforcement: enforce + rules: + - allow: + method: POST + path: "/api/**" + binaries: + - { path: /usr/bin/curl } +"#, + &cedar, + vec![ + same_probe( + "opa.rs::wildcard_host_l7_endpoint_config_returned", + Probe::Inspection(network("api.example.com", 8080, BINARY)), + ), + same_probe( + "opa.rs::wildcard_host_l7_endpoint_config_returned / other subdomain", + Probe::Inspection(network("other.example.com", 8080, BINARY)), + ), + same_probe( + "opa.rs::wildcard_host_l7_endpoint_config_returned / other subdomain GET", + request_in( + l7_ctx("other.example.com", 8080, BINARY), + rest("GET", "/anything"), + ), + ), + same_probe( + "opa.rs::wildcard_host_l7_endpoint_config_returned / other subdomain DELETE", + request_in( + l7_ctx("other.example.com", 8080, BINARY), + rest("DELETE", "/anything"), + ), + ), + same_probe( + "opa.rs::wildcard_host_l7_endpoint_config_returned / exact path POST", + request_in( + l7_ctx("api.example.com", 8080, BINARY), + rest("POST", "/api/items"), + ), + ), + same_probe( + "opa.rs::wildcard_host_l7_endpoint_config_returned / other subdomain POST", + request_in( + l7_ctx("other.example.com", 8080, BINARY), + rest("POST", "/api/items"), + ), + ), + same_probe( + "opa.rs::wildcard_host_l7_endpoint_config_returned / non-matching host", + Probe::Inspection(network("deep.api.example.com", 8080, BINARY)), + ), + ], + )); +} + +/// Cedar rejects an `HttpRequest` policy without one endpoint in its scope, +/// so the multi-port YAML endpoint becomes one policy per port. +#[test] +fn ported_l7_multi_port_request_evaluation() { + assert_parity(&Scenario { + name: "multi-port L7 endpoint", + host: "api.example.com", + yaml: r#" +network_policies: + multi_l7: + name: multi_l7 + endpoints: + - host: api.example.com + ports: [8080, 9090] + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "**" + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host == "api.example.com" + && [8080, 9090].contains(resource.port) + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) +}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:8080") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ["GET", "HEAD"].contains(context.method) +}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:9090") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && ["GET", "HEAD"].contains(context.method) +}; +"#, + cases: vec![ + same_probe( + "opa.rs::l7_multi_port_request_evaluation / 8080", + request_in( + l7_ctx("api.example.com", 8080, BINARY), + rest("GET", "/anything"), + ), + ), + same_probe( + "opa.rs::l7_multi_port_request_evaluation / 9090", + request_in( + l7_ctx("api.example.com", 9090, BINARY), + rest("GET", "/anything"), + ), + ), + ], + }); +} + +#[test] +fn ported_symlink_expanded_binaries() { + let cedar = connect( + "pypi.org:443", + &binaries(&["/usr/bin/python3", "/usr/bin/python3.11"]), + ); + assert_parity(&Scenario { + name: "symlink-expanded binaries", + host: "pypi.org", + yaml: r" +network_policies: + python_policy: + name: python_policy + endpoints: + - { host: pypi.org, port: 443 } + binaries: + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } +", + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::symlink_expanded_binary_allows_resolved_path", + Probe::Connect(network("pypi.org", 443, "/usr/bin/python3.11")), + ), + same_probe( + "opa.rs::symlink_expanded_binary_still_allows_original_path", + Probe::Connect(network("pypi.org", 443, "/usr/bin/python3")), + ), + same_probe( + "opa.rs::symlink_expanded_binary_does_not_weaken_security", + Probe::Connect(network("pypi.org", 443, "/usr/bin/curl")), + ), + same_probe( + "opa.rs::symlink_expansion_works_with_ancestors", + Probe::Connect(network_with_ancestors( + "pypi.org", + 443, + "/usr/bin/curl", + &["/usr/bin/python3.11"], + )), + ), + ], + }); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase1.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase1.rs new file mode 100644 index 0000000000..0b3ce5145c --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase1.rs @@ -0,0 +1,1016 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML token-grant owner tests (Cedar parity plan, phase 1). +//! +//! The gateway stamps each provider endpoint with the owner of its token +//! grants. A YAML sandbox enforces provider rules as network policies; a Cedar +//! sandbox receives them as `provider_credential_rules` and gets its access +//! from the Cedar policy alone. Each scenario here hands the original test's +//! rules to both engines that way ([`ProviderScenario`]), and the Cedar +//! policy translates the same rules into the access they grant. +//! +//! The originals mostly drive bytes through the relay or forward proxy with a +//! scripted grant resolver. Each port checks the owner set the relay selects +//! for the request (`Owners`) and, where the original asserts it, the +//! forwarding decision. Grant resolution, caching, header replacement, and +//! credential-installation guards run after owner selection and do not +//! depend on the policy engine, so they are not ported. +//! +//! An endpoint without `enforcement` defaults to audit for YAML, so the +//! translations of such endpoints mark their Cedar policies +//! `@enforcement("audit")`. A YAML `GET` rule also allows `HEAD`. + +use super::ported_relay::{batch_call, graphql_body, jsonrpc_body, mcp_body}; +use super::*; + +use openshell_core::proto::{ + L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, McpOptions, NetworkBinary, NetworkEndpoint, + NetworkEnforcementMode, NetworkPolicyRule, +}; + +pub(super) const HOST: &str = "api.example.test"; +pub(super) const PORT: u16 = 8080; +const OTHER_CLIENT: &str = "/usr/bin/other-client"; +const NODE: &str = "/usr/bin/node"; + +/// The Cedar condition for a YAML binary entry with an exact path. +pub(super) fn binary(path: &str) -> String { + format!("(context.binary_path == \"{path}\" || context.ancestors.contains(\"{path}\"))") +} + +/// The Cedar condition for a YAML REST rule allowing `GET` (and so `HEAD`). +pub(super) const GET: &str = r#"["GET", "HEAD"].contains(context.method)"#; + +/// A `NetworkConnect` permit for the test endpoint under `condition`. +pub(super) fn connect(condition: &str) -> String { + format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{HOST}:{PORT}") +when {{ {condition} }}; +"# + ) +} + +/// An `HttpRequest` policy on the test endpoint, with `annotations` before it. +pub(super) fn http(annotations: &str, effect: &str, condition: &str) -> String { + format!( + r#" +{annotations} +{effect} (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{HOST}:{PORT}") +when {{ {condition} }}; +"# + ) +} + +/// A network policy named `name` for `binaries`. +fn policy(name: &str, binaries: &[&str], endpoints: Vec) -> NetworkPolicyRule { + NetworkPolicyRule { + name: name.to_string(), + endpoints, + binaries: binaries + .iter() + .map(|path| NetworkBinary { + path: (*path).to_string(), + }) + .collect(), + } +} + +fn allow(method: &str, path: &str) -> L7Rule { + L7Rule { + allow: Some(L7Allow { + method: method.to_string(), + path: path.to_string(), + ..Default::default() + }), + } +} + +/// An endpoint on the test host stamped with `owner` (none when empty). +fn endpoint(owner: &str, path: &str, protocol: &str, rules: Vec) -> NetworkEndpoint { + NetworkEndpoint { + host: HOST.to_string(), + port: u32::from(PORT), + path: path.to_string(), + protocol: protocol.to_string(), + token_grant_owner: owner.to_string(), + rules, + ..Default::default() + } +} + +/// A REST endpoint whose one rule allows `method` on its own `path`, as +/// `opa.rs::token_grant_owner_endpoint` builds it. +fn rest_route(owner: &str, path: &str, method: &str) -> NetworkEndpoint { + endpoint(owner, path, "rest", vec![allow(method, path)]) +} + +/// [`rest_route`] with enforcement set, as the relay fixture's `route` builds it. +fn enforced(mut endpoint: NetworkEndpoint, mode: NetworkEnforcementMode) -> NetworkEndpoint { + endpoint.enforcement = mode as i32; + endpoint +} + +/// The relay fixture's one-endpoint policy per route, named after its owner. +fn route_policy(owner: &str, binary: &str, endpoint: NetworkEndpoint) -> NetworkPolicyRule { + policy(owner, &[binary], vec![endpoint]) +} + +/// An owner probe for [`BINARY`] on the test endpoint. +pub(super) fn owners(request: L7RequestInfo) -> Probe { + owners_in(l7_ctx(HOST, PORT, BINARY), request) +} + +/// A request probe for [`BINARY`] on the test endpoint. +pub(super) fn request(request: L7RequestInfo) -> Probe { + request_in(l7_ctx(HOST, PORT, BINARY), request) +} + +/// A request whose raw target the relay canonicalizes before policy, as +/// `l7/rest.rs` does: dot segments and percent-encoding are resolved and the +/// query is parsed into parameters. +fn canonical(method: &str, raw_target: &str) -> L7RequestInfo { + let (path, query) = crate::l7::path::canonicalize_request_target( + raw_target, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .expect("canonical target"); + let query_params = query + .as_deref() + .map(|query| crate::l7::rest::parse_query_params(query).expect("query parses")) + .unwrap_or_default(); + L7RequestInfo { + query_params, + ..rest(method, &path.path) + } +} + +// --------------------------------------------------------------------------- +// opa.rs +// --------------------------------------------------------------------------- + +#[test] +fn ported_owners_require_exact_endpoint_admission() { + let mut cedar = connect(&format!("{} || {}", binary(BINARY), binary(OTHER_CLIENT))); + cedar.push_str(&http( + r#"@enforcement("audit")"#, + "permit", + &format!( + r#"({curl} && (({GET} && context.path like("/a/**", "/")) + || (context.method == "POST" && context.path like("/a/private/**", "/")))) + || ({other} && {GET} && context.path like("/a/**", "/"))"#, + curl = binary(BINARY), + other = binary(OTHER_CLIENT), + ), + )); + assert_provider_parity(&ProviderScenario { + name: "exact endpoint admission", + host: HOST, + rules: vec![ + policy( + "allowed", + &[BINARY], + vec![ + rest_route("owner-broad", "/a/**", "GET"), + rest_route("owner-narrow", "/a/private/**", "POST"), + ], + ), + policy( + "other_binary", + &[OTHER_CLIENT], + vec![rest_route("owner-other", "/a/**", "GET")], + ), + ], + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::l7_token_grant_owners_require_exact_endpoint_admission/GET forwarded", + request(rest("GET", "/a/private/item")), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_token_grant_owners_require_exact_endpoint_admission/GET owner", + owners(rest("GET", "/a/private/item")), + ) + .asserting_yaml("owner-broad"), + same_probe( + "opa.rs::l7_token_grant_owners_require_exact_endpoint_admission/POST forwarded", + request(rest("POST", "/a/private/item")), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_token_grant_owners_require_exact_endpoint_admission/POST owner", + owners(rest("POST", "/a/private/item")), + ) + .asserting_yaml("owner-narrow"), + ], + }); +} + +#[test] +fn ported_owners_preserve_union_allow_and_global_deny() { + let mut broad = rest_route("owner-z", "/a/**", "GET"); + broad.deny_rules = vec![L7DenyRule { + method: "GET".to_string(), + path: "/a/private/blocked".to_string(), + ..Default::default() + }]; + let curl = binary(BINARY); + let mut cedar = connect(&curl); + // The narrow endpoints' rules are covered by the broad one. + cedar.push_str(&http( + r#"@enforcement("audit")"#, + "permit", + &format!(r#"{curl} && {GET} && context.path like("/a/**", "/")"#), + )); + cedar.push_str(&http( + r#"@enforcement("audit")"#, + "forbid", + &format!(r#"{curl} && {GET} && context.path == "/a/private/blocked""#), + )); + assert_provider_parity(&ProviderScenario { + name: "union allow and global deny", + host: HOST, + rules: vec![ + policy("broad", &[BINARY], vec![broad]), + policy( + "narrow", + &[BINARY], + vec![ + rest_route("owner-a", "/a/private/**", "GET"), + rest_route("owner-z", "/a/private/**", "GET"), + ], + ), + ], + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::l7_token_grant_owners_preserve_union_allow_and_global_deny/allowed", + request(rest("GET", "/a/private/item")), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_token_grant_owners_preserve_union_allow_and_global_deny/union", + owners(rest("GET", "/a/private/item")), + ) + .asserting_yaml("owner-a,owner-z"), + same_probe( + "opa.rs::l7_token_grant_owners_preserve_union_allow_and_global_deny/denied", + request(rest("GET", "/a/private/blocked")), + ) + .asserting_yaml("deny"), + same_probe( + "opa.rs::l7_token_grant_owners_preserve_union_allow_and_global_deny/deny admits none", + owners(rest("GET", "/a/private/blocked")), + ) + .asserting_yaml("none"), + ], + }); +} + +#[test] +fn ported_owners_preserve_binary_and_query_restrictions() { + let mut endpoint = rest_route("owner", "/a/**", "GET"); + endpoint.rules[0] + .allow + .as_mut() + .expect("allow rule") + .query + .insert( + "team".to_string(), + L7QueryMatcher { + glob: "prod-*".to_string(), + ..Default::default() + }, + ); + // Cedar cannot yet match query parameters (plan phase 4), so the access + // translation omits the query restriction. The provider rule still + // carries it, and owner admission checks it. + // The YAML binary glob, which also matches an ancestor, as + // `context.binary_globs` lists the patterns that match the request. + let glob = r#"context.binary_globs.contains("/opt/tools/*")"#; + let mut cedar = connect(glob); + cedar.push_str(&http( + r#"@enforcement("audit")"#, + "permit", + &format!(r#"{glob} && {GET} && context.path like("/a/**", "/")"#), + )); + let query = |values: &[&str]| { + let mut request = rest("GET", "/a/item"); + request.query_params = HashMap::from([( + "team".to_string(), + values.iter().map(ToString::to_string).collect(), + )]); + request + }; + let mut argv = l7_ctx(HOST, PORT, BINARY); + argv.cmdline_paths = vec!["/opt/tools/client".to_string()]; + let mut ancestor = argv.clone(); + ancestor.ancestors = vec!["/opt/tools/client".to_string()]; + assert_provider_parity(&ProviderScenario { + name: "binary and query restrictions", + host: HOST, + rules: vec![policy("owner", &["/opt/tools/*"], vec![endpoint])], + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::l7_token_grant_owners_preserve_binary_and_query_restrictions/argv", + owners_in(argv, query(&["prod-east"])), + ) + .asserting_yaml("none"), + same_probe( + "opa.rs::l7_token_grant_owners_preserve_binary_and_query_restrictions/ancestor", + owners_in(ancestor.clone(), query(&["prod-east"])), + ) + .asserting_yaml("owner"), + same_probe( + "opa.rs::l7_token_grant_owners_preserve_binary_and_query_restrictions/every query value", + owners_in(ancestor, query(&["prod-east", "dev"])), + ) + .asserting_yaml("none"), + // Positive control: the calling binary itself matches the glob, + // which Cedar can express. + same_probe( + "binary and query restrictions/calling binary matches the glob", + owners_in( + l7_ctx(HOST, PORT, "/opt/tools/client"), + query(&["prod-east"]), + ), + ) + .asserting_yaml("owner"), + ], + }); +} + +#[test] +fn ported_owners_exclude_audit_denial_and_missing_metadata() { + // The original also stamps a non-string owner (42) in raw Rego data. A + // gateway-stamped owner is a proto string, so that case cannot reach + // either engine and is not ported; an empty owner is the proto form of + // a missing one. + let mut audit = rest_route("owner-audit", "/a/**", "GET"); + audit.enforcement = NetworkEnforcementMode::Audit as i32; + let curl = binary(BINARY); + let mut cedar = connect(&curl); + cedar.push_str(&http( + r#"@enforcement("audit")"#, + "permit", + &format!( + r#"{curl} && {GET} && (context.path like("/a/**", "/") + || context.path like("/b/**", "/") || context.path like("/d/**", "/"))"# + ), + )); + assert_provider_parity(&ProviderScenario { + name: "audit denial and missing owner metadata", + host: HOST, + rules: vec![policy( + "owner", + &[BINARY], + vec![ + audit, + rest_route("", "/b/**", "GET"), + rest_route("", "/d/**", "GET"), + ], + )], + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::l7_token_grant_owners_exclude_audit_denial_and_missing_metadata/audit denied", + request(rest("POST", "/a/item")), + ) + .asserting_yaml("deny"), + same_probe( + "opa.rs::l7_token_grant_owners_exclude_audit_denial_and_missing_metadata/audit denial admits none", + owners(rest("POST", "/a/item")), + ) + .asserting_yaml("none"), + same_probe( + "audit denial and missing owner metadata/audit allow admits its owner", + owners(rest("GET", "/a/item")), + ) + .asserting_yaml("owner-audit"), + same_probe( + "audit denial and missing owner metadata/inspection", + Probe::Inspection(network(HOST, PORT, BINARY)), + ) + .asserting_yaml("Rest/Audit"), + same_probe( + "opa.rs::l7_token_grant_owners_exclude_audit_denial_and_missing_metadata/missing forwarded", + request(rest("GET", "/b/item")), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_token_grant_owners_exclude_audit_denial_and_missing_metadata/missing", + owners(rest("GET", "/b/item")), + ) + .asserting_yaml("none"), + same_probe( + "opa.rs::l7_token_grant_owners_exclude_audit_denial_and_missing_metadata/empty forwarded", + request(rest("GET", "/d/item")), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_token_grant_owners_exclude_audit_denial_and_missing_metadata/empty", + owners(rest("GET", "/d/item")), + ) + .asserting_yaml("none"), + ], + }); +} + +#[test] +fn ported_owners_preserve_proto_identity() { + let curl = binary(BINARY); + let mut cedar = connect(&curl); + cedar.push_str(&http( + r#"@enforcement("audit")"#, + "permit", + &format!(r#"{curl} && {GET} && context.path like("/a/**", "/")"#), + )); + assert_provider_parity(&ProviderScenario { + name: "proto owner identity", + host: HOST, + rules: vec![policy( + "owner", + &[BINARY], + vec![rest_route("gateway-stamped-owner", "/a/**", "GET")], + )], + cedar: &cedar, + cases: vec![ + same_probe( + "opa.rs::l7_token_grant_owners_preserve_proto_identity/forwarded", + request(rest("GET", "/a/item")), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_token_grant_owners_preserve_proto_identity", + owners(rest("GET", "/a/item")), + ) + .asserting_yaml("gateway-stamped-owner"), + ], + }); +} + +// --------------------------------------------------------------------------- +// l7/relay/token_grant_ownership_tests.rs +// --------------------------------------------------------------------------- + +/// The relay fixture's enforced REST route for `owner` on `path`. +fn relay_route(owner: &str, path: &str, method: &str) -> NetworkEndpoint { + enforced( + rest_route(owner, path, method), + NetworkEnforcementMode::Enforce, + ) +} + +/// The Cedar policy for enforced curl REST routes `(method condition, path glob)`. +fn relay_cedar(routes: &[(&str, &str)]) -> String { + let curl = binary(BINARY); + let routes = routes + .iter() + .map(|(method, path)| format!(r#"({method} && context.path like("{path}", "/"))"#)) + .collect::>() + .join(" || "); + let mut cedar = connect(&curl); + cedar.push_str(&http("", "permit", &format!("{curl} && ({routes})"))); + cedar +} + +/// The `native_protocol_fixture` routes for `protocol`: the native route, +/// and with `multiple_routes` the unrelated REST route on `/other/**`. +/// +/// The variants with the unrelated route need a protocol per path, so their +/// Cedar policies carry `@path`; `ported_phase3` ports them. +pub(super) fn native_scenario( + protocol: &str, + multiple_routes: bool, +) -> (Vec, String) { + let path = if multiple_routes { + r#"@path("/native")"# + } else { + "" + }; + let curl = binary(BINARY); + let (rule, condition) = match protocol { + "graphql" => ( + L7Allow { + operation_type: "query".to_string(), + fields: vec!["echo".to_string()], + ..Default::default() + }, + r#"context.graphql_operation_type == "query" + && context.graphql_fields.containsAny(["echo"]) + && ["echo"].containsAll(context.graphql_fields)"# + .to_string(), + ), + "json-rpc" => ( + L7Allow { + method: "echo".to_string(), + ..Default::default() + }, + r#"!context.jsonrpc_response && context.jsonrpc_method == "echo""#.to_string(), + ), + "mcp" => ( + L7Allow { + method: "tools/call".to_string(), + params: HashMap::from([( + "name".to_string(), + L7QueryMatcher { + glob: "echo".to_string(), + ..Default::default() + }, + )]), + ..Default::default() + }, + r#"!context.jsonrpc_response + && context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" + && context.mcp_tool == "echo""# + .to_string(), + ), + _ => panic!("unsupported fixture protocol"), + }; + let mut native = enforced( + endpoint( + "native", + "/native", + protocol, + vec![L7Rule { allow: Some(rule) }], + ), + NetworkEnforcementMode::Enforce, + ); + if protocol == "mcp" { + native.mcp = Some(McpOptions { + versions: vec!["2025-11-25".to_string()], + ..Default::default() + }); + } + let mut cedar = connect(&curl); + cedar.push_str(&http( + &format!("{path} @protocol(\"{protocol}\")"), + "permit", + &format!(r#"{curl} && context.path == "/native" && {condition}"#), + )); + if protocol == "mcp" { + // A YAML MCP endpoint also allows its receive stream and client + // response frames on the endpoint path. + cedar.push_str(&http( + path, + "permit", + &format!( + r#"{curl} && context.path == "/native" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response)"# + ), + )); + } + let mut rules = vec![route_policy("native", BINARY, native)]; + if multiple_routes { + rules.push(route_policy( + "unrelated", + BINARY, + relay_route("unrelated", "/other/**", "GET"), + )); + cedar.push_str(&http( + r#"@path("/other/**")"#, + "permit", + &format!(r#"{curl} && {GET} && context.path like("/other/**", "/")"#), + )); + } + (rules, cedar) +} + +/// The relay's parsed request for `native_protocol_body(protocol, operation)`. +pub(super) fn native_request(protocol: &str, operation: &str) -> L7RequestInfo { + match protocol { + "graphql" => graphql_body( + "/native", + &serde_json::json!({"query": format!("query {{ {operation} }}")}), + ), + "json-rpc" => jsonrpc_body( + "POST", + "/native", + &serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": operation}).to_string(), + ), + "mcp" => mcp_body( + "POST", + "/native", + &serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": operation, "arguments": {}} + }) + .to_string(), + ), + _ => panic!("unsupported fixture protocol"), + } +} + +#[test] +fn ported_native_protocol_grants() { + const PRESERVE: &str = "relay/token_grant_ownership_tests.rs::native_protocol_grants_preserve_auth_when_an_unrelated_route_is_added"; + const DENIALS: &str = "relay/token_grant_ownership_tests.rs::native_protocol_denials_do_not_resolve_grants_or_forward_bytes"; + const MISSING: &str = "relay/token_grant_ownership_tests.rs::missing_grant_owner_metadata_rejects_only_matching_requests"; + for protocol in ["graphql", "json-rpc", "mcp"] { + let (rules, cedar) = native_scenario(protocol, false); + let mut cases = vec![ + same_probe(PRESERVE, request(native_request(protocol, "echo"))).asserting_yaml("allow"), + same_probe(PRESERVE, owners(native_request(protocol, "echo"))).asserting_yaml("native"), + same_probe(DENIALS, request(native_request(protocol, "blocked"))) + .asserting_yaml("deny"), + same_probe(DENIALS, owners(native_request(protocol, "blocked"))).asserting_yaml("none"), + ]; + if protocol == "mcp" { + // The original installs an extra grant without owner metadata; + // which grant is rejected is decided after owner selection. + cases.push( + same_probe(MISSING, owners(native_request(protocol, "echo"))) + .asserting_yaml("native"), + ); + } + assert_provider_parity(&ProviderScenario { + name: protocol, + host: HOST, + rules, + cedar: &cedar, + cases, + }); + } +} + +#[test] +fn ported_route_selected_grants() { + const CANONICAL: &str = "relay/token_grant_ownership_tests.rs::grant_selection_uses_canonical_path_and_ignores_query_selector_text"; + const ABA: &str = "relay/token_grant_ownership_tests.rs::route_selected_grants_follow_a_b_a_on_one_connection"; + // `grant_selection_uses_canonical_path...` declares only route `a`; route + // `b` does not match its request path, so one scenario serves both. + assert_provider_parity(&ProviderScenario { + name: "route-selected grants", + host: HOST, + rules: vec![ + route_policy("a", BINARY, relay_route("a", "/a/**", "GET")), + route_policy("b", BINARY, relay_route("b", "/b/**", "GET")), + ], + cedar: &relay_cedar(&[(GET, "/a/**"), (GET, "/b/**")]), + cases: vec![ + same_probe( + CANONICAL, + request(canonical("GET", "/outside/../a/%69tem?next=/b/item")), + ) + .asserting_yaml("allow"), + same_probe( + CANONICAL, + owners(canonical("GET", "/outside/../a/%69tem?next=/b/item")), + ) + .asserting_yaml("a"), + same_probe(ABA, owners(canonical("GET", "/a/first"))).asserting_yaml("a"), + same_probe(ABA, owners(canonical("GET", "/a/../b/%69tem?next=/a/item"))) + .asserting_yaml("b"), + same_probe(ABA, owners(canonical("GET", "/a/third"))).asserting_yaml("a"), + ], + }); +} + +#[test] +fn ported_overlapping_get_and_post_routes() { + const BROADER: &str = "relay/token_grant_ownership_tests.rs::broader_get_allow_does_not_authorize_narrow_post_owner"; + const OVERLAPPING: &str = "relay/token_grant_ownership_tests.rs::overlapping_get_and_post_select_their_admitted_owner_on_one_connection"; + assert_provider_parity(&ProviderScenario { + name: "overlapping GET and POST routes", + host: HOST, + rules: vec![ + route_policy("broad", BINARY, relay_route("broad", "/a/**", "GET")), + route_policy( + "narrow", + BINARY, + relay_route("narrow", "/a/private/**", "POST"), + ), + ], + cedar: &relay_cedar(&[ + (GET, "/a/**"), + (r#"context.method == "POST""#, "/a/private/**"), + ]), + cases: vec![ + same_probe(BROADER, request(rest("GET", "/a/private/item"))).asserting_yaml("allow"), + same_probe(BROADER, owners(rest("GET", "/a/private/item"))).asserting_yaml("broad"), + same_probe(OVERLAPPING, owners(rest("POST", "/a/private/item"))) + .asserting_yaml("narrow"), + same_probe(OVERLAPPING, request(rest("POST", "/a/private/item"))) + .asserting_yaml("allow"), + ], + }); +} + +#[test] +fn ported_equal_selector_does_not_authorize_another_binarys_grant() { + const NAME: &str = "relay/token_grant_ownership_tests.rs::equal_selector_does_not_authorize_another_binarys_grant"; + let curl = binary(BINARY); + let other = binary(OTHER_CLIENT); + let mut cedar = connect(&format!("{curl} || {other}")); + cedar.push_str(&http( + "", + "permit", + &format!(r#"({curl} || {other}) && {GET} && context.path like("/a/**", "/")"#), + )); + assert_provider_parity(&ProviderScenario { + name: "equal selector, other binary", + host: HOST, + rules: vec![ + route_policy("allowed", BINARY, relay_route("allowed", "/a/**", "GET")), + route_policy("owner", OTHER_CLIENT, relay_route("owner", "/a/**", "GET")), + ], + cedar: &cedar, + cases: vec![ + same_probe(NAME, request(rest("GET", "/a/item"))).asserting_yaml("allow"), + same_probe(NAME, owners(rest("GET", "/a/item"))).asserting_yaml("allowed"), + // Positive control: the owning binary is admitted its own grant. + same_probe( + "equal selector, other binary/owning binary", + owners_in(l7_ctx(HOST, PORT, OTHER_CLIENT), rest("GET", "/a/item")), + ) + .asserting_yaml("owner"), + ], + }); +} + +#[test] +fn ported_denials_admit_no_grant() { + const ENFORCED: &str = + "relay/token_grant_ownership_tests.rs::enforced_denial_mints_nothing_and_forwards_no_bytes"; + const AUDIT: &str = + "relay/token_grant_ownership_tests.rs::audit_denial_does_not_authorize_a_grant"; + for (mode, annotation, inspection, name) in [ + ( + NetworkEnforcementMode::Enforce, + "", + "Rest/Enforce", + ENFORCED, + ), + ( + NetworkEnforcementMode::Audit, + r#"@enforcement("audit")"#, + "Rest/Audit", + AUDIT, + ), + ] { + let curl = binary(BINARY); + let mut cedar = connect(&curl); + cedar.push_str(&http( + annotation, + "permit", + &format!(r#"{curl} && {GET} && context.path like("/a/**", "/")"#), + )); + assert_provider_parity(&ProviderScenario { + name: inspection, + host: HOST, + rules: vec![route_policy( + "a", + BINARY, + enforced(rest_route("a", "/a/**", "GET"), mode), + )], + cedar: &cedar, + cases: vec![ + // The relay forwards an audit denial and rejects an enforced + // one; the inspection decides which. + same_probe(name, Probe::Inspection(network(HOST, PORT, BINARY))) + .asserting_yaml(inspection), + same_probe(name, request(rest("POST", "/a/item"))).asserting_yaml("deny"), + same_probe(name, owners(rest("POST", "/a/item"))).asserting_yaml("none"), + // Positive control: an allowed request admits the owner. + same_probe( + "denials admit no grant/allowed request", + owners(rest("GET", "/a/item")), + ) + .asserting_yaml("a"), + ], + }); + } +} + +/// A JSON-RPC endpoint on `path` allowing `methods`, as the body tests build it. +fn jsonrpc_route(owner: &str, path: &str, methods: &[&str]) -> NetworkEndpoint { + enforced( + endpoint( + owner, + path, + "json-rpc", + methods + .iter() + .map(|method| L7Rule { + allow: Some(L7Allow { + method: (*method).to_string(), + ..Default::default() + }), + }) + .collect(), + ), + NetworkEnforcementMode::Enforce, + ) +} + +#[test] +fn ported_jsonrpc_batch_requires_one_owner_to_admit_every_member() { + const NAME: &str = "relay/token_grant_ownership_tests.rs::jsonrpc_batch_requires_one_owner_to_admit_every_member"; + let batch = jsonrpc_body( + "POST", + "/rpc", + r#"[{"jsonrpc":"2.0","id":1,"method":"readFirst"},{"jsonrpc":"2.0","id":2,"method":"readSecond"}]"#, + ); + let curl = binary(BINARY); + let mut cedar = connect(&curl); + cedar.push_str(&http( + r#"@protocol("json-rpc")"#, + "permit", + &format!( + r#"{curl} && context.path == "/rpc" && !context.jsonrpc_response + && ["readFirst", "readSecond"].contains(context.jsonrpc_method)"# + ), + )); + for (shared_owner, expected) in [(false, "none"), (true, "shared")] { + let mut endpoints = vec![ + jsonrpc_route("first", "/rpc", &["readFirst"]), + jsonrpc_route("second", "/rpc", &["readSecond"]), + ]; + if shared_owner { + endpoints.push(jsonrpc_route( + "shared", + "/rpc", + &["readFirst", "readSecond"], + )); + } + assert_provider_parity(&ProviderScenario { + name: expected, + host: HOST, + rules: vec![policy("body_api", &[BINARY], endpoints)], + cedar: &cedar, + cases: vec![ + same_probe(NAME, request(batch_call(&batch, 0))).asserting_yaml("allow"), + same_probe(NAME, request(batch_call(&batch, 1))).asserting_yaml("allow"), + same_probe(NAME, owners(batch.clone())).asserting_yaml(expected), + // Positive control: one member alone admits its own owner. + same_probe( + "JSON-RPC batch/first member alone", + owners(batch_call(&batch, 0)), + ) + .asserting_yaml(if shared_owner { + "first,shared" + } else { + "first" + }), + ], + }); + } +} + +#[test] +fn ported_transformed_jsonrpc_body_selects_final_operation_owner() { + const NAME: &str = "relay/token_grant_ownership_tests.rs::transformed_jsonrpc_body_selects_final_operation_owner"; + let curl = binary(BINARY); + let mut cedar = connect(&curl); + cedar.push_str(&http( + r#"@protocol("json-rpc")"#, + "permit", + &format!( + r#"{curl} && !context.jsonrpc_response + && ((context.path == "/api/rpc" && context.jsonrpc_method == "readOriginal") + || (context.path like("/api/**", "/") + && context.jsonrpc_method == "readTransformed"))"# + ), + )); + // The relay recomputes owners for the body it will send, so the + // transformed body is probed as its own request. + let original = jsonrpc_body( + "POST", + "/api/rpc", + r#"{"jsonrpc":"2.0","id":1,"method":"readOriginal"}"#, + ); + let transformed = jsonrpc_body( + "POST", + "/api/rpc", + r#"{"jsonrpc":"2.0","id":1,"method":"readTransformed"}"#, + ); + assert_provider_parity(&ProviderScenario { + name: "transformed JSON-RPC body", + host: HOST, + rules: vec![policy( + "body_api", + &[BINARY], + vec![ + jsonrpc_route("original", "/api/rpc", &["readOriginal"]), + jsonrpc_route("transformed", "/api/**", &["readTransformed"]), + ], + )], + cedar: &cedar, + cases: vec![ + same_probe(NAME, request(original.clone())).asserting_yaml("allow"), + same_probe(NAME, owners(original)).asserting_yaml("original"), + same_probe(NAME, request(transformed.clone())).asserting_yaml("allow"), + same_probe(NAME, owners(transformed)).asserting_yaml("transformed"), + ], + }); +} + +// --------------------------------------------------------------------------- +// proxy/tests/token_grants.rs +// --------------------------------------------------------------------------- + +#[test] +fn ported_forward_inspection_owner_selection() { + const SELECTS: &str = "proxy/tests/token_grants.rs::forward_inspection_selects_only_the_owner_admitting_the_request"; + const RETAINS: &str = "proxy/tests/token_grants.rs::forward_inspection_retains_installation_through_guarded_write"; + let node = binary(NODE); + let mut cedar = connect(&node); + cedar.push_str(&http( + r#"@enforcement("audit")"#, + "permit", + &format!( + r#"{node} && (({GET} && context.path like("/v1/**", "/")) + || (context.method == "POST" && context.path == "/v1/projects"))"# + ), + )); + let ctx = l7_ctx(HOST, PORT, NODE); + assert_provider_parity(&ProviderScenario { + name: "forward inspection", + host: HOST, + rules: vec![policy( + "rest_api", + &[NODE], + vec![ + rest_route("other-owner", "/v1/**", "GET"), + endpoint( + "test-owner", + "/v1/projects", + "rest", + vec![allow("POST", "/v1/projects")], + ), + ], + )], + cedar: &cedar, + cases: vec![ + same_probe( + SELECTS, + request_in(ctx.clone(), rest("GET", "/v1/projects")), + ) + .asserting_yaml("allow"), + same_probe(SELECTS, owners_in(ctx.clone(), rest("GET", "/v1/projects"))) + .asserting_yaml("other-owner"), + same_probe( + SELECTS, + request_in(ctx.clone(), rest("POST", "/v1/projects")), + ) + .asserting_yaml("allow"), + same_probe( + SELECTS, + owners_in(ctx.clone(), rest("POST", "/v1/projects")), + ) + .asserting_yaml("test-owner"), + same_probe(RETAINS, owners_in(ctx, rest("POST", "/v1/projects"))) + .asserting_yaml("test-owner"), + ], + }); +} + +// --------------------------------------------------------------------------- +// proxy.rs +// --------------------------------------------------------------------------- + +/// The forward-proxy grant tests run without an inspected endpoint: an +/// uninspected forward request keeps selector-based credentials, with no +/// owner decision, and the grant itself is resolved without the policy +/// engine. What the policy decides is that the connection is allowed and not +/// inspected, which is what these cases compare. +#[test] +fn ported_forward_proxy_uninspected_grants() { + assert_parity(&Scenario { + name: "uninspected forward request", + host: HOST, + yaml: r" +network_policies: + rest_api: + name: rest_api + endpoints: + - { host: api.example.test, port: 8080 } + binaries: + - { path: /usr/bin/curl } +", + cedar: &connect(&binary(BINARY)), + cases: [ + "proxy.rs::forward_proxy_injects_token_grant_before_rewriting_request", + "proxy.rs::forward_proxy_injects_token_exchange_before_rewriting_request", + "proxy.rs::forward_proxy_token_grant_failure_returns_error_before_rewrite", + "proxy.rs::forward_proxy_token_exchange_failure_returns_error_before_rewrite", + ] + .into_iter() + .flat_map(|name| { + [ + same_probe(name, Probe::Connect(network(HOST, PORT, BINARY))) + .asserting_yaml("allow"), + same_probe(name, Probe::Inspection(network(HOST, PORT, BINARY))) + .asserting_yaml("none"), + ] + }) + .collect(), + }); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase2.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase2.rs new file mode 100644 index 0000000000..c39a72b787 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase2.rs @@ -0,0 +1,877 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML endpoint-setting tests (Cedar parity plan, phase 2). +//! +//! A YAML endpoint carries configuration such as `tls: skip`, +//! `allow_encoded_slash`, MCP revisions, and the GraphQL persisted-query +//! registry next to its rules. A Cedar sandbox gets the same configuration +//! from the `endpoint_settings` section of its middleware file +//! ([`SettingsScenario`]), and its access from the Cedar policy alone. Each +//! translation keeps the YAML endpoint's settings on a settings entry for +//! the same host and port. A YAML endpoint `path` that only scopes which +//! requests the rules apply to becomes a `context.path` condition, as in the +//! other ports; a settings entry gets a `path` only where the original +//! scopes a setting to one path of a shared `host:port`. +//! +//! The relay originals drive bytes through the L7 relay. Their ports do the +//! same with [`Probe::Relay`], using the endpoint configs and tunnel engine +//! each engine supplies, because the MCP revision and encoded-slash checks +//! the originals exercise run in the relay, outside the policy decision. + +use super::ported_opa_a::{L7_TEST_CEDAR, L7_TEST_DATA}; +use super::*; + +use std::fmt::Write as _; + +const PYTHON: &str = "/usr/bin/python3"; +const NODE: &str = "/usr/bin/node"; +const MCP_HOST: &str = "mcp.example.test"; +const MCP_PORT: u16 = 8000; + +/// The Cedar condition for a YAML binary entry with an exact path. +fn binary(path: &str) -> String { + format!("(context.binary_path == \"{path}\" || context.ancestors.contains(\"{path}\"))") +} + +/// A relay exchange from [`PYTHON`] to the MCP test endpoint. +fn mcp_exchange( + route_selected: bool, + request: String, + upstream_response: &str, + marker: Option<&'static str>, +) -> Probe { + Probe::Relay(Box::new(RelayExchange { + ctx: l7_ctx(MCP_HOST, MCP_PORT, PYTHON), + route_selected, + request, + upstream_response: upstream_response.to_string(), + marker, + })) +} + +/// An MCP request as the relay originals send it. +fn mcp_request(method: &str, headers: &str, body: &str) -> String { + format!( + "{method} /mcp HTTP/1.1\r\nHost: {MCP_HOST}:{MCP_PORT}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) +} + +/// A sessionless (2026-07-28) MCP body, as `sessionless_mcp_body` builds it. +fn sessionless_mcp_body(method: &str, mut params: serde_json::Value) -> String { + params["_meta"] = serde_json::json!({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + }); + serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string() +} + +/// The `mcp_api` endpoint of the MCP relay originals: tool `read_status` +/// allowed, `delete_resource` denied, under `enforcement` and `versions`. +fn mcp_tool_yaml(enforcement: &str, versions: &str) -> String { + format!( + r" +network_policies: + mcp_api: + name: mcp_api + endpoints: + - host: {MCP_HOST} + port: {MCP_PORT} + path: /mcp + protocol: mcp + enforcement: {enforcement} + mcp: + versions: {versions} + rules: + - allow: + method: tools/call + tool: read_status + deny_rules: + - method: tools/call + tool: delete_resource + binaries: + - {{ path: {PYTHON} }} +" + ) +} + +/// The Cedar translation of [`mcp_tool_yaml`]. +/// +/// YAML MCP endpoints implicitly admit the receive stream and response +/// frames; the second permit states that explicitly. +fn mcp_tool_cedar(enforcement_annotation: &str) -> String { + let binary = binary(PYTHON); + format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{MCP_HOST}:{MCP_PORT}") +when {{ {binary} }}; +@protocol("mcp"){enforcement_annotation} +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{MCP_HOST}:{MCP_PORT}") +when {{ + {binary} + && context.path == "/mcp" + && !context.jsonrpc_response + && context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" + && context.mcp_tool == "read_status" +}};{enforcement_annotation} +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{MCP_HOST}:{MCP_PORT}") +when {{ + {binary} + && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response) +}};{enforcement_annotation} +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{MCP_HOST}:{MCP_PORT}") +when {{ context.jsonrpc_method == "tools/call" && context.mcp_tool == "delete_resource" }}; +"# + ) +} + +/// The middleware file carrying [`mcp_tool_yaml`]'s MCP revisions. +fn mcp_settings(versions: &str) -> String { + format!( + "endpoint_settings:\n - host: {MCP_HOST}\n port: {MCP_PORT}\n mcp:\n versions: {versions}\n" + ) +} + +/// The `@enforcement` annotation for a YAML enforcement mode. +fn enforcement_annotation(enforcement: &str) -> &'static str { + if enforcement == "audit" { + "\n@enforcement(\"audit\")" + } else { + "" + } +} + +// --------------------------------------------------------------------------- +// opa.rs +// --------------------------------------------------------------------------- + +#[test] +fn ported_mcp_strict_tool_names_opt_out() { + let curl = binary(BINARY); + let cedar = format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"mcp.example.com:443") +when {{ {curl} }}; +@protocol("mcp") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.com:443") +when {{ + {curl} + && context.path == "/mcp" + && !context.jsonrpc_response + && context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" +}}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.com:443") +when {{ + {curl} + && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response) +}}; +"# + ); + assert_settings_parity(&SettingsScenario { + name: "opa.rs MCP strict tool names", + host: "mcp.example.com", + yaml: r#" +network_policies: + mcp: + name: mcp + endpoints: + - host: mcp.example.com + port: 443 + path: /mcp + protocol: mcp + enforcement: enforce + mcp: + versions: ["2025-11-25", "2025-03-26"] + strict_tool_names: false + rules: + - allow: + method: tools/call + binaries: + - { path: /usr/bin/curl } +"#, + cedar: &cedar, + settings: r#" +endpoint_settings: + - host: mcp.example.com + port: 443 + mcp: + versions: ["2025-11-25", "2025-03-26"] + strict_tool_names: false +"#, + cases: vec![ + same_probe( + "opa.rs::l7_endpoint_config_preserves_mcp_strict_tool_names_opt_out", + Probe::Configs(network("mcp.example.com", 443, BINARY)), + ) + .asserting_yaml( + "Mcp/Enforce/tls=Auto/mcp=[2025-03-26,2025-11-25]/strict=false/slash=false/jsonrpc=65536/graphql=65536", + ), + ], + }); +} + +/// A hash-only persisted query, as `l7_graphql_input` describes it. +fn hash_only_query(hash: &str) -> L7RequestInfo { + let mut operation = operation("", Some("Viewer"), &[]); + operation.persisted_query = true; + operation.persisted_query_hash = Some(hash.to_string()); + graphql(vec![operation]) +} + +/// The persisted-query settings of `graphql_api` in [`L7_TEST_DATA`]. +const GRAPHQL_REGISTRY_SETTINGS: &str = r" +endpoint_settings: + - host: api.graphql.com + port: 443 + persisted_queries: allow_registered + graphql_persisted_queries: + abc123: + operation_type: query + operation_name: Viewer + fields: [viewer] +"; + +#[test] +fn ported_graphql_persisted_query_registry() { + assert_settings_parity(&SettingsScenario { + name: "opa.rs GraphQL persisted queries", + host: "api.graphql.com", + yaml: L7_TEST_DATA, + cedar: L7_TEST_CEDAR, + settings: GRAPHQL_REGISTRY_SETTINGS, + cases: vec![ + same( + "opa.rs::l7_graphql_registered_hash_only_query_allowed", + hash_only_query("abc123"), + ) + .asserting_yaml("allow"), + same( + "opa.rs::l7_graphql_unregistered_hash_only_query_denied", + hash_only_query("missing"), + ) + .asserting_yaml("deny"), + same_probe( + "opa.rs::l7_graphql_unregistered_hash_only_query_has_deny_reason", + Probe::DenyReason { + ctx: None, + request: Box::new(hash_only_query("missing")), + }, + ) + .asserting_yaml("deny: GraphQL persisted query is not registered"), + ], + }); +} + +#[test] +fn graphql_persisted_query_registry_requires_allow_registered() { + // Without `persisted_queries: allow_registered`, YAML denies every + // hash-only query, registered or not; so must Cedar. + assert_settings_parity(&SettingsScenario { + name: "GraphQL persisted queries default to deny", + host: "api.graphql.com", + yaml: &L7_TEST_DATA.replace( + "persisted_queries: allow_registered", + "persisted_queries: deny", + ), + cedar: L7_TEST_CEDAR, + settings: &GRAPHQL_REGISTRY_SETTINGS.replace( + "persisted_queries: allow_registered", + "persisted_queries: deny", + ), + cases: vec![ + same("registered hash, mode deny", hash_only_query("abc123")).asserting_yaml("deny"), + same_probe( + "registered hash, mode deny, reason", + Probe::DenyReason { + ctx: None, + request: Box::new(hash_only_query("abc123")), + }, + ), + ], + }); +} + +// --------------------------------------------------------------------------- +// proxy.rs +// --------------------------------------------------------------------------- + +/// The handler tests' policy: the temp-dir binary glob, with `tls_line`. +fn handler_yaml(tls_line: &str) -> String { + format!( + r#"network_policies: + test_allow: + name: test_allow + endpoints: + - {{ host: "203.0.113.10", port: 443{tls_line} }} + binaries: + - {{ path: "/tmp/openshell-connect-test/*" }} +"# + ) +} + +const HANDLER_CEDAR: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"203.0.113.10:443") +when { context.binary_path like("/tmp/openshell-connect-test/*", "/") }; +"#; + +const HANDLER_BINARY: &str = "/tmp/openshell-connect-test/connect-bash"; + +#[test] +fn ported_tls_skip_route() { + // The originals drive a real CONNECT through the handler (Linux only) + // and read the TLS mode from the authorization; the port checks the + // authorization and the TLS mode the handler reads from it. + let input = || network("203.0.113.10", 443, HANDLER_BINARY); + let skip_yaml = handler_yaml(", tls: skip"); + assert_settings_parity(&SettingsScenario { + name: "proxy.rs tls: skip", + host: "203.0.113.10", + yaml: &skip_yaml, + cedar: HANDLER_CEDAR, + settings: "endpoint_settings:\n - { host: \"203.0.113.10\", port: 443, tls: skip }\n", + cases: vec![ + same_probe( + "proxy.rs::connect_handler_tls_skip_route_is_not_refused/allowed", + Probe::Connect(input()), + ) + .asserting_yaml("allow"), + same_probe( + "proxy.rs::connect_handler_tls_skip_route_is_not_refused/tls mode", + Probe::TlsMode(input()), + ) + .asserting_yaml("Skip"), + same_probe( + "proxy.rs::handler_test_policy_allows_glob_binary_and_reads_tls_mode/tls: skip", + Probe::TlsMode(input()), + ) + .asserting_yaml("Skip"), + same_probe( + "proxy.rs::connect_handler_tls_skip_route_is_not_refused/no inspection", + Probe::Inspection(input()), + ), + ], + }); + let auto_yaml = handler_yaml(""); + assert_settings_parity(&SettingsScenario { + name: "proxy.rs terminating endpoint", + host: "203.0.113.10", + yaml: &auto_yaml, + cedar: HANDLER_CEDAR, + settings: "", + cases: vec![ + same_probe( + "proxy.rs::handler_test_policy_allows_glob_binary_and_reads_tls_mode/terminating", + Probe::TlsMode(input()), + ) + .asserting_yaml("Auto"), + ], + }); +} + +// --------------------------------------------------------------------------- +// relay.rs: MCP revisions +// --------------------------------------------------------------------------- + +#[test] +fn ported_mcp_march_batches_authorize_every_member() { + let call = |id: u32, name: &str| { + serde_json::json!({ + "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": {"name": name, "arguments": {}} + }) + }; + let allowed = call(1, "read_status"); + let denied = call(2, "delete_resource"); + let malformed = serde_json::json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": 7, "arguments": {}} + }); + let batches = [ + ( + "allowed", + serde_json::json!([allowed, call(2, "read_status")]), + false, + false, + ), + ( + "deny last", + serde_json::json!([allowed, denied]), + true, + false, + ), + ( + "deny first", + serde_json::json!([denied, allowed]), + true, + false, + ), + ( + "malformed last", + serde_json::json!([allowed, malformed]), + false, + true, + ), + ]; + let versions = r#"["2025-03-26"]"#; + for enforcement in ["enforce", "audit"] { + let yaml = mcp_tool_yaml(enforcement, versions); + let cedar = mcp_tool_cedar(enforcement_annotation(enforcement)); + let settings = mcp_settings(versions); + let mut cases = Vec::new(); + for route_selected in [false, true] { + for (case, members, policy_denied, malformed) in &batches { + // Audit forwards policy denials, but never malformed MCP. + let forwarded = !*malformed && (!*policy_denied || enforcement == "audit"); + let expected = if *malformed { + "400/blocked/marker" + } else if forwarded { + "204/forwarded/no-marker" + } else { + "403/blocked/no-marker" + }; + let name = format!( + "relay.rs::mcp_march_batches_authorize_every_member_before_forwarding/{enforcement}/{case}/route_selected={route_selected}" + ); + cases.push( + same_probe( + name, + mcp_exchange( + route_selected, + mcp_request( + "POST", + "MCP-Protocol-Version: 2025-03-26\r\n", + &members.to_string(), + ), + NO_CONTENT, + Some("invalid_mcp_request"), + ), + ) + .asserting_yaml(expected), + ); + } + } + assert_settings_parity(&SettingsScenario { + name: &format!("relay.rs MCP 2025-03-26 batches {enforcement}"), + host: MCP_HOST, + yaml: &yaml, + cedar: &cedar, + settings: &settings, + cases, + }); + } +} + +#[test] +fn ported_mcp_tool_rewrites_obey_policy_with_matching_metadata() { + // The original rewrites the tool call with a middleware stage and checks + // that the relay judges the rewritten request. Middleware does not depend + // on the policy engine, so the port sends the rewritten request itself + // and checks the decision and the revision checks the relay applies to + // it. An enforced denial is a 403 here and a middleware failure in the + // original. + for (version, configured) in [ + ("2025-06-18", r#"["2025-06-18"]"#), + ("2025-11-25", r#"["2025-11-25"]"#), + ("2026-07-28", r#"["2026-07-28"]"#), + ("2025-11-25", r#"["2025-11-25", "2026-07-28"]"#), + ("2026-07-28", r#"["2025-11-25", "2026-07-28"]"#), + ] { + let sessionless = version == "2026-07-28"; + for enforcement in ["enforce", "audit"] { + let yaml = mcp_tool_yaml(enforcement, configured); + let cedar = mcp_tool_cedar(enforcement_annotation(enforcement)); + let settings = mcp_settings(configured); + let mut cases = Vec::new(); + for route_selected in [false, true] { + for tool in ["read_status", "delete_resource"] { + let params = + serde_json::json!({"name": tool, "arguments": {"rewritten": true}}); + let body = if sessionless { + sessionless_mcp_body("tools/call", params) + } else { + serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params + }) + .to_string() + }; + let mut headers = format!("MCP-Protocol-Version: {version}\r\n"); + if sessionless { + let _ = write!(headers, "Mcp-Method: tools/call\r\nMcp-Name: {tool}\r\n"); + } + let expected = if tool == "delete_resource" && enforcement == "enforce" { + "403/blocked" + } else { + "204/forwarded" + }; + let name = format!( + "relay.rs::mcp_middleware_tool_rewrites_obey_policy_with_matching_metadata/{version} of {configured}/{enforcement}/{tool}/route_selected={route_selected}" + ); + cases.push( + same_probe( + name, + mcp_exchange( + route_selected, + mcp_request("POST", &headers, &body), + NO_CONTENT, + None, + ), + ) + .asserting_yaml(expected), + ); + } + } + assert_settings_parity(&SettingsScenario { + name: &format!("relay.rs MCP rewrites {version} of {configured} {enforcement}"), + host: MCP_HOST, + yaml: &yaml, + cedar: &cedar, + settings: &settings, + cases, + }); + } + } +} + +/// The sessionless endpoint of `mcp_sessionless_test_relay_context`. +const SESSIONLESS_YAML: &str = r#" +network_policies: + mcp_api: + name: mcp_api + endpoints: + - host: mcp.example.test + port: 8000 + path: /mcp + protocol: mcp + enforcement: enforce + mcp: + versions: ["2026-07-28"] + allow_all_known_mcp_methods: true + rules: + - allow: {} + - allow: + method: vendor/inspect + deny_rules: + - method: tools/call + tool: blocked + binaries: + - { path: /usr/bin/python3 } +"#; + +/// The Cedar translation of [`SESSIONLESS_YAML`]. `allow: {}` with +/// `allow_all_known_mcp_methods` admits every known MCP method. +fn sessionless_cedar() -> String { + let binary = binary(PYTHON); + format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{MCP_HOST}:{MCP_PORT}") +when {{ {binary} }}; +@protocol("mcp") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{MCP_HOST}:{MCP_PORT}") +when {{ + {binary} + && context.path == "/mcp" + && !context.jsonrpc_response + && (context.mcp_method_class == "available" + || context.jsonrpc_method == "vendor/inspect") +}}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{MCP_HOST}:{MCP_PORT}") +when {{ + {binary} + && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response) +}}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{MCP_HOST}:{MCP_PORT}") +when {{ context.jsonrpc_method == "tools/call" && context.mcp_tool == "blocked" }}; +"# + ) +} + +#[test] +fn ported_mcp_sessionless_relays() { + let mut cases = Vec::new(); + for route_selected in [false, true] { + for (method, params, name, response_body) in [ + ( + "server/discover", + serde_json::json!({}), + None, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}", + ), + ( + "tools/call", + serde_json::json!({"name": "echo", "arguments": {}}), + Some("echo"), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}", + ), + ( + "vendor/inspect", + serde_json::json!({}), + None, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}", + ), + ( + "subscriptions/listen", + serde_json::json!({"notifications": {"toolsListChanged": true}}), + None, + "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n", + ), + ] { + let mut headers = + format!("MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: {method}\r\n"); + if let Some(name) = name { + let _ = write!(headers, "Mcp-Name: {name}\r\n"); + } + let content_type = if method == "subscriptions/listen" { + "text/event-stream" + } else { + "application/json" + }; + let upstream = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ); + let name = format!( + "relay.rs::mcp_sessionless_relays_discovery_tools_extensions_and_subscription_sse/{method}/route_selected={route_selected}" + ); + cases.push( + same_probe( + name, + mcp_exchange( + route_selected, + mcp_request("POST", &headers, &sessionless_mcp_body(method, params)), + &upstream, + Some(response_body), + ), + ) + .asserting_yaml("200/forwarded/marker"), + ); + } + for (method, params, header_method, name, expected) in [ + ( + "tools/list", + serde_json::json!({}), + "server/discover", + None, + "400/blocked", + ), + ( + "tools/call", + serde_json::json!({"name": "echo"}), + "tools/call", + None, + "400/blocked", + ), + ( + "tools/call", + serde_json::json!({"name": "blocked"}), + "tools/call", + Some("blocked"), + "403/blocked", + ), + ( + "vendor/unlisted", + serde_json::json!({}), + "vendor/unlisted", + None, + "403/blocked", + ), + ] { + let mut headers = + format!("MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: {header_method}\r\n"); + if let Some(name) = name { + let _ = write!(headers, "Mcp-Name: {name}\r\n"); + } + let name = format!( + "relay.rs::mcp_sessionless_relays_apply_metadata_and_method_policy/{method} as {header_method}/route_selected={route_selected}" + ); + cases.push( + same_probe( + name, + mcp_exchange( + route_selected, + mcp_request("POST", &headers, &sessionless_mcp_body(method, params)), + NO_CONTENT, + None, + ), + ) + .asserting_yaml(expected), + ); + } + } + let cedar = sessionless_cedar(); + assert_settings_parity(&SettingsScenario { + name: "relay.rs MCP sessionless", + host: MCP_HOST, + yaml: SESSIONLESS_YAML, + cedar: &cedar, + settings: &mcp_settings(r#"["2026-07-28"]"#), + cases, + }); +} + +#[test] +fn mcp_revision_outside_the_settings_is_rejected() { + // Control for the ports above: without the revision settings, a Cedar + // MCP endpoint keeps the pinned default, so a request at a revision the + // YAML endpoint allows is rejected by Cedar's relay alone. + let body = sessionless_mcp_body( + "tools/call", + serde_json::json!({"name": "read_status", "arguments": {}}), + ); + let headers = + "MCP-Protocol-Version: 2026-07-28\r\nMcp-Method: tools/call\r\nMcp-Name: read_status\r\n"; + let yaml = mcp_tool_yaml("enforce", r#"["2026-07-28"]"#); + let cedar = mcp_tool_cedar(""); + assert_settings_parity(&SettingsScenario { + name: "MCP revision without settings", + host: MCP_HOST, + yaml: &yaml, + cedar: &cedar, + settings: "", + cases: vec![diverges_probe( + "2026-07-28 request without mcp.versions settings", + mcp_exchange(false, mcp_request("POST", headers, &body), NO_CONTENT, None), + "the Cedar endpoint has no endpoint settings, so it allows only the default MCP revision", + ) + .asserting_yaml("204/forwarded")], + }); +} + +// --------------------------------------------------------------------------- +// relay.rs: per-path encoded slashes +// --------------------------------------------------------------------------- + +/// `ENCODED_SLASH_SCOPING_POLICY`: GET on `/repos/**` and `/admin/**` of one +/// `host:port`, with only `/repos/**` opting into encoded slashes. +const ENCODED_SLASH_YAML: &str = r#" +network_policies: + route_api: + name: route_api + endpoints: + - host: gateway.example.test + port: 443 + path: /repos/** + protocol: rest + enforcement: enforce + allow_encoded_slash: true + rules: + - allow: + method: GET + path: "/repos/**" + - host: gateway.example.test + port: 443 + path: /admin/** + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/admin/**" + binaries: + - { path: /usr/bin/node } +"#; + +fn encoded_slash_cedar() -> String { + let binary = binary(NODE); + format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"gateway.example.test:443") +when {{ {binary} }}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"gateway.example.test:443") +when {{ + {binary} + && ["GET", "HEAD"].contains(context.method) + && (context.path like("/repos/**", "/") || context.path like("/admin/**", "/")) +}}; +"# + ) +} + +const ENCODED_SLASH_SETTINGS: &str = r" +endpoint_settings: + - host: gateway.example.test + port: 443 + path: /repos/** + allow_encoded_slash: true +"; + +/// A GET of `target` on the route-selection endpoint, in `ctx`. +fn encoded_slash_exchange(ctx: L7EvalContext, target: &str) -> Probe { + Probe::Relay(Box::new(RelayExchange { + ctx, + route_selected: true, + request: format!( + "GET {target} HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n" + ), + upstream_response: NO_CONTENT.to_string(), + marker: Some("not allowed on this endpoint"), + })) +} + +#[test] +fn ported_route_selected_encoded_slash_scoping() { + let ctx = l7_ctx("gateway.example.test", 443, NODE); + let (child_env, resolver) = openshell_core::secrets::SecretResolver::from_provider_env( + std::iter::once(("TOKEN".to_string(), "real-token".to_string())).collect(), + ); + let placeholder = child_env.get("TOKEN").expect("placeholder env").clone(); + let resolver_ctx = L7EvalContext { + secret_resolver: resolver.map(Arc::new), + ..ctx.clone() + }; + let cedar = encoded_slash_cedar(); + assert_settings_parity(&SettingsScenario { + name: "relay.rs encoded slash scoping", + host: "gateway.example.test", + yaml: ENCODED_SLASH_YAML, + cedar: &cedar, + settings: ENCODED_SLASH_SETTINGS, + cases: vec![ + same_probe( + "relay.rs::route_selected_encoded_slash_optin_does_not_leak_to_other_endpoints", + encoded_slash_exchange(ctx.clone(), "/admin/x%2Fy"), + ) + .asserting_yaml("403/blocked/marker"), + same_probe( + "relay.rs::route_selected_encoded_slash_check_survives_credential_redaction", + encoded_slash_exchange(resolver_ctx, &format!("/admin/{placeholder}%2Fx")), + ) + .asserting_yaml("403/blocked/marker"), + same_probe( + "relay.rs::route_selected_encoded_slash_still_allowed_on_opted_in_endpoint", + encoded_slash_exchange(ctx.clone(), "/repos/group%2Fproject"), + ) + .asserting_yaml("204/forwarded/no-marker"), + // Positive control without an encoded slash on the endpoint that + // did not opt in. + same_probe( + "control: unencoded admin path", + encoded_slash_exchange(ctx, "/admin/x"), + ) + .asserting_yaml("204/forwarded/no-marker"), + ], + }); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase3.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase3.rs new file mode 100644 index 0000000000..66700bbbdd --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase3.rs @@ -0,0 +1,1805 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML tests for per-path protocols and WebSocket inspection (Cedar +//! parity plan, phase 3). +//! +//! YAML endpoints that share a `host:port` with different `path`s become +//! Cedar `HttpRequest` policies with `@path`, which gives each path its own +//! inspection. `@path` selects inspection only, so each translation also +//! spells out the YAML endpoint path as a `context.path` condition, as the +//! other ports do. Every policy of a translated endpoint carries the +//! endpoint's `@path`, so a translated `forbid` does not add a path-less +//! inspection the YAML policy lacks. +//! +//! A YAML `protocol: websocket` endpoint whose rules name GraphQL operations +//! becomes `@protocol("websocket-graphql")`; one without becomes +//! `@protocol("websocket")`. WebSocket messages are probed through the relay +//! with [`Probe::WebSocket`] where the original sends frames, and as +//! `WEBSOCKET_TEXT` requests where it evaluates the message directly. + +use super::ported_opa_a::{L7_TEST_CEDAR, L7_TEST_DATA}; +use super::ported_phase1::{self as phase1, native_request, native_scenario}; +use super::ported_relay::mcp_body; +use super::*; + +use crate::l7::jsonrpc::McpMethodClassification::Available; + +const NODE: &str = "/usr/bin/node"; +const PYTHON: &str = "/usr/bin/python3"; + +/// The Cedar condition for a YAML binary entry with an exact path. +fn binary(path: &str) -> String { + phase1::binary(path) +} + +/// The Cedar condition for a YAML REST rule allowing `GET` (and so `HEAD`). +const GET: &str = phase1::GET; + +/// The Cedar condition for a YAML GraphQL rule listing `fields`. +fn fields_within(fields: &[&str]) -> String { + let list = fields + .iter() + .map(|field| format!("\"{field}\"")) + .collect::>() + .join(", "); + format!( + "(context.graphql_fields.containsAny([{list}]) && [{list}].containsAll(context.graphql_fields))" + ) +} + +/// A `NetworkConnect` permit for `host:port` under `condition`. +fn connect(host: &str, port: u16, condition: &str) -> String { + format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{host}:{port}") +when {{ {condition} }}; +"# + ) +} + +/// An `HttpRequest` policy on `host:port`, with `annotations` before it. +fn http(host: &str, port: u16, annotations: &str, effect: &str, condition: &str) -> String { + format!( + r#" +{annotations} +{effect} (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:{port}") +when {{ {condition} }}; +"# + ) +} + +/// A client text message on the WebSocket opened at `target`, as the relay +/// evaluates it. +fn ws_text(target: &str) -> L7RequestInfo { + rest("WEBSOCKET_TEXT", target) +} + +/// A GraphQL-over-WebSocket operation message on the WebSocket opened at +/// `target`, as the relay classifies and evaluates it. +fn ws_graphql(target: &str, operations: Vec) -> L7RequestInfo { + L7RequestInfo { + graphql: Some(GraphqlRequestInfo { + operations, + error: None, + }), + ..ws_text(target) + } +} + +/// A GraphQL-over-WebSocket `subscribe` message carrying `query`. +fn subscribe(query: &str) -> String { + serde_json::json!({"id": "1", "type": "subscribe", "payload": {"query": query}}).to_string() +} + +/// A WebSocket upgrade request for `target` on `host:port`. +fn upgrade_request(host: &str, port: u16, target: &str) -> String { + format!( + "GET {target} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n" + ) +} + +/// A WebSocket session at `target` that sends `message` after the upgrade. +fn websocket(ctx: L7EvalContext, target: &str, message: &str) -> Probe { + Probe::WebSocket(Box::new(WebSocketExchange { + upgrade: upgrade_request(&ctx.host, ctx.port, target), + ctx, + route_selected: false, + message: message.to_string(), + })) +} + +/// One HTTP/1 exchange, answered by the upstream with no content. +fn exchange(ctx: L7EvalContext, request: String, marker: Option<&'static str>) -> Probe { + Probe::Relay(Box::new(RelayExchange { + ctx, + route_selected: false, + request, + upstream_response: NO_CONTENT.to_string(), + marker, + })) +} + +/// The marker of the relay's refusal of an HTTP upgrade on a JSON-RPC-family +/// or GraphQL endpoint. +const UPGRADE_REFUSAL: &str = "unsupported_l7_protocol"; + +// --------------------------------------------------------------------------- +// opa.rs: `graphql_ws` in `L7_TEST_DATA` +// --------------------------------------------------------------------------- + +/// The Cedar translation of `graphql_ws` in [`L7_TEST_DATA`], which +/// `L7_TEST_CEDAR` leaves out. +const GRAPHQL_WS_CEDAR: &str = r#" +// graphql_ws +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"realtime.graphql.com:443") +when { context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl") }; +@path("/graphql") +@protocol("websocket-graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"realtime.graphql.com:443") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && context.path == "/graphql" + && (["GET", "HEAD"].contains(context.method) + || (context.graphql_operation_type == "query" + && context.graphql_fields.containsAny(["viewer"]) + && ["viewer"].containsAll(context.graphql_fields)) + || (context.graphql_operation_type == "subscription" + && context.graphql_fields.containsAny(["messageAdded"]) + && ["messageAdded"].containsAll(context.graphql_fields))) +}; +@path("/graphql") +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"realtime.graphql.com:443") +when { + (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + && context.path == "/graphql" + && context.graphql_operation_type == "mutation" +}; +"#; + +#[test] +fn ported_l7_test_data_graphql_ws() { + let ctx = l7_ctx("realtime.graphql.com", 443, BINARY); + let message = |operation_type, name, fields| { + request_in( + ctx.clone(), + ws_graphql("/graphql", vec![operation(operation_type, name, fields)]), + ) + }; + assert_parity(&Scenario { + name: "opa.rs L7_TEST_DATA graphql_ws", + host: "realtime.graphql.com", + yaml: L7_TEST_DATA, + cedar: &format!("{L7_TEST_CEDAR}{GRAPHQL_WS_CEDAR}"), + cases: vec![ + same_probe( + "opa.rs::l7_websocket_graphql_subscription_allowed_by_field_rule", + message("subscription", Some("NewMessages"), &["messageAdded"]), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_websocket_graphql_unlisted_field_denied", + message("query", None, &["adminAuditLog"]), + ) + .asserting_yaml("deny"), + same_probe( + "opa.rs::l7_websocket_graphql_deny_rule_takes_precedence", + message("mutation", Some("DeleteRepo"), &["deleteRepository"]), + ) + .asserting_yaml("deny"), + same_probe( + "graphql_ws/upgrade", + request_in(ctx.clone(), rest("GET", "/graphql")), + ) + .asserting_yaml("allow"), + same_probe( + "graphql_ws/inspection", + Probe::Inspection(network("realtime.graphql.com", 443, BINARY)), + ) + .asserting_yaml("Websocket/Enforce"), + same_probe( + "graphql_ws/relay forwards an allowed subscription", + websocket( + ctx.clone(), + "/graphql", + &subscribe("subscription { messageAdded }"), + ), + ) + .asserting_yaml("101/message-forwarded"), + same_probe( + "graphql_ws/relay blocks a denied mutation", + websocket( + ctx.clone(), + "/graphql", + &subscribe("mutation { deleteRepository }"), + ), + ) + .asserting_yaml("101/message-blocked"), + same_probe( + "graphql_ws/relay forwards a control message", + websocket(ctx.clone(), "/graphql", r#"{"type":"connection_init"}"#), + ) + .asserting_yaml("101/message-forwarded"), + same_probe( + "graphql_ws/relay blocks a message that is not GraphQL over WebSocket", + websocket(ctx, "/graphql", "not json"), + ) + .asserting_yaml("101/message-blocked"), + ], + }); +} + +/// `l7_websocket_graphql_not_bypassed_by_generic_text_rule` loads its policy +/// without validation, since YAML validation rejects a `WEBSOCKET_TEXT` rule +/// on a WebSocket endpoint with GraphQL operation rules. The port does the +/// same on the YAML side. Cedar rejects the literal translation, a generic +/// `WEBSOCKET_TEXT` permit on a `websocket-graphql` path, at load; the Cedar +/// policy compared conditions its message permit on the GraphQL operation, +/// which is what the YAML endpoint enforces. +#[test] +fn ported_websocket_graphql_generic_text_rule() { + const YAML: &str = r#" +network_policies: + graphql_ws: + name: graphql_ws + endpoints: + - host: realtime.graphql.com + ports: [443] + path: "/graphql" + protocol: websocket + enforcement: enforce + rules: + - allow: + method: GET + path: "/graphql" + - allow: + method: WEBSOCKET_TEXT + path: "/graphql" + - allow: + operation_type: query + fields: [viewer] + binaries: + - { path: /usr/bin/curl } +"#; + let curl = binary(BINARY); + let host = "realtime.graphql.com"; + let cedar = |message_permit: &str| { + let mut cedar = connect(host, 443, &curl); + cedar.push_str(&http( + host, + 443, + r#"@path("/graphql") @protocol("websocket-graphql")"#, + "permit", + &format!( + r#"{curl} && context.path == "/graphql" + && ({GET} || {message_permit})"# + ), + )); + cedar + }; + let viewer = fields_within(&["viewer"]); + assert!( + OpaEngine::from_strings(REGO, YAML).is_err(), + "YAML validation rejects the generic WEBSOCKET_TEXT rule" + ); + // The literal translation of the generic rule: a message permit with no + // GraphQL condition, beside the operation rule's permit. + let generic = format!( + "{}{}", + cedar(r#"context.method == "WEBSOCKET_TEXT""#), + http( + host, + 443, + r#"@path("/graphql") @protocol("websocket-graphql")"#, + "permit", + &format!( + r#"{curl} && context.path == "/graphql" + && context.graphql_operation_type == "query" && {viewer}"# + ), + ) + ); + assert_cedar_rejects( + "opa.rs::l7_websocket_graphql_not_bypassed_by_generic_text_rule", + &generic, + "could permit a client message without a GraphQL condition", + ); + let engines = Engines::new( + OpaEngine::from_strings_unvalidated(REGO, YAML).expect("YAML data loads"), + CedarOnlyEngine::from_policy_str(&cedar(&format!( + r#"(context.method == "WEBSOCKET_TEXT" + && context.graphql_operation_type == "query" && {viewer})"# + ))) + .expect("Cedar policy loads"), + ); + let ctx = l7_ctx(host, 443, BINARY); + let message = |fields: &[&str]| { + request_in( + ctx.clone(), + ws_graphql("/graphql", vec![operation("query", None, fields)]), + ) + }; + report_parity(run( + "GraphQL WebSocket generic text rule", + host, + &engines, + &[ + // Rego denies an operation the endpoint's operation rules do not + // allow even when the generic WEBSOCKET_TEXT rule matches; the + // GraphQL-conditioned Cedar permit denies it too. + same_probe( + "opa.rs::l7_websocket_graphql_not_bypassed_by_generic_text_rule", + message(&["adminAuditLog"]), + ) + .asserting_yaml("deny"), + // Positive control: an operation the operation rules allow. + same_probe("generic text rule/allowed operation", message(&["viewer"])) + .asserting_yaml("allow"), + ], + )); +} + +// --------------------------------------------------------------------------- +// opa.rs: endpoints sharing a host:port +// --------------------------------------------------------------------------- + +#[test] +fn ported_l7_endpoint_path_scopes_rest_and_graphql() { + const NAME: &str = "opa.rs::l7_endpoint_path_scopes_rest_and_graphql_on_same_host"; + let host = "api.github.test"; + let curl = binary(BINARY); + let mut cedar = connect(host, 443, &curl); + // The REST rule `method: "*"`, `path: "/**"` on endpoint path `/repos/**`. + cedar.push_str(&http( + host, + 443, + r#"@path("/repos/**")"#, + "permit", + &format!( + r#"{curl} && (context.path == "/repos" || context.path like("/repos/**", "/")) + && context.path like("/**", "/")"# + ), + )); + cedar.push_str(&http( + host, + 443, + r#"@path("/graphql") @protocol("graphql")"#, + "permit", + &format!( + r#"{curl} && context.path == "/graphql" && context.graphql_operation_type == "query""# + ), + )); + assert_parity(&Scenario { + name: "REST and GraphQL paths", + host, + yaml: r#" +network_policies: + mixed_api: + name: mixed_api + endpoints: + - host: api.github.test + port: 443 + path: "/repos/**" + protocol: rest + enforcement: enforce + rules: + - allow: + method: "*" + path: "/**" + - host: api.github.test + port: 443 + path: "/graphql" + protocol: graphql + enforcement: enforce + rules: + - allow: + operation_type: query + binaries: + - { path: /usr/bin/curl } +"#, + cedar: &cedar, + cases: vec![ + same(NAME, rest("POST", "/repos/org/repo/issues")).asserting_yaml("allow"), + same(NAME, graphql(vec![operation("query", None, &["viewer"])])) + .asserting_yaml("allow"), + same( + NAME, + graphql(vec![operation("mutation", None, &["deleteRepository"])]), + ) + .asserting_yaml("deny"), + same_probe( + "REST and GraphQL paths/inspection", + Probe::Inspection(network(host, 443, BINARY)), + ) + .asserting_yaml("Graphql/Enforce,Rest/Enforce"), + same( + "REST and GraphQL paths/no endpoint path matches", + rest("GET", "/orgs/nvidia"), + ) + .asserting_yaml("deny"), + ], + }); +} + +#[test] +fn ported_l7_mcp_denial_paths_and_protocols() { + const NAME: &str = "opa.rs::l7_mcp_denial_reason_matches_request_path_and_protocol"; + const HOST: &str = "mcp.reasons.test"; + const PORT: u16 = 8000; + let curl = binary(BINARY); + let mut cedar = connect(HOST, PORT, &curl); + cedar.push_str(&http( + HOST, + PORT, + r#"@path("/rpc") @protocol("json-rpc")"#, + "permit", + &format!( + r#"{curl} && context.path == "/rpc" && !context.jsonrpc_response + && context.jsonrpc_method == "reports.list""# + ), + )); + cedar.push_str(&http( + HOST, + PORT, + r#"@path("/rest")"#, + "permit", + &format!(r#"{curl} && context.path == "/rest" && {GET}"#), + )); + // `allow_all_known_mcp_methods` allows every core method, except that a + // `tool` rule narrows `tools/call` to its tools. + cedar.push_str(&http( + HOST, + PORT, + r#"@path("/mcp") @protocol("mcp")"#, + "permit", + &format!( + r#"{curl} && context.path == "/mcp" && !context.jsonrpc_response + && context.mcp_method_class == "available" + && (context.jsonrpc_method != "tools/call" || context.mcp_tool == "read_status")"# + ), + )); + // A YAML MCP endpoint also allows its receive stream and client response + // frames on the endpoint path. + cedar.push_str(&http( + HOST, + PORT, + r#"@path("/mcp")"#, + "permit", + &format!( + r#"{curl} && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response)"# + ), + )); + let ctx = l7_ctx(HOST, PORT, BINARY); + let call_at = |path: &str, method: &str, tool: Option<&str>| { + request_in( + ctx.clone(), + jsonrpc_request(path, vec![call(method, tool, Some(Available))]), + ) + }; + let mcp_info = |info: JsonRpcRequestInfo| { + request_in( + ctx.clone(), + L7RequestInfo { + jsonrpc: Some(info), + ..rest("POST", "/mcp") + }, + ) + }; + let response_frame = |path: &str| { + request_in( + ctx.clone(), + L7RequestInfo { + jsonrpc: Some(JsonRpcRequestInfo { + has_response: true, + ..jsonrpc_info(Vec::new()) + }), + ..rest("POST", path) + }, + ) + }; + let mut cases = vec![ + same_probe( + format!("{NAME}/unmatched tool"), + call_at("/mcp", "tools/call", Some("other_tool")), + ) + .asserting_yaml("deny"), + ]; + for path in ["/rest", "/rpc", "/other"] { + cases.push( + same_probe( + format!("{NAME}/tools/call on {path}"), + call_at(path, "tools/call", None), + ) + .asserting_yaml("deny"), + ); + } + cases.extend([ + same_probe( + format!("{NAME}/no selected call"), + mcp_info(jsonrpc_info(Vec::new())), + ) + .asserting_yaml("deny"), + same_probe( + format!("{NAME}/inspection error"), + request_in(ctx.clone(), mcp_body("POST", "/mcp", "{not json")), + ) + .asserting_yaml("deny"), + same_probe( + format!("{NAME}/unavailable method"), + request_in( + ctx.clone(), + jsonrpc_request("/mcp", vec![call("tools/call", Some("other_tool"), None)]), + ), + ) + .asserting_yaml("deny"), + same_probe( + format!("{NAME}/JSON-RPC response frame"), + response_frame("/rpc"), + ) + .asserting_yaml("deny"), + same_probe(format!("{NAME}/MCP response frame"), response_frame("/mcp")) + .asserting_yaml("allow"), + same_probe( + format!("{NAME}/MCP tools/list"), + call_at("/mcp", "tools/list", None), + ) + .asserting_yaml("allow"), + // Positive controls for each path's own protocol. + same_probe( + "MCP, JSON-RPC, and REST paths/allowed tool", + call_at("/mcp", "tools/call", Some("read_status")), + ) + .asserting_yaml("allow"), + same_probe( + "MCP, JSON-RPC, and REST paths/allowed JSON-RPC method", + request_in( + ctx.clone(), + jsonrpc_request("/rpc", vec![call("reports.list", None, None)]), + ), + ) + .asserting_yaml("allow"), + same_probe( + "MCP, JSON-RPC, and REST paths/allowed REST request", + request_in(ctx, rest("GET", "/rest")), + ) + .asserting_yaml("allow"), + same_probe( + "MCP, JSON-RPC, and REST paths/inspection", + Probe::Inspection(network(HOST, PORT, BINARY)), + ) + .asserting_yaml("JsonRpc/Enforce,Mcp/Enforce,Rest/Enforce"), + ]); + assert_parity(&Scenario { + name: "MCP, JSON-RPC, and REST paths", + host: HOST, + yaml: r" +network_policies: + mixed: + name: mixed + endpoints: + - host: mcp.reasons.test + port: 8000 + path: /rpc + protocol: json-rpc + enforcement: enforce + rules: [{allow: {method: reports.list}}] + - host: mcp.reasons.test + port: 8000 + path: /rest + protocol: rest + enforcement: enforce + rules: [{allow: {method: GET, path: /rest}}] + - host: mcp.reasons.test + port: 8000 + path: /mcp + protocol: mcp + enforcement: enforce + mcp: + allow_all_known_mcp_methods: true + rules: [{allow: {tool: read_status}}] + binaries: + - {path: /usr/bin/curl} +", + cedar: &cedar, + cases, + }); +} + +#[test] +fn ported_yaml_and_proto_protocol_parity() { + const NAME: &str = "opa.rs::yaml_and_proto_loads_have_protocol_config_and_authorization_parity"; + let curl = binary(BINARY); + let mut cedar = String::new(); + for host in [ + "rest.parity.test", + "graphql.parity.test", + "websocket.parity.test", + "jsonrpc.parity.test", + "mcp.parity.test", + ] { + cedar.push_str(&connect(host, 443, &curl)); + } + cedar.push_str(&http( + "rest.parity.test", + 443, + r#"@path("/items/**")"#, + "permit", + &format!(r#"{curl} && {GET} && context.path like("/items/**", "/")"#), + )); + cedar.push_str(&http( + "graphql.parity.test", + 443, + r#"@path("/graphql") @protocol("graphql")"#, + "permit", + &format!( + r#"{curl} && context.path == "/graphql" && context.graphql_operation_type == "query" + && context.graphql_operation_name == "GetWidget" && {fields}"#, + fields = fields_within(&["id", "name"]), + ), + )); + cedar.push_str(&http( + "websocket.parity.test", + 443, + r#"@path("/graphql") @protocol("websocket-graphql")"#, + "permit", + &format!( + r#"{curl} && context.path == "/graphql" + && ({GET} || (context.graphql_operation_type == "subscription" && {fields}))"#, + fields = fields_within(&["messageAdded"]), + ), + )); + cedar.push_str(&http( + "jsonrpc.parity.test", + 443, + r#"@path("/rpc") @protocol("json-rpc")"#, + "permit", + &format!( + r#"{curl} && context.path == "/rpc" && !context.jsonrpc_response + && context.jsonrpc_method == "status.get""# + ), + )); + cedar.push_str(&http( + "mcp.parity.test", + 443, + r#"@path("/mcp") @protocol("mcp")"#, + "permit", + &format!( + r#"{curl} && context.path == "/mcp" && !context.jsonrpc_response + && context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" && context.mcp_tool == "read_status""# + ), + )); + cedar.push_str(&http( + "mcp.parity.test", + 443, + r#"@path("/mcp")"#, + "permit", + &format!( + r#"{curl} && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response)"# + ), + )); + let at = |host: &str, request: L7RequestInfo| request_in(l7_ctx(host, 443, BINARY), request); + let graphql_at = |operation_type, name| L7RequestInfo { + graphql: Some(GraphqlRequestInfo { + operations: vec![operation(operation_type, Some(name), &["id"])], + error: None, + }), + ..rest("POST", "/graphql") + }; + let websocket_message = + |fields: &[&str]| ws_graphql("/graphql", vec![operation("subscription", None, fields)]); + let cases = vec![ + ( + "REST", + at("rest.parity.test", rest("GET", "/items/one")), + at("rest.parity.test", rest("DELETE", "/items/one")), + ), + ( + "GraphQL", + at("graphql.parity.test", graphql_at("query", "GetWidget")), + at( + "graphql.parity.test", + graphql_at("mutation", "DeleteWidget"), + ), + ), + ( + "WebSocket", + at( + "websocket.parity.test", + websocket_message(&["messageAdded"]), + ), + at( + "websocket.parity.test", + websocket_message(&["adminAuditLog"]), + ), + ), + ( + "JSON-RPC", + at( + "jsonrpc.parity.test", + jsonrpc_request("/rpc", vec![call("status.get", None, None)]), + ), + at( + "jsonrpc.parity.test", + jsonrpc_request("/rpc", vec![call("status.delete", None, None)]), + ), + ), + ( + "MCP", + at( + "mcp.parity.test", + jsonrpc_request( + "/mcp", + vec![call("tools/call", Some("read_status"), Some(Available))], + ), + ), + at( + "mcp.parity.test", + jsonrpc_request( + "/mcp", + vec![call("tools/call", Some("delete_status"), Some(Available))], + ), + ), + ), + ] + .into_iter() + .flat_map(|(protocol, allowed, denied)| { + [ + same_probe(format!("{NAME}/{protocol} allowed"), allowed).asserting_yaml("allow"), + same_probe(format!("{NAME}/{protocol} denied"), denied).asserting_yaml("deny"), + ] + }) + .chain([same_probe( + format!("{NAME}/WebSocket inspection"), + Probe::Inspection(network("websocket.parity.test", 443, BINARY)), + ) + .asserting_yaml("Websocket/Enforce")]) + .collect(); + assert_parity(&Scenario { + name: "YAML and proto protocol parity", + host: "rest.parity.test", + yaml: r" +version: 1 +network_policies: + parity: + name: parity + endpoints: + - host: rest.parity.test + port: 443 + path: /items/** + protocol: rest + enforcement: enforce + allow_encoded_slash: true + rules: + - allow: { method: GET, path: /items/** } + - host: graphql.parity.test + port: 443 + path: /graphql + protocol: graphql + enforcement: enforce + graphql_max_body_bytes: 65536 + rules: + - allow: + operation_type: query + operation_name: GetWidget + fields: [id, name] + - host: websocket.parity.test + port: 443 + path: /graphql + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: /graphql } + - allow: + operation_type: subscription + fields: [messageAdded] + - host: jsonrpc.parity.test + port: 443 + path: /rpc + protocol: json-rpc + enforcement: enforce + json_rpc: { max_body_bytes: 32768 } + rules: + - allow: { method: status.get } + - host: mcp.parity.test + port: 443 + path: /mcp + protocol: mcp + enforcement: enforce + mcp: + max_body_bytes: 16384 + strict_tool_names: false + rules: + - allow: + method: tools/call + tool: read_status + binaries: + - { path: /usr/bin/curl } +", + cedar: &cedar, + cases, + }); +} + +// --------------------------------------------------------------------------- +// proxy.rs: route selection and forward-proxy WebSocket policy +// --------------------------------------------------------------------------- + +/// The original builds the two configs by hand and checks only which one +/// the relay selects. The port declares the same endpoints, with a GET rule +/// on the REST endpoint and a query rule on the GraphQL one, and checks the +/// selection through the relay: a GraphQL mutation sent with POST is parsed +/// and denied as GraphQL. +#[test] +fn ported_l7_route_selection_prefers_path_specific_graphql() { + const NAME: &str = "proxy.rs::l7_route_selection_prefers_path_specific_graphql_endpoint"; + let host = "api.example.com"; + let curl = binary(BINARY); + let mut cedar = connect(host, 443, &curl); + cedar.push_str(&http( + host, + 443, + r#"@path("/**")"#, + "permit", + &format!(r#"{curl} && {GET} && context.path like("/**", "/")"#), + )); + cedar.push_str(&http( + host, + 443, + r#"@path("/graphql") @protocol("graphql")"#, + "permit", + &format!( + r#"{curl} && context.path == "/graphql" && context.graphql_operation_type == "query""# + ), + )); + let ctx = l7_ctx(host, 443, BINARY); + let body = r#"{"query":"mutation { deleteRepository }"}"#; + assert_parity(&Scenario { + name: "path-specific GraphQL route", + host, + yaml: r#" +network_policies: + mixed: + name: mixed + endpoints: + - host: api.example.com + port: 443 + path: "/**" + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: api.example.com + port: 443 + path: "/graphql" + protocol: graphql + enforcement: enforce + rules: + - allow: { operation_type: query } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: &cedar, + cases: vec![ + same_probe(NAME, Probe::Inspection(network(host, 443, BINARY))) + .asserting_yaml("Graphql/Enforce,Rest/Enforce"), + same_probe( + NAME, + exchange( + ctx.clone(), + format!( + "POST /graphql HTTP/1.1\r\nHost: {host}:443\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ), + None, + ), + ) + .asserting_yaml("403/blocked"), + same_probe( + NAME, + exchange( + ctx, + format!( + "GET /repos/org/repo HTTP/1.1\r\nHost: {host}:443\r\nConnection: close\r\n\r\n" + ), + None, + ), + ) + .asserting_yaml("204/forwarded"), + ], + }); +} + +/// A GraphQL request on a path a broader REST endpoint also matches. +/// +/// Not a port: YAML makes a GraphQL endpoint authoritative for the requests +/// it parses, so a REST rule of a broader endpoint never allows them. Cedar +/// scopes a REST permit beside a GraphQL path to the requests routed as REST, +/// so the literal translation of the REST rule decides as YAML does, and so +/// does the translation that conditions it on `resource.protocol == "rest"` +/// explicitly. +#[test] +fn graphql_path_authority_over_a_broader_rest_path() { + let host = "api.example.com"; + let curl = binary(BINARY); + let yaml = r#" +network_policies: + mixed: + name: mixed + endpoints: + - host: api.example.com + port: 443 + path: "/**" + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: api.example.com + port: 443 + path: "/graphql" + protocol: graphql + enforcement: enforce + rules: + - allow: { operation_type: query } + binaries: + - { path: /usr/bin/curl } +"#; + let cedar = |rest_scope: &str| { + let mut cedar = connect(host, 443, &curl); + cedar.push_str(&http( + host, + 443, + r#"@path("/**")"#, + "permit", + &format!(r#"{curl} && {GET} && context.path like("/**", "/"){rest_scope}"#), + )); + cedar.push_str(&http( + host, + 443, + r#"@path("/graphql") @protocol("graphql")"#, + "permit", + &format!( + r#"{curl} && context.path == "/graphql" && context.graphql_operation_type == "query""# + ), + )); + cedar + }; + let mutation = || { + exchange( + l7_ctx(host, 443, BINARY), + format!( + "GET /graphql?query=mutation%7BdeleteRepository%7D HTTP/1.1\r\nHost: {host}:443\r\nConnection: close\r\n\r\n" + ), + None, + ) + }; + let query = || { + exchange( + l7_ctx(host, 443, BINARY), + format!( + "GET /graphql?query=%7Bviewer%7D HTTP/1.1\r\nHost: {host}:443\r\nConnection: close\r\n\r\n" + ), + None, + ) + }; + assert_parity(&Scenario { + name: "GraphQL authority, literal REST translation", + host, + yaml, + cedar: &cedar(""), + cases: vec![ + same_probe("GET GraphQL mutation", mutation()).asserting_yaml("403/blocked"), + same_probe("GET GraphQL query", query()).asserting_yaml("204/forwarded"), + ], + }); + assert_parity(&Scenario { + name: "GraphQL authority, REST translation scoped by resource.protocol", + host, + yaml, + cedar: &cedar(r#" && resource.protocol == "rest""#), + cases: vec![ + same_probe("GET GraphQL mutation", mutation()).asserting_yaml("403/blocked"), + same_probe("GET GraphQL query", query()).asserting_yaml("204/forwarded"), + ], + }); +} + +/// An audit endpoint whose path also matches a more specific enforced one. +/// +/// Not a port. YAML applies the rules of every endpoint whose path matches a +/// request, and the selected endpoint's enforcement decides whether a denial +/// blocks: an audit endpoint's deny rule blocks requests routed to an +/// overlapping enforced endpoint, and its allow rules allow them. Cedar +/// `@enforcement("audit")` policies take part in the decision the same way: +/// the audit path's policies decide requests routed to the enforced path, +/// and only requests routed to the audit path are logged and forwarded. +/// Writing the rules again as enforced policies scoped to the enforced path +/// is the same policy stated twice. +#[test] +fn audit_endpoint_rules_on_an_overlapping_enforced_path() { + let host = "api.example.com"; + let curl = binary(BINARY); + let mut cedar = connect(host, 443, &curl); + cedar.push_str(&http( + host, + 443, + r#"@path("/**") @enforcement("audit")"#, + "permit", + &format!(r#"{curl} && {GET} && context.path like("/**", "/")"#), + )); + cedar.push_str(&http( + host, + 443, + r#"@path("/**") @enforcement("audit")"#, + "forbid", + &format!(r#"{curl} && context.method == "DELETE""#), + )); + cedar.push_str(&http( + host, + 443, + r#"@path("/api/**")"#, + "permit", + &format!( + r#"{curl} && ["DELETE", "POST"].contains(context.method) + && (context.path == "/api" || context.path like("/api/**", "/"))"# + ), + )); + let ctx = l7_ctx(host, 443, BINARY); + let send = |method: &str, path: &str| { + exchange( + ctx.clone(), + format!("{method} {path} HTTP/1.1\r\nHost: {host}:443\r\nConnection: close\r\n\r\n"), + None, + ) + }; + assert_parity(&Scenario { + name: "audit endpoint beside an enforced path", + host, + yaml: AUDIT_OVERLAP_YAML, + cedar: &cedar, + cases: vec![ + same_probe( + "audit deny rule on the enforced path", + send("DELETE", "/api/item"), + ) + .asserting_yaml("403/blocked"), + same_probe( + "audit allow rule on the enforced path", + send("GET", "/api/item"), + ) + .asserting_yaml("204/forwarded"), + same_probe("allowed on the enforced path", send("POST", "/api/item")) + .asserting_yaml("204/forwarded"), + same_probe("audited denial on the audit path", send("DELETE", "/other")) + .asserting_yaml("204/forwarded"), + ], + }); + + // The same rules restated: the audit endpoint's rules that YAML enforces + // on the overlapping path, written again as enforced policies scoped to + // that path, change no decision. + let mut faithful = cedar.clone(); + faithful.push_str(&http( + host, + 443, + r#"@path("/api/**")"#, + "forbid", + &format!( + r#"{curl} && context.method == "DELETE" + && (context.path == "/api" || context.path like("/api/**", "/"))"# + ), + )); + faithful.push_str(&http( + host, + 443, + r#"@path("/api/**")"#, + "permit", + &format!( + r#"{curl} && {GET} + && (context.path == "/api" || context.path like("/api/**", "/"))"# + ), + )); + assert_parity(&Scenario { + name: "audit endpoint beside an enforced path, rules split by enforcement", + host, + yaml: AUDIT_OVERLAP_YAML, + cedar: &faithful, + cases: vec![ + same_probe( + "audit deny rule on the enforced path", + send("DELETE", "/api/item"), + ) + .asserting_yaml("403/blocked"), + same_probe( + "audit allow rule on the enforced path", + send("GET", "/api/item"), + ) + .asserting_yaml("204/forwarded"), + same_probe("allowed on the enforced path", send("POST", "/api/item")) + .asserting_yaml("204/forwarded"), + same_probe("audited denial on the audit path", send("DELETE", "/other")) + .asserting_yaml("204/forwarded"), + ], + }); +} + +const AUDIT_OVERLAP_YAML: &str = r#" +network_policies: + mixed: + name: mixed + endpoints: + - host: api.example.com + port: 443 + path: "/**" + protocol: rest + enforcement: audit + rules: + - allow: { method: GET, path: "/**" } + deny_rules: + - { method: DELETE, path: "/**" } + - host: api.example.com + port: 443 + path: "/api/**" + protocol: rest + enforcement: enforce + rules: + - allow: { method: DELETE, path: "/api/**" } + - allow: { method: POST, path: "/api/**" } + binaries: + - { path: /usr/bin/curl } +"#; + +/// The `ws_api` WebSocket policy of +/// `forward_websocket_upgrade_blocks_text_frame_by_policy`, with or without +/// its `WEBSOCKET_TEXT` deny rule. +fn ws_api(deny_rule: bool) -> (String, String) { + let deny_yaml = if deny_rule { + "\n deny_rules:\n - method: WEBSOCKET_TEXT\n path: \"/ws\"" + } else { + "" + }; + let yaml = format!( + r#" +network_policies: + ws_api: + name: ws_api + endpoints: + - host: gateway.example.test + port: 80 + path: "/ws" + protocol: websocket + enforcement: enforce + rules: + - allow: + method: GET + path: "/ws" + - allow: + method: WEBSOCKET_TEXT + path: "/ws"{deny_yaml} + binaries: + - {{ path: /usr/bin/node }} +"# + ); + let node = binary(NODE); + let host = "gateway.example.test"; + let mut cedar = connect(host, 80, &node); + cedar.push_str(&http( + host, + 80, + r#"@path("/ws") @protocol("websocket")"#, + "permit", + &format!( + r#"{node} && context.path == "/ws" && ({GET} || context.method == "WEBSOCKET_TEXT")"# + ), + )); + if deny_rule { + cedar.push_str(&http( + host, + 80, + r#"@path("/ws")"#, + "forbid", + &format!(r#"{node} && context.method == "WEBSOCKET_TEXT" && context.path == "/ws""#), + )); + } + (yaml, cedar) +} + +#[test] +fn ported_forward_websocket_text_frame_policy() { + const NAME: &str = "proxy.rs::forward_websocket_upgrade_blocks_text_frame_by_policy"; + let ctx = l7_ctx("gateway.example.test", 80, NODE); + let message = r#"{"type":"unsafe"}"#; + let (yaml, cedar) = ws_api(true); + assert_parity(&Scenario { + name: "WebSocket text deny rule", + host: "gateway.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe(NAME, websocket(ctx.clone(), "/ws", message)) + .asserting_yaml("101/message-blocked"), + same_probe(NAME, request_in(ctx.clone(), ws_text("/ws"))).asserting_yaml("deny"), + same_probe( + "WebSocket text deny rule/upgrade", + request_in(ctx.clone(), rest("GET", "/ws")), + ) + .asserting_yaml("allow"), + ], + }); + // Positive control: without the deny rule the message is forwarded. + let (yaml, cedar) = ws_api(false); + assert_parity(&Scenario { + name: "WebSocket text allow rule", + host: "gateway.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe( + "WebSocket text allow rule/message", + websocket(ctx, "/ws", message), + ) + .asserting_yaml("101/message-forwarded"), + ], + }); +} + +#[test] +fn ported_forward_graphql_websocket_operation_policy() { + const NAME: &str = "proxy.rs::forward_graphql_websocket_upgrade_blocks_unallowed_operation"; + let host = "gateway.example.test"; + let node = binary(NODE); + let mut cedar = connect(host, 80, &node); + cedar.push_str(&http( + host, + 80, + r#"@path("/graphql") @protocol("websocket-graphql")"#, + "permit", + &format!( + r#"{node} && context.path == "/graphql" + && ({GET} || (context.graphql_operation_type == "query" && {viewer}))"#, + viewer = fields_within(&["viewer"]), + ), + )); + cedar.push_str(&http( + host, + 80, + r#"@path("/graphql")"#, + "forbid", + &format!( + r#"{node} && context.path == "/graphql" && context.graphql_operation_type == "query" + && context.graphql_fields.containsAny(["admin"])"# + ), + )); + let ctx = l7_ctx(host, 80, NODE); + assert_parity(&Scenario { + name: "GraphQL WebSocket operation rules", + host, + yaml: r#" +network_policies: + graphql_ws: + name: graphql_ws + endpoints: + - host: gateway.example.test + port: 80 + path: "/graphql" + protocol: websocket + enforcement: enforce + rules: + - allow: + method: GET + path: "/graphql" + - allow: + operation_type: query + fields: [viewer] + deny_rules: + - operation_type: query + fields: [admin] + binaries: + - { path: /usr/bin/node } +"#, + cedar: &cedar, + cases: vec![ + same_probe( + NAME, + websocket(ctx.clone(), "/graphql", &subscribe("query { admin }")), + ) + .asserting_yaml("101/message-blocked"), + same_probe( + "GraphQL WebSocket operation rules/allowed operation", + websocket(ctx, "/graphql", &subscribe("query { viewer }")), + ) + .asserting_yaml("101/message-forwarded"), + ], + }); +} + +// --------------------------------------------------------------------------- +// websocket.rs and relay.rs: WebSocket message policy +// --------------------------------------------------------------------------- + +#[test] +fn ported_graphql_websocket_message_policy() { + let host = "realtime.graphql.test"; + let node = binary(NODE); + let mut cedar = connect(host, 443, &node); + cedar.push_str(&http( + host, + 443, + r#"@path("/graphql") @protocol("websocket-graphql")"#, + "permit", + &format!( + r#"{node} && context.path == "/graphql" + && ({GET} + || (context.graphql_operation_type == "query" && {viewer}) + || (context.graphql_operation_type == "subscription" && {added}))"#, + viewer = fields_within(&["viewer"]), + added = fields_within(&["messageAdded"]), + ), + )); + let ctx = l7_ctx(host, 443, NODE); + let admin = subscribe("query Admin { adminAuditLog }"); + assert_parity(&Scenario { + name: "websocket.rs GRAPHQL_WS_POLICY", + host, + yaml: r#" +network_policies: + graphql_ws: + name: graphql_ws + endpoints: + - host: realtime.graphql.test + port: 443 + path: "/graphql" + protocol: websocket + enforcement: enforce + rules: + - allow: + method: GET + path: "/graphql" + - allow: + operation_type: query + fields: [viewer] + - allow: + operation_type: subscription + fields: [messageAdded] + binaries: + - { path: /usr/bin/node } +"#, + cedar: &cedar, + cases: vec![ + same_probe( + "websocket.rs::graphql_websocket_policy_allows_subscription_operation", + websocket( + ctx.clone(), + "/graphql", + &subscribe("subscription NewMessages { messageAdded }"), + ), + ) + .asserting_yaml("101/message-forwarded"), + same_probe( + "websocket.rs::graphql_websocket_policy_denies_unlisted_operation_field", + websocket(ctx.clone(), "/graphql", &admin), + ) + .asserting_yaml("101/message-blocked"), + same_probe( + "websocket.rs::graphql_policy_denial_reports_policy_denial_to_middleware", + websocket(ctx, "/graphql", &admin), + ) + .asserting_yaml("101/message-blocked"), + ], + }); +} + +#[test] +fn ported_websocket_text_requires_message_rule() { + const NAME: &str = "relay.rs::websocket_text_policy_requires_explicit_message_rule"; + let host = "gateway.example.test"; + let node = binary(NODE); + let mut cedar = connect(host, 443, &node); + cedar.push_str(&http( + host, + 443, + r#"@protocol("websocket")"#, + "permit", + &format!(r#"{node} && {GET} && context.path == "/ws""#), + )); + let ctx = l7_ctx(host, 443, NODE); + assert_parity(&Scenario { + name: "WebSocket without a message rule", + host, + yaml: r#" +network_policies: + ws_api: + name: ws_api + endpoints: + - host: gateway.example.test + port: 443 + protocol: websocket + enforcement: enforce + rules: + - allow: + method: GET + path: "/ws" + binaries: + - { path: /usr/bin/node } +"#, + cedar: &cedar, + cases: vec![ + same_probe(NAME, request_in(ctx.clone(), ws_text("/ws"))).asserting_yaml("deny"), + same_probe(NAME, websocket(ctx.clone(), "/ws", "hello")) + .asserting_yaml("101/message-blocked"), + same_probe( + "WebSocket without a message rule/upgrade", + request_in(ctx.clone(), rest("GET", "/ws")), + ) + .asserting_yaml("allow"), + same_probe( + "WebSocket without a message rule/plain GET", + exchange( + ctx, + format!("GET /ws HTTP/1.1\r\nHost: {host}:443\r\nConnection: close\r\n\r\n"), + None, + ), + ) + .asserting_yaml("403/blocked"), + ], + }); +} + +// --------------------------------------------------------------------------- +// relay.rs: per-request route selection +// --------------------------------------------------------------------------- + +/// `jsonrpc_and_rest_route_configs(protocol, enforcement)`: a JSON-RPC-family +/// endpoint at `/mcp` allowing only `initialize`, and a REST endpoint at +/// `/api/**`, on `mcp.example.test:8000`. +fn jsonrpc_and_rest(protocol: &str, enforcement: &str) -> (String, String) { + let yaml = format!( + r#" +network_policies: + shared_api: + name: shared_api + endpoints: + - host: mcp.example.test + port: 8000 + path: "/mcp" + protocol: {protocol} + enforcement: {enforcement} + rules: + - allow: + method: initialize + - host: mcp.example.test + port: 8000 + path: "/api/**" + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/api/**" + binaries: + - {{ path: /usr/bin/python3 }} +"# + ); + let host = "mcp.example.test"; + let python = binary(PYTHON); + let audit = if enforcement == "audit" { + r#"@enforcement("audit")"# + } else { + "" + }; + let available = if protocol == "mcp" { + r#" && context.mcp_method_class == "available""# + } else { + "" + }; + let mut cedar = connect(host, 8000, &python); + cedar.push_str(&http( + host, + 8000, + &format!(r#"@path("/mcp") @protocol("{protocol}") {audit}"#), + "permit", + &format!( + r#"{python} && context.path == "/mcp" && !context.jsonrpc_response{available} + && context.jsonrpc_method == "initialize""# + ), + )); + if protocol == "mcp" { + cedar.push_str(&http( + host, + 8000, + &format!(r#"@path("/mcp") {audit}"#), + "permit", + &format!( + r#"{python} && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response)"# + ), + )); + } + cedar.push_str(&http( + host, + 8000, + r#"@path("/api/**")"#, + "permit", + &format!(r#"{python} && {GET} && context.path like("/api/**", "/")"#), + )); + (yaml, cedar) +} + +/// The receive-stream `GET` of the MCP relay originals, with or without +/// WebSocket upgrade headers. +fn mcp_receive_stream(upgrade: bool) -> String { + let upgrade = if upgrade { + "Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + } else { + "Connection: close\r\n" + }; + format!( + "GET /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nAccept: text/event-stream\r\nMCP-Protocol-Version: 2025-11-25\r\n{upgrade}\r\n" + ) +} + +#[test] +fn ported_route_selected_jsonrpc_and_rest() { + let ctx = l7_ctx("mcp.example.test", 8000, PYTHON); + let (yaml, cedar) = jsonrpc_and_rest("mcp", "enforce"); + assert_parity(&Scenario { + name: "MCP and REST routes", + host: "mcp.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe( + "relay.rs::route_selected_mcp_websocket_upgrade_is_denied_before_forwarding", + exchange( + ctx.clone(), + mcp_receive_stream(true), + Some(UPGRADE_REFUSAL), + ), + ) + .asserting_yaml("403/blocked/marker"), + same_probe( + "relay.rs::route_selected_rest_websocket_upgrade_still_relays_beside_mcp", + websocket(ctx.clone(), "/api/ws", r#"{"type":"ping"}"#), + ) + .asserting_yaml("101/message-forwarded"), + same_probe( + "relay.rs::route_selected_mcp_receive_stream_without_upgrade_is_still_forwarded", + exchange(ctx.clone(), mcp_receive_stream(false), None), + ) + .asserting_yaml("204/forwarded"), + same_probe( + "MCP and REST routes/no endpoint path matches", + exchange( + ctx, + "GET /other HTTP/1.1\r\nHost: mcp.example.test:8000\r\nConnection: close\r\n\r\n" + .to_string(), + None, + ), + ) + .asserting_yaml("403/blocked"), + ], + }); + let ctx = l7_ctx("mcp.example.test", 8000, PYTHON); + let (yaml, cedar) = jsonrpc_and_rest("json-rpc", "audit"); + assert_parity(&Scenario { + name: "audited JSON-RPC and REST routes", + host: "mcp.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe( + "relay.rs::route_selected_audit_jsonrpc_websocket_upgrade_is_denied_before_forwarding", + exchange(ctx, mcp_receive_stream(true), Some(UPGRADE_REFUSAL)), + ) + .asserting_yaml("403/blocked/marker"), + same_probe( + "audited JSON-RPC and REST routes/inspection", + Probe::Inspection(network("mcp.example.test", 8000, PYTHON)), + ) + .asserting_yaml("JsonRpc/Audit,Rest/Enforce"), + ], + }); +} + +/// `graphql_and_rest_route_configs(enforcement)`: a GraphQL endpoint at +/// `/graphql` allowing only `query { viewer }`, and a REST endpoint at +/// `/api/**`, on `graphql.example.test:8000`. +fn graphql_and_rest(enforcement: &str) -> (String, String) { + let yaml = format!( + r#" +network_policies: + shared_graphql: + name: shared_graphql + endpoints: + - host: graphql.example.test + port: 8000 + path: "/graphql" + protocol: graphql + enforcement: {enforcement} + rules: + - allow: + operation_type: query + fields: [viewer] + - host: graphql.example.test + port: 8000 + path: "/api/**" + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/api/**" + binaries: + - {{ path: /usr/bin/python3 }} +"# + ); + let host = "graphql.example.test"; + let python = binary(PYTHON); + let audit = if enforcement == "audit" { + r#"@enforcement("audit")"# + } else { + "" + }; + let mut cedar = connect(host, 8000, &python); + cedar.push_str(&http( + host, + 8000, + &format!(r#"@path("/graphql") @protocol("graphql") {audit}"#), + "permit", + &format!( + r#"{python} && context.path == "/graphql" && context.graphql_operation_type == "query" + && {viewer}"#, + viewer = fields_within(&["viewer"]), + ), + )); + cedar.push_str(&http( + host, + 8000, + r#"@path("/api/**")"#, + "permit", + &format!(r#"{python} && {GET} && context.path like("/api/**", "/")"#), + )); + (yaml, cedar) +} + +/// A `GET` of `target` on the GraphQL route host, as a WebSocket upgrade or +/// a plain request. +fn graphql_get(target: &str, upgrade: bool) -> String { + let headers = if upgrade { + "Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + } else { + "Connection: close\r\n" + }; + format!("GET {target} HTTP/1.1\r\nHost: graphql.example.test:8000\r\n{headers}\r\n") +} + +#[test] +fn ported_route_selected_graphql_and_rest() { + const UPGRADE: &str = + "relay.rs::route_selected_graphql_websocket_upgrade_is_denied_before_forwarding"; + let ctx = l7_ctx("graphql.example.test", 8000, PYTHON); + let viewer = "/graphql?query=%7Bviewer%7D"; + let admin = "/graphql?query=%7Badmin%7D"; + let (yaml, cedar) = graphql_and_rest("enforce"); + assert_parity(&Scenario { + name: "GraphQL and REST routes", + host: "graphql.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe( + format!("{UPGRADE}/enforce"), + exchange( + ctx.clone(), + graphql_get(viewer, true), + Some(UPGRADE_REFUSAL), + ), + ) + .asserting_yaml("403/blocked/marker"), + same_probe( + "relay.rs::route_selected_graphql_query_without_upgrade_is_still_forwarded", + exchange(ctx.clone(), graphql_get(viewer, false), None), + ) + .asserting_yaml("204/forwarded"), + same_probe( + "GraphQL and REST routes/denied query", + exchange(ctx.clone(), graphql_get(admin, false), None), + ) + .asserting_yaml("403/blocked"), + same_probe( + "GraphQL and REST routes/REST route", + exchange(ctx.clone(), graphql_get("/api/items", false), None), + ) + .asserting_yaml("204/forwarded"), + ], + }); + let (yaml, cedar) = graphql_and_rest("audit"); + assert_parity(&Scenario { + name: "audited GraphQL and REST routes", + host: "graphql.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe( + format!("{UPGRADE}/audit"), + exchange(ctx.clone(), graphql_get(viewer, true), Some(UPGRADE_REFUSAL)), + ) + .asserting_yaml("403/blocked/marker"), + same_probe( + "relay.rs::route_selected_audit_graphql_upgrade_with_denied_query_is_refused", + exchange(ctx.clone(), graphql_get(admin, true), Some(UPGRADE_REFUSAL)), + ) + .asserting_yaml("403/blocked/marker"), + // The audit path logs and forwards the denied query; the REST + // path beside it stays enforced. + same_probe( + "audited GraphQL and REST routes/denied query is forwarded", + exchange(ctx.clone(), graphql_get(admin, false), None), + ) + .asserting_yaml("204/forwarded"), + same_probe( + "audited GraphQL and REST routes/enforced REST route", + exchange( + ctx, + "DELETE /api/items HTTP/1.1\r\nHost: graphql.example.test:8000\r\nConnection: close\r\n\r\n" + .to_string(), + None, + ), + ) + .asserting_yaml("403/blocked"), + ], + }); +} + +// --------------------------------------------------------------------------- +// l7/relay/token_grant_ownership_tests.rs: native protocols beside REST +// --------------------------------------------------------------------------- + +#[test] +fn ported_native_protocol_grants_beside_an_unrelated_route() { + const PRESERVE: &str = "relay/token_grant_ownership_tests.rs::native_protocol_grants_preserve_auth_when_an_unrelated_route_is_added/multiple routes"; + const DENIALS: &str = "relay/token_grant_ownership_tests.rs::native_protocol_denials_do_not_resolve_grants_or_forward_bytes/multiple routes"; + const MISSING: &str = "relay/token_grant_ownership_tests.rs::missing_grant_owner_metadata_rejects_only_matching_requests/multiple routes"; + for protocol in ["graphql", "json-rpc", "mcp"] { + let (rules, cedar) = native_scenario(protocol, true); + let native = |operation| phase1::request(native_request(protocol, operation)); + let owners = |operation| phase1::owners(native_request(protocol, operation)); + let mut cases = vec![ + same_probe(PRESERVE, native("echo")).asserting_yaml("allow"), + same_probe(PRESERVE, owners("echo")).asserting_yaml("native"), + same_probe(DENIALS, native("blocked")).asserting_yaml("deny"), + same_probe(DENIALS, owners("blocked")).asserting_yaml("none"), + same_probe( + "native protocol beside REST/unrelated route", + phase1::owners(rest("GET", "/other/item")), + ) + .asserting_yaml("unrelated"), + same_probe( + "native protocol beside REST/inspection", + Probe::Inspection(network(phase1::HOST, phase1::PORT, BINARY)), + ), + ]; + if protocol == "mcp" { + // The original installs an extra grant without owner metadata; + // which grant is rejected is decided after owner selection. + cases.push(same_probe(MISSING, owners("echo")).asserting_yaml("native")); + } + assert_provider_parity(&ProviderScenario { + name: protocol, + host: phase1::HOST, + rules, + cedar: &cedar, + cases, + }); + } +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase4.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase4.rs new file mode 100644 index 0000000000..fa6ab4062c --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase4.rs @@ -0,0 +1,609 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML tests for query parameter matchers (Cedar parity plan, +//! phase 4). +//! +//! A YAML `query` matcher on key `k` becomes +//! `context.query.hasTag("k") && context.query.getTag("k") like ""`, +//! and an `any` matcher an `||` of such `like`s. Rego matches query values +//! with `glob.match(pattern, [], value)`, whose empty delimiter list means +//! `.`, so `*` stays within one `.`-separated segment: the translation is +//! Cedar's `like` with a `.` delimiter, which agrees for the `*`-only +//! patterns these tests use. Each YAML allow rule becomes its own +//! `permit`, since Cedar allows a request with repeated keys only when one +//! `permit` allows every combination of values, as one YAML rule must match +//! every value. A YAML deny rule becomes a `forbid`, which denies when one +//! combination matches, as a deny rule fires on one matching value per key. + +use super::ported_opa_a::{L7_TEST_CEDAR, L7_TEST_DATA}; +use super::ported_phase1::{self as phase1, GET}; +use super::*; + +use crate::l7::jsonrpc::McpMethodClassification::Available; + +/// The Cedar condition for a YAML binary entry with an exact path. +fn binary(path: &str) -> String { + phase1::binary(path) +} + +/// The Cedar condition for the YAML query matcher `key: pattern`. +fn query_glob(key: &str, pattern: &str) -> String { + format!( + r#"(context.query.hasTag("{key}") && context.query.getTag("{key}") like("{pattern}", "."))"# + ) +} + +/// The Cedar condition for the YAML query matcher `key: {any: patterns}`. +fn query_any(key: &str, patterns: &[&str]) -> String { + let alternatives = patterns + .iter() + .map(|pattern| format!(r#"context.query.getTag("{key}") like("{pattern}", ".")"#)) + .collect::>() + .join(" || "); + format!(r#"(context.query.hasTag("{key}") && ({alternatives}))"#) +} + +/// A request for `method target` with `query`, as the request parser +/// decodes it. +fn with_query(method: &str, target: &str, query: &[(&str, &[&str])]) -> L7RequestInfo { + L7RequestInfo { + query_params: query + .iter() + .map(|(key, values)| { + ( + (*key).to_string(), + values.iter().map(ToString::to_string).collect(), + ) + }) + .collect(), + ..rest(method, target) + } +} + +/// A request probe for `host:port` from [`BINARY`]. +fn at(host: &str, port: u16, request: L7RequestInfo) -> Probe { + request_in(l7_ctx(host, port, BINARY), request) +} + +/// One HTTP/1 `GET target` through the relay to `host:port`, answered by +/// the upstream with no content. +fn relay_get(host: &str, port: u16, target: &str) -> Probe { + Probe::Relay(Box::new(RelayExchange { + ctx: l7_ctx(host, port, BINARY), + route_selected: false, + request: format!( + "GET {target} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n" + ), + upstream_response: NO_CONTENT.to_string(), + marker: None, + })) +} + +// --------------------------------------------------------------------------- +// opa.rs: `query_api` in `L7_TEST_DATA` +// --------------------------------------------------------------------------- + +/// The Cedar translation of `query_api` in [`L7_TEST_DATA`], which +/// `L7_TEST_CEDAR` leaves out. Each YAML rule is its own `permit`. +fn query_api_cedar() -> String { + let curl = binary(BINARY); + format!( + r#" +// query_api +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.query.com:8080") +when {{ {curl} }}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.query.com:8080") +when {{ + {curl} && {GET} && context.path == "/download" + && {download} +}}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.query.com:8080") +when {{ + {curl} && {GET} && context.path == "/search" + && {search} +}}; +"#, + download = query_glob("tag", "foo-*"), + search = query_any("tag", &["foo-*", "bar-*"]), + ) +} + +#[test] +fn ported_l7_test_data_query_api() { + let api = |target: &str, query: &[(&str, &[&str])]| { + at("api.query.com", 8080, with_query("GET", target, query)) + }; + // Within the cap: a page of repeated values, as an earlier cap of 64 + // would have refused. + let many: Vec = (0..65).map(|value| format!("foo-{value}")).collect(); + let many: Vec<&str> = many.iter().map(String::as_str).collect(); + let over_cap: Vec = (0..=openshell_policy_cedar::MAX_QUERY_COMBINATIONS) + .map(|value| format!("foo-{value}")) + .collect(); + let over_cap: Vec<&str> = over_cap.iter().map(String::as_str).collect(); + assert_parity(&Scenario { + name: "opa.rs L7_TEST_DATA query_api", + host: "api.query.com", + yaml: L7_TEST_DATA, + cedar: &format!("{L7_TEST_CEDAR}{}", query_api_cedar()), + cases: vec![ + same_probe( + "opa.rs::l7_query_glob_allows_matching_duplicate_values", + api( + "/download", + &[("tag", &["foo-a", "foo-b"]), ("extra", &["ignored"])], + ), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_query_glob_denies_on_mismatched_duplicate_value", + api("/download", &[("tag", &["foo-a", "evil"])]), + ) + .asserting_yaml("deny"), + same_probe( + "opa.rs::l7_query_any_allows_if_every_value_matches_any_pattern", + api("/search", &[("tag", &["foo-a", "bar-b"])]), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_query_missing_required_key_denied", + api("/download", &[]), + ) + .asserting_yaml("deny"), + // The original sets `graphql` and `jsonrpc` to null in the Rego + // input; a parsed REST request carries neither. + same_probe( + "opa.rs::l7_rest_request_ignores_null_jsonrpc_metadata", + api("/download", &[("tag", &["foo-a"])]), + ) + .asserting_yaml("allow"), + same_probe( + "query_api/a glob star stays within one dot-separated segment", + api("/download", &[("tag", &["foo-a.b"])]), + ) + .asserting_yaml("deny"), + same_probe( + "query_api/any denies a value matching no pattern", + api("/search", &[("tag", &["foo-a", "baz"])]), + ) + .asserting_yaml("deny"), + same_probe( + "query_api/relay decodes and forwards matching values", + relay_get("api.query.com", 8080, "/download?tag=foo-a&t%61g=foo%2Db"), + ) + .asserting_yaml("204/forwarded"), + same_probe( + "query_api/relay blocks a mismatched repeated value", + relay_get("api.query.com", 8080, "/download?tag=foo-a&tag=evil"), + ) + .asserting_yaml("403/blocked"), + same_probe( + "query_api/65 repeated values within the combination cap", + api("/download", &[("tag", &many)]), + ) + .asserting_yaml("allow"), + diverges_probe( + "query_api/repeated values over the combination cap", + api("/download", &[("tag", &over_cap)]), + "Cedar evaluates at most MAX_QUERY_COMBINATIONS value combinations per \ + request and denies a request with more; YAML checks every value", + ) + .asserting_yaml("allow"), + ], + }); +} + +// --------------------------------------------------------------------------- +// opa.rs: `deny_with_query` in `L7_DENY_TEST_DATA` +// --------------------------------------------------------------------------- + +#[test] +fn ported_l7_deny_rule_with_query() { + let admin = |query: &[(&str, &[&str])]| with_query("POST", "/admin/settings", query); + let curl = binary(BINARY); + assert_parity(&Scenario { + name: "opa.rs L7_DENY_TEST_DATA deny_with_query", + host: "api.restricted.com", + yaml: r#" +network_policies: + deny_with_query: + name: deny_with_query + endpoints: + - host: api.restricted.com + port: 443 + protocol: rest + enforcement: enforce + access: full + deny_rules: + - method: POST + path: "/admin/**" + query: + force: "true" + binaries: + - { path: /usr/bin/curl } +"#, + cedar: &format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.restricted.com:443") +when {{ {curl} }}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.restricted.com:443") +when {{ {curl} }}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.restricted.com:443") +when {{ + context.method == "POST" && context.path like("/admin/**", "/") + && {force} +}}; +"#, + force = query_glob("force", "true"), + ), + cases: vec![ + same( + "opa.rs::l7_deny_rule_with_query_blocks_matching_params", + admin(&[("force", &["true"])]), + ) + .asserting_yaml("deny"), + same( + "opa.rs::l7_deny_rule_with_query_allows_non_matching_params", + admin(&[("force", &["false"])]), + ) + .asserting_yaml("allow"), + same( + "opa.rs::l7_deny_rule_with_query_blocks_when_any_value_matches", + admin(&[("force", &["true", "false"])]), + ) + .asserting_yaml("deny"), + same( + "opa.rs::l7_deny_rule_without_matching_query_key_allows", + rest("POST", "/admin/settings"), + ) + .asserting_yaml("allow"), + same( + "deny_with_query/a dotted value does not match the literal", + admin(&[("force", &["true.x"])]), + ) + .asserting_yaml("allow"), + same( + "deny_with_query/the deny rule needs its path", + with_query("POST", "/settings", &[("force", &["true"])]), + ) + .asserting_yaml("allow"), + ], + }); +} + +// --------------------------------------------------------------------------- +// opa.rs: query matchers loaded from protobuf +// --------------------------------------------------------------------------- + +/// The original builds the policy as protobuf with an `L7QueryMatcher` +/// whose `glob` is `foo-*`; the YAML here is the same policy. +#[test] +fn ported_l7_query_rules_from_proto() { + let curl = binary(BINARY); + let download = |values: &[&str]| { + at( + "api.proto.com", + 8080, + with_query("GET", "/download", &[("tag", values)]), + ) + }; + assert_parity(&Scenario { + name: "opa.rs query_proto", + host: "api.proto.com", + yaml: r#" +network_policies: + query_proto: + name: query_proto + endpoints: + - host: api.proto.com + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /download + query: + tag: {glob: "foo-*"} + binaries: + - { path: /usr/bin/curl } +"#, + cedar: &format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.proto.com:8080") +when {{ {curl} }}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.proto.com:8080") +when {{ {curl} && {GET} && context.path == "/download" && {tag} }}; +"#, + tag = query_glob("tag", "foo-*"), + ), + cases: vec![ + same_probe( + "opa.rs::l7_query_rules_from_proto_are_enforced/allow", + download(&["foo-a"]), + ) + .asserting_yaml("allow"), + same_probe( + "opa.rs::l7_query_rules_from_proto_are_enforced/deny", + download(&["evil"]), + ) + .asserting_yaml("deny"), + ], + }); +} + +// --------------------------------------------------------------------------- +// opa.rs: matchers that survive a reload +// --------------------------------------------------------------------------- + +/// The REST half of the original: a query allow matcher and a narrower query +/// deny matcher on the same endpoint. The original also marks the endpoint +/// `provider_credentialed` and `advisor_proposed`, provenance with no Cedar +/// counterpart that does not affect decisions. +#[test] +fn ported_yaml_and_proto_matchers_rest_query() { + let host = "matchers.parity.test"; + let curl = binary(BINARY); + let name = |value: &str| with_query("GET", "/", &[("name", &[value])]); + assert_parity_across_reload(&Scenario { + name: "opa.rs matchers rest", + host, + yaml: r#" +network_policies: + matchers: + name: matchers + endpoints: + - host: matchers.parity.test + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /** + query: {name: "read_*"} + deny_rules: + - method: GET + path: /** + query: {name: "read_sec*"} + binaries: + - {path: /usr/bin/curl} +"#, + cedar: &format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{host}:443") +when {{ {curl} }}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:443") +when {{ {curl} && {GET} && context.path like("/**", "/") && {allow} }}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:443") +when {{ {GET} && context.path like("/**", "/") && {deny} }}; +"#, + allow = query_glob("name", "read_*"), + deny = query_glob("name", "read_sec*"), + ), + cases: vec![ + same( + "opa.rs::yaml_and_proto_matchers_retain_decisions_and_provenance_across_reload/rest allow", + name("read_status"), + ) + .asserting_yaml("allow"), + same( + "opa.rs::yaml_and_proto_matchers_retain_decisions_and_provenance_across_reload/rest deny", + name("read_secret"), + ) + .asserting_yaml("deny"), + ], + }); +} + +/// The MCP half of the original: a `params` allow matcher on the tool name +/// and an `any` deny matcher. Provenance is left out as for REST. +#[test] +fn ported_yaml_and_proto_matchers_mcp_params() { + let host = "matchers.parity.test"; + let curl = binary(BINARY); + let tool = + |name: &str| jsonrpc_request("/", vec![call("tools/call", Some(name), Some(Available))]); + let endpoint = format!("{host}:443"); + assert_parity_across_reload(&Scenario { + name: "opa.rs matchers mcp", + host, + yaml: r#" +network_policies: + matchers: + name: matchers + endpoints: + - host: matchers.parity.test + port: 443 + protocol: mcp + enforcement: enforce + rules: + - allow: + method: tools/call + params: {name: "read_*"} + deny_rules: + - method: tools/call + params: {name: {any: [read_secret, read_private]}} + binaries: + - {path: /usr/bin/curl} +"#, + cedar: &format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{endpoint}") +when {{ {curl} }}; +@protocol("mcp") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{endpoint}") +when {{ + {curl} + && !context.jsonrpc_response + && context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" + && context.mcp_tool like("read_*", ".") +}}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{endpoint}") +when {{ + context.jsonrpc_method == "tools/call" + && (context.mcp_tool like("read_secret", ".") || context.mcp_tool like("read_private", ".")) +}}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{endpoint}") +when {{ + {curl} + && ((context.method == "GET" && context.jsonrpc_receive_stream) || context.jsonrpc_response) +}}; +"# + ), + cases: vec![ + same( + "opa.rs::yaml_and_proto_matchers_retain_decisions_and_provenance_across_reload/mcp allow", + tool("read_status"), + ) + .asserting_yaml("allow"), + same( + "opa.rs::yaml_and_proto_matchers_retain_decisions_and_provenance_across_reload/mcp deny", + tool("read_secret"), + ) + .asserting_yaml("deny"), + same("matchers mcp/deny any second pattern", tool("read_private")) + .asserting_yaml("deny"), + same( + "matchers mcp/a glob star stays within one dot-separated segment", + tool("read_a.b"), + ) + .asserting_yaml("deny"), + ], + }); +} + +/// The original's empty scalar matcher: a deny rule on `name=""`. +#[test] +fn ported_yaml_empty_query_matcher() { + let host = "empty.parity.test"; + let curl = binary(BINARY); + let name = |value: &str| with_query("GET", "/", &[("name", &[value])]); + assert_parity_across_reload(&Scenario { + name: "opa.rs empty_query", + host, + yaml: r#" +network_policies: + empty_query: + name: empty_query + endpoints: + - host: empty.parity.test + port: 443 + protocol: rest + enforcement: enforce + access: full + deny_rules: + - method: GET + path: /** + query: {name: ""} + binaries: + - {path: /usr/bin/curl} +"#, + cedar: &format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{host}:443") +when {{ {curl} }}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:443") +when {{ {curl} }}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:443") +when {{ {GET} && context.path like("/**", "/") && {empty} }}; +"#, + empty = query_glob("name", ""), + ), + cases: vec![ + same( + "opa.rs::yaml_empty_query_matcher_retains_deny_semantics_across_reload/empty", + name(""), + ) + .asserting_yaml("deny"), + same( + "opa.rs::yaml_empty_query_matcher_retains_deny_semantics_across_reload/present", + name("present"), + ) + .asserting_yaml("allow"), + ], + }); +} + +// --------------------------------------------------------------------------- +// Query matchers on an audit endpoint +// --------------------------------------------------------------------------- + +/// A YAML `enforcement: audit` endpoint with a query matcher, and its Cedar +/// translation with `@enforcement("audit")`: both deny a mismatched repeated +/// value, and the relay logs and forwards it. +#[test] +fn query_matchers_on_an_audit_endpoint() { + let host = "audit.query.test"; + let curl = binary(BINARY); + let download = |values: &[&str]| with_query("GET", "/download", &[("tag", values)]); + assert_parity(&Scenario { + name: "query audit endpoint", + host, + yaml: r#" +network_policies: + audit_query: + name: audit_query + endpoints: + - host: audit.query.test + port: 443 + protocol: rest + enforcement: audit + rules: + - allow: + method: GET + path: /download + query: {tag: "foo-*"} + binaries: + - {path: /usr/bin/curl} +"#, + cedar: &format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{host}:443") +when {{ {curl} }}; +@protocol("rest") +@enforcement("audit") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:443") +when {{ {curl} && {GET} && context.path == "/download" && {tag} }}; +"#, + tag = query_glob("tag", "foo-*"), + ), + cases: vec![ + same("allowed values", download(&["foo-a", "foo-b"])).asserting_yaml("allow"), + same("a mismatched value", download(&["foo-a", "evil"])).asserting_yaml("deny"), + same_probe( + "the relay forwards the audit denial", + relay_get(host, 443, "/download?tag=foo-a&tag=evil"), + ) + .asserting_yaml("204/forwarded"), + ], + }); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase5.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase5.rs new file mode 100644 index 0000000000..d3bd52a82c --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase5.rs @@ -0,0 +1,392 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML tests for native TCP endpoints (Cedar parity plan, phase 5). +//! +//! A YAML endpoint with `protocol: tcp` becomes a `NetworkConnect` permit +//! annotated `@transport("tcp")`. Both are plain L4 connections without an +//! endpoint config, and both publish a policy DNS record marked +//! `protocol: tcp`. YAML names a record by its policy and endpoint index and +//! Cedar by its position among the `cedar` records, so the probes compare +//! records by host, ports, and transport, and check that each engine's +//! connection decisions name records of its own DNS snapshot. + +use super::ported_opa_a::{L7_TEST_CEDAR, L7_TEST_DATA}; +use super::*; + +/// A transparent open of `name:port` from `binary`. +fn transparent_open(name: &str, port: u16, binary: &str) -> Probe { + Probe::TransparentOpen { + name: name.to_string(), + port, + binary: binary.to_string(), + answers: vec![TRANSPARENT_UPSTREAM.into()], + } +} + +fn dns(name: &str, port: u16) -> Probe { + Probe::DnsEligible { + name: name.to_string(), + port, + } +} + +// --------------------------------------------------------------------------- +// opa.rs::egress_authorization_preserves_explicit_tcp_endpoint_identity +// --------------------------------------------------------------------------- + +#[test] +fn ported_explicit_tcp_endpoint_identity() { + const CLIENT: &str = "/usr/bin/client"; + const NAME: &str = "opa.rs::egress_authorization_preserves_explicit_tcp_endpoint_identity"; + let input = || network("database.example.com", 5432, CLIENT); + assert_parity(&Scenario { + name: "Explicit TCP endpoint identity", + host: "database.example.com", + yaml: r" +network_policies: + native_tcp: + name: native_tcp + endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + binaries: + - path: /usr/bin/client +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +", + cedar: r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"database.example.com:5432") +when { context.binary_path == "/usr/bin/client" }; +"#, + cases: vec![ + same_probe(format!("{NAME} configs"), Probe::Inspection(input())) + .asserting_yaml("none"), + same_probe(format!("{NAME} matched"), Probe::MatchedEndpoints(input())) + .asserting_yaml("database.example.com:5432/tcp"), + same_probe( + format!("{NAME} other binary"), + Probe::MatchedEndpoints(network("database.example.com", 5432, BINARY)), + ) + .asserting_yaml("deny"), + ], + }); +} + +// --------------------------------------------------------------------------- +// opa.rs::explicit_tcp_authorizes_as_l4_without_endpoint_config +// --------------------------------------------------------------------------- + +/// `L7_TEST_CEDAR` marks `explicit-tcp.example.com:443` with +/// `@transport("tcp")`. +#[test] +fn ported_explicit_tcp_authorizes_as_l4() { + const NAME: &str = "opa.rs::explicit_tcp_authorizes_as_l4_without_endpoint_config"; + let input = || network("explicit-tcp.example.com", 443, BINARY); + assert_parity(&Scenario { + name: "opa.rs L7_TEST_DATA explicit TCP", + host: "explicit-tcp.example.com", + yaml: L7_TEST_DATA, + cedar: L7_TEST_CEDAR, + cases: vec![ + same_probe(format!("{NAME} allow"), Probe::Connect(input())).asserting_yaml("allow"), + same_probe(format!("{NAME} config"), Probe::Inspection(input())).asserting_yaml("none"), + same_probe(format!("{NAME} exact"), Probe::ExactHost(input())).asserting_yaml("exact"), + same_probe(format!("{NAME} matched"), Probe::MatchedEndpoints(input())) + .asserting_yaml("explicit-tcp.example.com:443/tcp"), + // The plain L4 endpoint beside it is not native TCP. + same_probe( + format!("{NAME} plain L4 control"), + Probe::MatchedEndpoints(network("l4only.example.com", 443, BINARY)), + ) + .asserting_yaml("l4only.example.com:443"), + ], + }); +} + +// --------------------------------------------------------------------------- +// opa.rs::policy_dns_snapshot_includes_every_tcp_carried_endpoint +// --------------------------------------------------------------------------- + +/// `POLICY_DNS_SNAPSHOT_DATA` from `opa.rs`. +/// +/// The hostless `protocol: tcp` endpoint with `allowed_ips` has no Cedar +/// form: Cedar rejects a hostless `@transport("tcp")` permit at load, as +/// gateway validation rejects that YAML endpoint +/// (`lib.rs::validate_rejects_hostless_allowed_ips_for_explicit_tcp`). The +/// test asserts it is left out of the snapshot, which the record list and +/// the `8.8.8.8` case check on the YAML side. `access: full` on a REST +/// endpoint becomes an `HttpRequest` permit without request conditions. +/// The test's endpoint indices are YAML policy positions and are not +/// compared; each matched record must be in its engine's snapshot. +#[test] +fn ported_policy_dns_snapshot_tcp_endpoints() { + const NAME: &str = "opa.rs::policy_dns_snapshot_includes_every_tcp_carried_endpoint"; + const PROCESS: &str = "/usr/bin/one-process"; + assert_parity_across_reload(&Scenario { + name: "Policy DNS snapshot TCP endpoints", + host: "web.example", + yaml: r" +network_policies: + dns_transport: + name: dns_transport + endpoints: + - { host: resolver.example, ports: [53, 853], protocol: tcp } + - { host: web.example, port: 443, protocol: rest, access: full } + - { host: implicit.example, port: 443 } + - { host: '', port: 53, protocol: tcp, allowed_ips: [8.8.8.8] } + - { host: secondary.example, port: 5353, protocol: tcp } + binaries: + - { path: /usr/bin/one-process } +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +", + cedar: r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"resolver.example:53") +when { context.binary_path == "/usr/bin/one-process" }; +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"resolver.example:853") +when { context.binary_path == "/usr/bin/one-process" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"web.example:443") +when { context.binary_path == "/usr/bin/one-process" }; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"web.example:443") +when { context.binary_path == "/usr/bin/one-process" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"implicit.example:443") +when { context.binary_path == "/usr/bin/one-process" }; +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"secondary.example:5353") +when { context.binary_path == "/usr/bin/one-process" }; +"#, + cases: vec![ + same_probe(format!("{NAME} records"), Probe::DnsRecords).asserting_yaml( + "implicit.example:443;resolver.example:53,853/tcp;\ + secondary.example:5353/tcp;web.example:443", + ), + same_probe(format!("{NAME} resolver 53"), dns("resolver.example", 53)) + .asserting_yaml("eligible"), + same_probe(format!("{NAME} resolver 853"), dns("resolver.example", 853)) + .asserting_yaml("eligible"), + same_probe(format!("{NAME} web"), dns("web.example", 443)).asserting_yaml("eligible"), + same_probe(format!("{NAME} implicit"), dns("implicit.example", 443)) + .asserting_yaml("eligible"), + same_probe(format!("{NAME} secondary"), dns("secondary.example", 5353)) + .asserting_yaml("eligible"), + same_probe( + format!("{NAME} undeclared port"), + dns("resolver.example", 443), + ) + .asserting_yaml("ineligible"), + same_probe(format!("{NAME} hostless excluded"), dns("8.8.8.8", 53)) + .asserting_yaml("ineligible"), + same_probe( + format!("{NAME} resolver matched"), + Probe::MatchedEndpoints(network("resolver.example", 853, PROCESS)), + ) + .asserting_yaml("resolver.example:53,853/tcp"), + same_probe( + format!("{NAME} web matched"), + Probe::MatchedEndpoints(network("web.example", 443, PROCESS)), + ) + .asserting_yaml("web.example:443"), + ], + }); +} + +// --------------------------------------------------------------------------- +// opa.rs::yaml_and_proto_sql_and_l4_policies_have_config_and_decision_parity +// --------------------------------------------------------------------------- + +/// SQL commands are not inspected in either format: the proxy does not +/// enforce YAML SQL rules, and Cedar rejects `@protocol("sql")`. Only the +/// TCP and L4 parts of the test are ported: each endpoint allows the +/// connection and denies an unlisted host, and the `tcp` and plain forms +/// carry no endpoint config. The SQL variant's audit config and command +/// decisions are not ported. The YAML-against-proto comparison is a YAML +/// loader check with no Cedar counterpart. The test's `protocol: ""` +/// endpoint is loaded in its delivered form, with no protocol entry (see +/// `load_delivered_yaml`), so it is DNS eligible like the Cedar endpoint. + +#[test] +fn ported_sql_and_l4_connection_parity() { + const NAME: &str = "opa.rs::yaml_and_proto_sql_and_l4_policies_have_config_and_decision_parity"; + for (protocol, fields, annotation) in [ + ( + "sql", + "enforcement: audit\n rules: [{allow: {command: SELECT}}]", + "", + ), + ("tcp", "", "@transport(\"tcp\")"), + ("", "", ""), + ] { + let yaml = format!( + r#" +version: 1 +network_policies: + parity: + name: parity + endpoints: + - host: sql-l4.parity.test + port: 443 + protocol: "{protocol}" + {fields} + binaries: + - {{path: /usr/bin/curl}} +"# + ); + let cedar = format!( + r#" +{annotation} +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"sql-l4.parity.test:443") +when {{ context.binary_path == "/usr/bin/curl" }}; +"# + ); + let input = || network("sql-l4.parity.test", 443, BINARY); + let mut cases = vec![ + same_probe( + format!("{NAME} {protocol:?} allow"), + Probe::Connect(input()), + ) + .asserting_yaml("allow"), + same_probe( + format!("{NAME} {protocol:?} unlisted"), + Probe::Connect(network("unlisted.parity.test", 443, BINARY)), + ) + .asserting_yaml("deny"), + ]; + if protocol != "sql" { + cases.push( + same_probe( + format!("{NAME} {protocol:?} config"), + Probe::Inspection(input()), + ) + .asserting_yaml("none"), + ); + // A native TCP record carries the `/tcp` marker; the default + // L4 form does not. + let record = if protocol == "tcp" { + "sql-l4.parity.test:443/tcp" + } else { + "sql-l4.parity.test:443" + }; + cases.push( + same_probe( + format!("{NAME} {protocol:?} matched"), + Probe::MatchedEndpoints(input()), + ) + .asserting_yaml(record), + ); + cases.push( + same_probe( + format!("{NAME} {protocol:?} dns"), + dns("sql-l4.parity.test", 443), + ) + .asserting_yaml("eligible"), + ); + } + assert_parity(&Scenario { + name: &format!("SQL and L4 parity, protocol {protocol:?}"), + host: "sql-l4.parity.test", + yaml: &yaml, + cedar: &cedar, + cases, + }); + } +} + +// --------------------------------------------------------------------------- +// proxy.rs::staged_transparent_open_dials_only_pinned_policy_dns_addresses +// --------------------------------------------------------------------------- + +/// `POLICY_DNS_OPEN_POLICY` from `proxy.rs`, with `db.example` marked +/// `@transport("tcp")`. +/// +/// The original publishes the mapping directly; here each engine's own +/// policy DNS publishes it from its snapshot, so the probe also checks that +/// the published mapping pins the resolved address. `correlated` checks +/// that the decision names an endpoint of the mapping, which the transparent +/// TCP listener requires and which Cedar decisions used to lack. +#[test] +#[cfg_attr( + target_os = "windows", + ignore = "transparent TCP opens are not supported on Windows, so the YAML side differs too" +)] +fn ported_staged_transparent_open_dials_pinned_addresses() { + const NAME: &str = "proxy.rs::staged_transparent_open_dials_only_pinned_policy_dns_addresses"; + assert_parity(&Scenario { + name: "Staged transparent open native TCP", + host: "db.example", + yaml: r" +network_policies: + database: + name: database + endpoints: + - { host: db.example, port: 5432, protocol: tcp } + binaries: [{ path: /usr/bin/curl }] + pypi: + name: pypi + endpoints: + - { host: pypi.org, port: 80 } + binaries: [{ path: /usr/bin/curl }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +", + cedar: r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example:5432") +when { context.binary_path == "/usr/bin/curl" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:80") +when { context.binary_path == "/usr/bin/curl" }; +"#, + cases: vec![ + same_probe(NAME, transparent_open("db.example", 5432, BINARY)) + .asserting_yaml("RelayReady/203.0.113.8:5432/correlated"), + same_probe( + format!("{NAME} plain L4 control"), + transparent_open("pypi.org", 80, BINARY), + ) + .asserting_yaml("RelayReady/203.0.113.8:80/correlated"), + same_probe( + format!("{NAME} other binary"), + transparent_open("db.example", 5432, "/usr/bin/wget"), + ) + .asserting_yaml("Denied(PolicyDenied)"), + same_probe( + format!("{NAME} undeclared port"), + transparent_open("db.example", 443, BINARY), + ), + same_probe( + format!("{NAME} undeclared name"), + transparent_open("unknown.example", 5432, BINARY), + ), + ], + }); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase6.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase6.rs new file mode 100644 index 0000000000..73ae20d5a7 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase6.rs @@ -0,0 +1,1184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML tests for `allowed_ips` (Cedar parity plan, phase 6). +//! +//! A YAML endpoint's `allowed_ips` becomes a `NetworkConnect` permit +//! condition, `context has destination_ip && +//! context.destination_ip.isInRange(ip("..."))`, and a host-less endpoint a +//! permit with a port condition and no host. Many original tests assert the +//! `allowed_ips` list a YAML engine returns or call the proxy's address +//! checks with hand-built allowlists. Cedar has no such list, so those are +//! ported as [`Probe::Destination`] cases: the decision for a connection +//! whose host resolved to given addresses, through each engine's own +//! destination mode, and [`Probe::DnsAnswers`] cases for policy DNS answer +//! filtering. Tests that build an allowlist covering loopback, link-local, +//! or unspecified addresses keep their address and use a valid allowlist: +//! Cedar rejects such ranges when the policy loads +//! (`openshell-policy-cedar/tests/destination_ip.rs`), and the address stays +//! blocked in both formats. + +use std::net::IpAddr; + +use super::*; + +fn ips(addresses: &[&str]) -> Vec { + addresses + .iter() + .map(|address| address.parse().expect("test address parses")) + .collect() +} + +/// A connection from `binary` to `host:port` whose host resolved to +/// `addresses`. +fn destination(host: &str, port: u16, binary: &str, addresses: &[&str]) -> Probe { + Probe::Destination { + input: network(host, port, binary), + addresses: ips(addresses), + } +} + +fn dns_answers(name: &str, port: u16, answers: &[&str]) -> Probe { + Probe::DnsAnswers { + name: name.to_string(), + port, + answers: ips(answers), + } +} + +fn dns(name: &str, port: u16) -> Probe { + Probe::DnsEligible { + name: name.to_string(), + port, + } +} + +// --------------------------------------------------------------------------- +// opa.rs ALLOWED_IPS_TEST_DATA +// --------------------------------------------------------------------------- + +const ALLOWED_IPS_YAML: &str = r#" +network_policies: + internal_api: + name: internal_api + endpoints: + - host: my-service.corp.net + port: 8080 + allowed_ips: ["10.0.5.0/24"] + binaries: + - { path: /usr/bin/curl } + private_network: + name: private_network + endpoints: + - port: 9443 + allowed_ips: ["172.16.0.0/12", "192.168.1.1"] + binaries: + - { path: /usr/bin/curl } + public_api: + name: public_api + endpoints: + - { host: api.github.com, port: 443 } + binaries: + - { path: /usr/bin/curl } + wildcard_api: + name: wildcard_api + endpoints: + - { host: "*.corp.net", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#; + +const ALLOWED_IPS_CEDAR: &str = r#" +@id("internal_api") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"my-service.corp.net:8080") +when { + context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.0.5.0/24")) +}; +@id("private_network") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.port == 9443 + && context.binary_path == "/usr/bin/curl" + && context has destination_ip + && (context.destination_ip.isInRange(ip("172.16.0.0/12")) + || context.destination_ip.isInRange(ip("192.168.1.1"))) +}; +@id("public_api") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { context.binary_path == "/usr/bin/curl" }; +@id("wildcard_api") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.corp.net", ".") + && resource.port == 443 + && context.binary_path == "/usr/bin/curl" +}; +"#; + +/// `allowed_ips_mode*`, `exact_declared_endpoint_host_*_allowed_ips`, and +/// `egress_authorization_returns_one_generation_consistent_snapshot`. +/// +/// The YAML tests read the matched endpoint's `allowed_ips` list; here the +/// destination cases check what that list admits. The snapshot test's +/// policy name, endpoint index, and generation have no Cedar counterpart +/// beyond the matched record and the decision, which are compared. +#[test] +fn ported_allowed_ips_modes() { + const MODE1: &str = "opa.rs::allowed_ips_mode1_no_ips_returns_empty"; + const MODE2: &str = "opa.rs::allowed_ips_mode2_host_plus_ips_allows"; + const MODE2_IPS: &str = "opa.rs::allowed_ips_mode2_returns_allowed_ips"; + const MODE3: &str = "opa.rs::allowed_ips_mode3_hostless_allows_any_domain"; + const MODE3_IPS: &str = "opa.rs::allowed_ips_mode3_returns_allowed_ips"; + const MODE3_PORT: &str = "opa.rs::allowed_ips_mode3_wrong_port_denied"; + const EXACT_IPS: &str = "opa.rs::exact_declared_endpoint_host_true_for_host_with_allowed_ips"; + const HOSTLESS: &str = "opa.rs::exact_declared_endpoint_host_false_for_hostless_allowed_ips"; + const SNAPSHOT: &str = + "opa.rs::egress_authorization_returns_one_generation_consistent_snapshot"; + let service = || network("my-service.corp.net", 8080, BINARY); + let anything = || network("anything.example.com", 9443, BINARY); + assert_parity(&Scenario { + name: "allowed_ips modes", + host: "my-service.corp.net", + yaml: ALLOWED_IPS_YAML, + cedar: ALLOWED_IPS_CEDAR, + cases: vec![ + // Mode 1: a host without allowed_ips is exactly declared, so it + // may resolve to public and private addresses. + same_probe( + format!("{MODE1} public"), + destination("api.github.com", 443, BINARY, &["140.82.112.5"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{MODE1} private"), + destination("api.github.com", 443, BINARY, &["10.1.2.3"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{MODE1} loopback"), + destination("api.github.com", 443, BINARY, &["127.0.0.1"]), + ) + .asserting_yaml("rejected"), + // Mode 2: host plus allowed_ips. + same_probe(format!("{MODE2} allow"), Probe::Connect(service())).asserting_yaml("allow"), + same_probe( + format!("{MODE2} other binary"), + Probe::Connect(network("my-service.corp.net", 8080, "/usr/bin/wget")), + ) + .asserting_yaml("deny"), + same_probe( + format!("{MODE2_IPS} inside"), + destination("my-service.corp.net", 8080, BINARY, &["10.0.5.9"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{MODE2_IPS} private outside"), + destination("my-service.corp.net", 8080, BINARY, &["10.0.6.1"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{MODE2_IPS} public outside"), + destination("my-service.corp.net", 8080, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{MODE2_IPS} one address outside"), + destination( + "my-service.corp.net", + 8080, + BINARY, + &["10.0.5.9", "10.0.6.1"], + ), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{MODE2_IPS} dns answers"), + dns_answers("my-service.corp.net", 8080, &["10.0.6.1", "10.0.5.7"]), + ) + .asserting_yaml("10.0.5.7"), + // Mode 3: host-less allowed_ips match any host on the port. + same_probe(format!("{MODE3} allow"), Probe::Connect(anything())) + .asserting_yaml("allow"), + same_probe( + format!("{MODE3} range"), + destination("anything.example.com", 9443, BINARY, &["172.20.1.1"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{MODE3_IPS} single address"), + destination("anything.example.com", 9443, BINARY, &["192.168.1.1"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{MODE3_IPS} next address"), + destination("anything.example.com", 9443, BINARY, &["192.168.1.2"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{MODE3_IPS} public"), + destination("anything.example.com", 9443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{MODE3_PORT} wrong port"), + Probe::Connect(network("anything.example.com", 12345, BINARY)), + ) + .asserting_yaml("deny"), + same_probe( + format!("{MODE3} not policy DNS eligible"), + dns("anything.example.com", 9443), + ) + .asserting_yaml("ineligible"), + same_probe( + format!("{MODE3} dns refused"), + dns_answers("anything.example.com", 9443, &["172.20.1.1"]), + ) + // An ineligible name gets only an observation mapping, which + // pins no address for any port. + .asserting_yaml("no mapping for port"), + // Exactly declared hosts. + same_probe(EXACT_IPS, Probe::ExactHost(service())).asserting_yaml("exact"), + same_probe(HOSTLESS, Probe::ExactHost(anything())).asserting_yaml("not-exact"), + // The wildcard host is neither exact nor allowed private addresses. + same_probe( + "ALLOWED_IPS_TEST_DATA wildcard private", + destination("api.corp.net", 443, BINARY, &["10.0.5.9"]), + ) + .asserting_yaml("rejected"), + same_probe( + "ALLOWED_IPS_TEST_DATA wildcard public", + destination("api.corp.net", 443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("admitted"), + // The atomic snapshot: allow, exact host, the matched record. + same_probe(format!("{SNAPSHOT} allow"), Probe::Connect(service())) + .asserting_yaml("allow"), + same_probe(format!("{SNAPSHOT} exact"), Probe::ExactHost(service())) + .asserting_yaml("exact"), + same_probe( + format!("{SNAPSHOT} matched"), + Probe::MatchedEndpoints(service()), + ) + .asserting_yaml("my-service.corp.net:8080"), + ], + }); +} + +// --------------------------------------------------------------------------- +// opa.rs::hostless_endpoint_multi_port +// --------------------------------------------------------------------------- + +#[test] +fn ported_hostless_endpoint_multi_port() { + const NAME: &str = "opa.rs::hostless_endpoint_multi_port"; + assert_parity(&Scenario { + name: "Host-less multi-port allowed_ips", + host: "anything.internal", + yaml: r#" +network_policies: + private: + name: private + endpoints: + - ports: [80, 443] + allowed_ips: ["10.0.0.0/8"] + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + [80, 443].contains(resource.port) + && context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.0.0.0/8")) +}; +"#, + cases: vec![ + same_probe( + format!("{NAME} port 80"), + Probe::Connect(network("anything.internal", 80, BINARY)), + ) + .asserting_yaml("allow"), + same_probe( + format!("{NAME} port 443"), + Probe::Connect(network("anything.internal", 443, BINARY)), + ) + .asserting_yaml("allow"), + same_probe( + format!("{NAME} unlisted port"), + Probe::Connect(network("anything.internal", 8080, BINARY)), + ) + .asserting_yaml("deny"), + same_probe( + format!("{NAME} in range"), + destination("anything.internal", 443, BINARY, &["10.9.8.7"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{NAME} out of range"), + destination("anything.internal", 443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{NAME} not exact"), + Probe::ExactHost(network("anything.internal", 80, BINARY)), + ) + .asserting_yaml("not-exact"), + ], + }); +} + +// --------------------------------------------------------------------------- +// opa.rs::allowed_ips_proto_round_trip +// --------------------------------------------------------------------------- + +/// The YAML test builds the policy as a proto, a YAML loader path; here +/// both lists' ranges are checked by what they admit. +#[test] +fn ported_allowed_ips_proto_round_trip() { + const NAME: &str = "opa.rs::allowed_ips_proto_round_trip"; + let policy = openshell_core::proto::SandboxPolicy { + network_policies: HashMap::from([( + "internal".to_string(), + openshell_core::proto::NetworkPolicyRule { + name: "internal".to_string(), + endpoints: vec![openshell_core::proto::NetworkEndpoint { + host: "internal.corp.net".to_string(), + port: 8080, + allowed_ips: vec!["10.0.5.0/24".to_string(), "10.0.6.0/24".to_string()], + ..Default::default() + }], + binaries: vec![openshell_core::proto::NetworkBinary { + path: BINARY.to_string(), + }], + }, + )]), + ..Default::default() + }; + let opa = OpaEngine::from_proto(&policy).expect("YAML proto loads"); + let cedar = CedarOnlyEngine::from_policy_str( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"internal.corp.net:8080") +when { + context.binary_path == "/usr/bin/curl" + && context has destination_ip + && (context.destination_ip.isInRange(ip("10.0.5.0/24")) + || context.destination_ip.isInRange(ip("10.0.6.0/24"))) +}; +"#, + ) + .expect("Cedar policy loads"); + let engines = Engines::new(opa, cedar); + let cases = [ + same_probe( + format!("{NAME} first range"), + destination("internal.corp.net", 8080, BINARY, &["10.0.5.3"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{NAME} second range"), + destination("internal.corp.net", 8080, BINARY, &["10.0.6.3"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{NAME} outside"), + destination("internal.corp.net", 8080, BINARY, &["10.0.7.3"]), + ) + .asserting_yaml("rejected"), + ]; + report_parity(run( + "allowed_ips proto round trip", + "internal.corp.net", + &engines, + &cases, + )); +} + +// --------------------------------------------------------------------------- +// opa.rs::credential_guard_does_not_shadow_inspected_endpoint_config +// --------------------------------------------------------------------------- + +/// `access: full` without `enforcement` is an audit-only `HttpRequest` +/// permit, as YAML defaults it to audit. The inspected endpoint's +/// `tls: skip` is an endpoint setting on the Cedar +/// side. The L4 endpoint's `provider_credentialed` and +/// `allow_uninspected_credentials` come only from provider endpoints on a +/// Cedar sandbox, so the credential-guard count is not ported; provider +/// credential guards are covered by the phase 1 ports. The YAML test's +/// point, that the L4 endpoint does not shadow the inspected endpoint's +/// inspection, TLS mode, and `allowed_ips`, is checked by decision. +#[test] +fn ported_credential_guard_does_not_shadow_allowed_ips() { + const NAME: &str = "opa.rs::credential_guard_does_not_shadow_inspected_endpoint_config"; + let input = || network("api.example.com", 443, BINARY); + assert_settings_parity(&SettingsScenario { + name: "Credential guard does not shadow allowed_ips", + host: "api.example.com", + yaml: r#" +network_policies: + telemetry_l4: + name: telemetry_l4 + endpoints: + - host: api.example.com + port: 443 + provider_credentialed: true + allow_uninspected_credentials: true + binaries: + - { path: /usr/bin/curl } + inspected_api: + name: inspected_api + endpoints: + - host: api.example.com + port: 443 + protocol: rest + access: full + tls: skip + allowed_ips: ["10.0.5.0/24"] + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +@id("telemetry_l4") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.binary_path == "/usr/bin/curl" }; +@id("inspected_api") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { + context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.0.5.0/24")) +}; +@protocol("rest") +@enforcement("audit") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.binary_path == "/usr/bin/curl" }; +"#, + settings: r" +endpoint_settings: + - host: api.example.com + port: 443 + tls: skip +", + cases: vec![ + same_probe(format!("{NAME} inspection"), Probe::Inspection(input())) + .asserting_yaml("Rest/Audit"), + same_probe(format!("{NAME} tls"), Probe::TlsMode(input())).asserting_yaml("Skip"), + same_probe( + format!("{NAME} allowed_ips inside"), + destination("api.example.com", 443, BINARY, &["10.0.5.3"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{NAME} allowed_ips private outside"), + destination("api.example.com", 443, BINARY, &["10.0.6.3"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{NAME} allowed_ips public outside"), + destination("api.example.com", 443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + ], + }); +} + +// --------------------------------------------------------------------------- +// opa.rs::overlapping_policy_outputs_are_snapshotted_independently +// --------------------------------------------------------------------------- + +/// `OVERLAPPING_L7_TEST_DATA` from `opa.rs`: two policies on one IP literal +/// endpoint, the second with `tls: skip` and `allowed_ips`. The YAML test +/// asserts the per-config lists and the matched policy name; here the TLS +/// mode, the two matched records, the exact host, and the admitted address +/// are compared. The second endpoint's `tls: skip` is an endpoint setting. +#[test] +fn ported_overlapping_policy_outputs() { + const NAME: &str = "opa.rs::overlapping_policy_outputs_are_snapshotted_independently"; + let input = || network("192.168.1.100", 8567, BINARY); + assert_settings_parity(&SettingsScenario { + name: "Overlapping policy outputs", + host: "192.168.1.100", + yaml: r#" +network_policies: + test_server: + name: test_server + endpoints: + - host: 192.168.1.100 + port: 8567 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "**" + binaries: + - { path: /usr/bin/curl } + allow_192_168_1_100_8567: + name: allow_192_168_1_100_8567 + endpoints: + - host: 192.168.1.100 + port: 8567 + protocol: rest + enforcement: enforce + tls: skip + allowed_ips: + - 192.168.1.100 + rules: + - allow: + method: GET + path: "**" + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"192.168.1.100:8567") +when { context.binary_path == "/usr/bin/curl" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"192.168.1.100:8567") +when { + context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("192.168.1.100")) +}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"192.168.1.100:8567") +when { context.binary_path == "/usr/bin/curl" && context.method == "GET" }; +"#, + settings: r" +endpoint_settings: + - host: 192.168.1.100 + port: 8567 + tls: skip +", + cases: vec![ + same_probe(format!("{NAME} allow"), Probe::Connect(input())).asserting_yaml("allow"), + same_probe(format!("{NAME} matched"), Probe::MatchedEndpoints(input())) + .asserting_yaml("192.168.1.100:8567;192.168.1.100:8567"), + same_probe(format!("{NAME} tls"), Probe::TlsMode(input())).asserting_yaml("Skip"), + same_probe(format!("{NAME} exact"), Probe::ExactHost(input())).asserting_yaml("exact"), + same_probe( + format!("{NAME} allowed_ips"), + destination("192.168.1.100", 8567, BINARY, &["192.168.1.100"]), + ) + .asserting_yaml("admitted"), + ], + }); +} + +// --------------------------------------------------------------------------- +// proxy.rs::supplied_identity_preserves_authorized_endpoint_metadata +// --------------------------------------------------------------------------- + +/// The supplied-identity path authorizes through the same engine call for +/// both formats. `request_body_credential_rewrite` comes only from provider +/// endpoints on a Cedar sandbox and is not ported; the endpoint's +/// `allowed_ips` and inspection are. +#[test] +fn ported_supplied_identity_endpoint_metadata() { + const NAME: &str = "proxy.rs::supplied_identity_preserves_authorized_endpoint_metadata"; + const PYTHON: &str = "/usr/bin/python3"; + assert_parity(&Scenario { + name: "Supplied identity endpoint metadata", + host: "api.example.com", + yaml: r#" +network_policies: + inspected: + name: inspected + endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + allowed_ips: ["192.0.2.0/24"] + rules: + - allow: { method: GET, path: /allowed } + binaries: + - path: /usr/bin/python3 +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { + context.binary_path == "/usr/bin/python3" + && context has destination_ip + && context.destination_ip.isInRange(ip("192.0.2.0/24")) +}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { + context.binary_path == "/usr/bin/python3" + && ["GET", "HEAD"].contains(context.method) + && context.path == "/allowed" +}; +"#, + cases: vec![ + same_probe( + format!("{NAME} inspection"), + Probe::Inspection(network("api.example.com", 443, PYTHON)), + ) + .asserting_yaml("Rest/Enforce"), + same_probe( + format!("{NAME} allowed_ips inside"), + destination("api.example.com", 443, PYTHON, &["192.0.2.10"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{NAME} allowed_ips outside"), + destination("api.example.com", 443, PYTHON, &["198.51.100.10"]), + ) + .asserting_yaml("rejected"), + ], + }); +} + +// --------------------------------------------------------------------------- +// proxy.rs address checks with allowlists +// --------------------------------------------------------------------------- + +/// The proxy's `allowed_ips` address-check tests, through a policy whose +/// endpoints carry the allowlists the tests build. +/// +/// - `curl` has a host-less allowlist on every port the tests use. +/// - `wget` has a host-less allowlist that excludes `10.0.0.1`. +/// - `python3` has a host glob with an allowlist and `node` the same glob +/// without one, for the hosts-file test: `/etc/hosts` is only where the +/// private address came from, so the probe supplies it directly. The +/// original's `searxng.local` becomes `searxng.home.test` so the host glob +/// is a valid YAML wildcard host. +#[test] +fn ported_proxy_allowlist_address_checks() { + const PYTHON: &str = "/usr/bin/python3"; + const NODE: &str = "/usr/bin/node"; + const WGET: &str = "/usr/bin/wget"; + const LOOPBACK: &str = "proxy.rs::test_resolve_check_allowed_ips_blocks_loopback"; + const METADATA: &str = "proxy.rs::test_resolve_check_allowed_ips_blocks_metadata"; + const UNSPECIFIED: &str = "proxy.rs::test_resolve_check_allowed_ips_blocks_unspecified"; + const OUTSIDE: &str = "proxy.rs::test_resolve_check_allowed_ips_rejects_outside_allowlist"; + const CONTROL: &str = "proxy.rs::test_resolve_check_allowed_ips_blocks_control_plane_ports"; + const ALLOWED: &str = "proxy.rs::test_resolve_check_allowed_ips_allows_non_control_plane_ports"; + const FORWARD_ACCEPTED: &str = "proxy.rs::test_forward_private_ip_accepted_with_allowed_ips"; + const FORWARD_WRONG: &str = "proxy.rs::test_forward_private_ip_rejected_with_wrong_allowed_ips"; + const FORWARD_LOOPBACK: &str = + "proxy.rs::test_forward_loopback_always_blocked_even_with_allowed_ips"; + const FORWARD_LINK_LOCAL: &str = + "proxy.rs::test_forward_link_local_always_blocked_even_with_allowed_ips"; + const HOSTS_FILE: &str = + "proxy.rs::test_resolve_from_hosts_file_contents_private_ip_requires_allowed_ips"; + assert_parity(&Scenario { + name: "Proxy allowlist address checks", + host: "dns.google", + yaml: r#" +network_policies: + allowlisted: + name: allowlisted + endpoints: + - ports: [80, 443, 2379, 6443, 10250] + allowed_ips: ["10.0.0.0/8", "8.8.8.0/24"] + binaries: + - { path: /usr/bin/curl } + wrong_range: + name: wrong_range + endpoints: + - ports: [80] + allowed_ips: ["192.168.0.0/16"] + binaries: + - { path: /usr/bin/wget } + hosts_file_allowlisted: + name: hosts_file_allowlisted + endpoints: + - host: "*.home.test" + port: 8080 + allowed_ips: ["192.168.1.105/32"] + binaries: + - { path: /usr/bin/python3 } + hosts_file_default: + name: hosts_file_default + endpoints: + - { host: "*.home.test", port: 8080 } + binaries: + - { path: /usr/bin/node } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + [80, 443, 2379, 6443, 10250].contains(resource.port) + && context.binary_path == "/usr/bin/curl" + && context has destination_ip + && (context.destination_ip.isInRange(ip("10.0.0.0/8")) + || context.destination_ip.isInRange(ip("8.8.8.0/24"))) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.port == 80 + && context.binary_path == "/usr/bin/wget" + && context has destination_ip + && context.destination_ip.isInRange(ip("192.168.0.0/16")) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.home.test", ".") + && resource.port == 8080 + && context.binary_path == "/usr/bin/python3" + && context has destination_ip + && context.destination_ip.isInRange(ip("192.168.1.105/32")) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.home.test", ".") + && resource.port == 8080 + && context.binary_path == "/usr/bin/node" +}; +"#, + cases: vec![ + same_probe( + LOOPBACK, + destination("127.0.0.1", 80, BINARY, &["127.0.0.1"]), + ) + .asserting_yaml("rejected"), + same_probe( + METADATA, + destination("169.254.169.254", 80, BINARY, &["169.254.169.254"]), + ) + .asserting_yaml("rejected"), + same_probe( + UNSPECIFIED, + destination("0.0.0.0", 80, BINARY, &["0.0.0.0"]), + ) + .asserting_yaml("rejected"), + same_probe( + FORWARD_LOOPBACK, + destination("loopback.example", 80, BINARY, &["127.0.0.1"]), + ) + .asserting_yaml("rejected"), + same_probe( + FORWARD_LINK_LOCAL, + destination("metadata.example", 80, BINARY, &["169.254.169.254"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{OUTSIDE} public"), + destination("dns.google", 443, BINARY, &["1.1.1.1"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{OUTSIDE} one of two"), + destination("dns.google", 443, BINARY, &["8.8.8.8", "1.1.1.1"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{CONTROL} 6443"), + destination("8.8.8.8", 6443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{CONTROL} 2379"), + destination("8.8.8.8", 2379, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{CONTROL} 10250"), + destination("8.8.8.8", 10250, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + same_probe(ALLOWED, destination("8.8.8.8", 443, BINARY, &["8.8.8.8"])) + .asserting_yaml("admitted"), + same_probe( + format!("{ALLOWED} by name"), + destination("dns.google", 443, BINARY, &["8.8.8.8", "8.8.8.4"]), + ) + .asserting_yaml("admitted"), + same_probe( + FORWARD_ACCEPTED, + destination("10.0.0.1", 80, BINARY, &["10.0.0.1"]), + ) + .asserting_yaml("admitted"), + same_probe( + FORWARD_WRONG, + destination("10.0.0.1", 80, WGET, &["10.0.0.1"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{FORWARD_WRONG} control"), + destination("192.168.3.4", 80, WGET, &["192.168.3.4"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{HOSTS_FILE} default"), + destination("searxng.home.test", 8080, NODE, &["192.168.1.105"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{HOSTS_FILE} default public control"), + destination("searxng.home.test", 8080, NODE, &["8.8.8.8"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{HOSTS_FILE} allowlisted"), + destination("searxng.home.test", 8080, PYTHON, &["192.168.1.105"]), + ) + .asserting_yaml("admitted"), + ], + }); +} + +// --------------------------------------------------------------------------- +// policy_dns/mod.rs and proxy/destination.rs answer filtering +// --------------------------------------------------------------------------- + +/// `BASE_POLICY` from `policy_dns/mod.rs` with `allowed_ips`, and the +/// address filter test from `proxy/destination.rs` through policy DNS. Each +/// engine's policy DNS filters the trusted resolver's answers and pins the +/// rest. +#[test] +#[cfg_attr( + target_os = "windows", + ignore = "transparent TCP opens are not supported on Windows, so the YAML side differs too" +)] +fn ported_policy_dns_allowed_ips_filtering() { + const DNS: &str = + "policy_dns/mod.rs::allowed_ips_filters_each_answer_without_rejecting_usable_addresses"; + const FILTER: &str = "proxy/destination.rs::address_filter_enforces_allowed_ips"; + const PSQL: &str = "/usr/bin/psql"; + let open = |binary: &str, answers: &[&str]| Probe::TransparentOpen { + name: "db.example".to_string(), + port: 5432, + binary: binary.to_string(), + answers: ips(answers), + }; + assert_parity(&Scenario { + name: "Policy DNS allowed_ips filtering", + host: "db.example", + yaml: r" +network_policies: + database: + name: database + endpoints: + - { host: db.example, port: 5432, protocol: tcp, allowed_ips: [10.2.0.0/16] } + binaries: [{ path: /usr/bin/psql }] + allowlisted: + name: allowlisted + endpoints: + - { host: allowlisted.example, port: 443, allowed_ips: [10.2.0.0/16] } + binaries: [{ path: /usr/bin/curl }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +", + cedar: r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example:5432") +when { + context.binary_path == "/usr/bin/psql" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.2.0.0/16")) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"allowlisted.example:443") +when { + context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.2.0.0/16")) +}; +"#, + cases: vec![ + same_probe( + DNS, + dns_answers("db.example", 5432, &["10.3.4.5", "10.2.3.4"]), + ) + .asserting_yaml("10.2.3.4"), + same_probe( + format!("{DNS} no usable answer"), + dns_answers("db.example", 5432, &["10.3.4.5"]), + ) + .asserting_yaml( + "dns refused: no trusted resolver address passed endpoint destination policy", + ), + same_probe( + format!("{DNS} transparent open"), + open(PSQL, &["10.3.4.5", "10.2.3.4"]), + ) + .asserting_yaml("RelayReady/10.2.3.4:5432/correlated"), + same_probe( + format!("{DNS} other binary"), + open(BINARY, &["10.3.4.5", "10.2.3.4"]), + ) + .asserting_yaml("Denied(PolicyDenied)"), + same_probe( + FILTER, + dns_answers("allowlisted.example", 443, &["10.3.4.5", "10.2.3.4"]), + ) + .asserting_yaml("10.2.3.4"), + ], + }); +} + +// --------------------------------------------------------------------------- +// proxy/destination.rs::validation_mode_precedence_is_explicit_and_stable +// --------------------------------------------------------------------------- + +/// The mode precedence below the host gateway aliases, by what each mode +/// admits: `allowed_ips` over an exactly declared host, an IP literal host, +/// an exactly declared host, and the public-only default for a glob. The +/// host gateway modes take precedence the same way for both formats and are +/// not reached without a gateway; a Cedar sandbox also requires Cedar to +/// allow the gateway address (`proxy/destination.rs` unit tests). +#[test] +fn ported_validation_mode_precedence() { + const NAME: &str = "proxy/destination.rs::validation_mode_precedence_is_explicit_and_stable"; + assert_parity(&Scenario { + name: "Destination mode precedence", + host: "private.example", + yaml: r#" +network_policies: + modes: + name: modes + endpoints: + - { host: explicit.example, port: 443, allowed_ips: ["10.0.0.0/8"] } + - { host: private.example, port: 443 } + - { host: 10.2.3.4, port: 443 } + - { host: "*.glob.example", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"explicit.example:443") +when { + context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.0.0.0/8")) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"private.example:443") +when { context.binary_path == "/usr/bin/curl" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"10.2.3.4:443") +when { context.binary_path == "/usr/bin/curl" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.glob.example", ".") + && resource.port == 443 + && context.binary_path == "/usr/bin/curl" +}; +"#, + cases: vec![ + same_probe( + format!("{NAME} explicit private"), + destination("explicit.example", 443, BINARY, &["10.1.1.1"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{NAME} explicit over exact"), + destination("explicit.example", 443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{NAME} implicit literal"), + destination("10.2.3.4", 443, BINARY, &["10.2.3.4"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{NAME} declared private"), + destination("private.example", 443, BINARY, &["10.1.1.1"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{NAME} declared public"), + destination("private.example", 443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("admitted"), + same_probe( + format!("{NAME} default private"), + destination("a.glob.example", 443, BINARY, &["10.1.1.1"]), + ) + .asserting_yaml("rejected"), + same_probe( + format!("{NAME} default public"), + destination("a.glob.example", 443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("admitted"), + ], + }); +} + +// --------------------------------------------------------------------------- +// Overlapping endpoints (not a ported test) +// --------------------------------------------------------------------------- + +/// Why Cedar rejects a private address YAML admits for overlapping endpoints. +const FIRST_CONFIG: &str = "YAML checks addresses against the first matching endpoint that has \ + extended config, so a host-less allowed_ips endpoint decides for a glob host whose own \ + endpoint has none; Cedar does not depend on that order and admits a private address only \ + when every allowing permit restricts the address or one names the host exactly"; + +/// A glob endpoint and a host-less `allowed_ips` endpoint on the same port. +/// +/// YAML picks the address mode from the first matching endpoint with +/// extended config, an order Cedar cannot reproduce, so Cedar applies the +/// strictest mode of every allowing permit. Both reject a public address +/// outside the allowlist; for a private address inside it Cedar is stricter. +#[test] +fn overlapping_endpoints_never_widen_address_admission() { + assert_parity(&Scenario { + name: "Overlapping glob and host-less allowed_ips", + host: "svc.corp.example", + yaml: r#" +network_policies: + glob: + name: glob + endpoints: + - { host: "*.corp.example", port: 443 } + binaries: + - { path: /usr/bin/curl } + hostless: + name: hostless + endpoints: + - ports: [443] + allowed_ips: ["10.0.0.0/8"] + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.corp.example", ".") + && resource.port == 443 + && context.binary_path == "/usr/bin/curl" +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.port == 443 + && context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.0.0.0/8")) +}; +"#, + cases: vec![ + same_probe( + "overlap public outside allowlist", + destination("svc.corp.example", 443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + diverges_probe( + "overlap private inside allowlist", + destination("svc.corp.example", 443, BINARY, &["10.1.1.1"]), + FIRST_CONFIG, + ) + .asserting_yaml("admitted"), + same_probe( + "overlap host-less only", + destination("other.example", 443, BINARY, &["10.1.1.1"]), + ) + .asserting_yaml("admitted"), + ], + }); +} + +// --------------------------------------------------------------------------- +// Provider allowed_ips (not a ported test) +// --------------------------------------------------------------------------- + +/// Why Cedar rejects a private address a YAML provider endpoint admits. +const PROVIDERS_GRANT_NO_ACCESS: &str = "a YAML sandbox enforces provider rules as network \ + policies, so a provider endpoint's allowed_ips admit private addresses; on a Cedar sandbox \ + providers grant no access, so their allowed_ips only narrow what the Cedar policy admits, \ + and a glob permit stays public-only"; + +/// One provider rule for curl to `api.corp.example:443` within `10.5.0.0/16`. +fn ranged_provider_rule() -> openshell_core::proto::NetworkPolicyRule { + openshell_core::proto::NetworkPolicyRule { + name: "_provider_corp".to_string(), + endpoints: vec![openshell_core::proto::NetworkEndpoint { + host: "api.corp.example".to_string(), + port: 443, + allowed_ips: vec!["10.5.0.0/16".to_string()], + provider_credentialed: true, + ..Default::default() + }], + binaries: vec![openshell_core::proto::NetworkBinary { + path: BINARY.to_string(), + }], + } +} + +/// A credentialed provider endpoint's `allowed_ips` restrict the addresses +/// its connections reach on both formats, when Cedar names the host exactly. +#[test] +fn provider_allowed_ips_restrict_credentialed_connections() { + assert_provider_parity(&ProviderScenario { + name: "Provider allowed_ips with an exact Cedar permit", + host: "api.corp.example", + rules: vec![ranged_provider_rule()], + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.corp.example:443") +when { context.binary_path == "/usr/bin/curl" }; +"#, + cases: vec![ + same_probe( + "provider range inside", + destination("api.corp.example", 443, BINARY, &["10.5.0.1"]), + ) + .asserting_yaml("admitted"), + same_probe( + "provider range outside private", + destination("api.corp.example", 443, BINARY, &["10.6.0.1"]), + ) + .asserting_yaml("rejected"), + same_probe( + "provider range outside public", + destination("api.corp.example", 443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + same_probe( + "provider range mixed answers", + destination("api.corp.example", 443, BINARY, &["10.5.0.1", "10.6.0.1"]), + ) + .asserting_yaml("rejected"), + ], + }); +} + +/// Provider `allowed_ips` never admit a private address a Cedar glob permit +/// rejects; outside the range both formats reject. +#[test] +fn provider_allowed_ips_never_widen_cedar_admission() { + assert_provider_parity(&ProviderScenario { + name: "Provider allowed_ips with a glob Cedar permit", + host: "api.corp.example", + rules: vec![ranged_provider_rule()], + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.corp.example", ".") + && resource.port == 443 + && context.binary_path == "/usr/bin/curl" +}; +"#, + cases: vec![ + diverges_probe( + "provider range inside, glob permit", + destination("api.corp.example", 443, BINARY, &["10.5.0.1"]), + PROVIDERS_GRANT_NO_ACCESS, + ) + .asserting_yaml("admitted"), + same_probe( + "provider range outside, glob permit", + destination("api.corp.example", 443, BINARY, &["8.8.8.8"]), + ) + .asserting_yaml("rejected"), + ], + }); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase7.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase7.rs new file mode 100644 index 0000000000..709dacc4f3 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_phase7.rs @@ -0,0 +1,376 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML tests for symlinked binary paths (Cedar parity plan, phase 7). +//! +//! Once the entrypoint process is known, YAML resolves each exact binary path +//! through `/proc//root` and adds a symlink's target as another binary +//! entry, which matches the process's binary or an ancestor. A Cedar policy +//! reads the same expansion as `context.binary_aliases`, so an exact YAML +//! binary `B` is translated as +//! `context.binary_path == B || context.ancestors.contains(B) || +//! context.binary_aliases.contains(B)`. A glob binary is never resolved by +//! either format. +//! +//! Like the originals, these tests resolve symlinks in a temporary directory +//! through this test process's own `/proc//root`, so they run on Linux +//! only and skip where that root is not traversable. Both engines resolve +//! either at load (YAML `from_proto_with_pid`, Cedar +//! `resolve_binary_symlinks`) or on a reload with the pid (YAML +//! `reload_from_proto_with_pid`, Cedar `stage` with the pid and `commit`). + +use std::os::unix::fs::symlink; + +use super::*; + +/// The Cedar condition for a YAML binary entry with an exact path. +fn binary(path: &str) -> String { + format!( + "(context.binary_path == \"{path}\" || context.ancestors.contains(\"{path}\") \ + || context.binary_aliases.contains(\"{path}\"))" + ) +} + +/// The Cedar condition for a YAML binary entry with a glob path. +fn binary_glob(pattern: &str) -> String { + format!("context.binary_path like(\"{pattern}\", \"/\")") +} + +/// A symlink `link` to the file `target`, in temporary directories that live +/// as long as this value. +struct Symlink { + _dirs: Vec, + link: String, + target: String, +} + +impl Symlink { + /// Creates `link_name` in one directory pointing at `target_name` in + /// `target_dir`, or in the same directory when `target_dir` is `false`. + /// + /// Returns `None` where `/proc//root` does not resolve it, as the + /// originals skip. + fn new(link_name: &str, target_name: &str, separate_dirs: bool) -> Option { + let link_dir = tempfile::tempdir().expect("tempdir"); + let target_dir = separate_dirs.then(|| tempfile::tempdir().expect("tempdir")); + let target = target_dir + .as_ref() + .unwrap_or(&link_dir) + .path() + .join(target_name); + let link = link_dir.path().join(link_name); + std::fs::write(&target, b"binary").expect("target"); + symlink(&target, &link).expect("symlink"); + let symlink = Self { + link: link.to_string_lossy().into_owned(), + target: target.to_string_lossy().into_owned(), + _dirs: std::iter::once(link_dir).chain(target_dir).collect(), + }; + if crate::binary_resolution::resolve_policy_binary(&symlink.link, std::process::id()) + .is_none() + { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return None; + } + Some(symlink) + } + + /// The directory holding the link. + fn link_dir(&self) -> &str { + self.link + .rsplit_once('/') + .map_or("", |(directory, _)| directory) + } +} + +/// When the engines learn the entrypoint process. +enum Resolution { + /// At load. + AtLoad, + /// On a reload of the same policy; `before` runs first, on engines loaded + /// without it. + OnReload { before: Vec }, +} + +/// Runs `scenario` with both engines resolving binary symlinks in this test +/// process's root filesystem. +fn assert_symlink_parity(scenario: &Scenario<'_>, resolution: Resolution) { + let pid = std::process::id(); + let yaml = openshell_policy::parse_sandbox_policy(scenario.yaml) + .unwrap_or_else(|error| panic!("{}: YAML policy parses: {error:?}", scenario.name)); + let cedar = openshell_core::proto::SandboxPolicy { + cedar_policy_source: scenario.cedar.to_string(), + ..Default::default() + }; + let load = |pid: u32| { + let opa = OpaEngine::from_proto_with_pid(&yaml, pid) + .unwrap_or_else(|error| panic!("{}: YAML policy loads: {error}", scenario.name)); + let cedar_engine = CedarOnlyEngine::from_proto(&cedar) + .unwrap_or_else(|error| panic!("{}: Cedar policy loads: {error}", scenario.name)); + cedar_engine + .resolve_binary_symlinks(pid) + .unwrap_or_else(|error| panic!("{}: Cedar resolves: {error}", scenario.name)); + Engines::new(opa, cedar_engine) + }; + match resolution { + Resolution::AtLoad => { + let engines = load(pid); + report_parity(run(scenario.name, scenario.host, &engines, &scenario.cases)); + } + Resolution::OnReload { before } => { + let engines = load(0); + let startup = format!("{} (before pid)", scenario.name); + let (mut report, mut failures) = run(&startup, scenario.host, &engines, &before); + engines + .opa + .reload_from_proto_with_pid(&yaml, pid) + .unwrap_or_else(|error| panic!("{}: YAML policy reloads: {error}", scenario.name)); + let staged = engines + .cedar + .stage(&cedar, pid) + .unwrap_or_else(|error| panic!("{}: Cedar policy stages: {error}", scenario.name)); + engines + .cedar + .commit(staged) + .unwrap_or_else(|error| panic!("{}: Cedar policy commits: {error}", scenario.name)); + let reload = format!("{} (reload with pid)", scenario.name); + let (reloaded, reload_failures) = + run(&reload, scenario.host, &engines, &scenario.cases); + report.push_str(&reloaded); + failures.extend(reload_failures); + report_parity((report, failures)); + } + } +} + +/// A connection-only YAML policy for `host:443` and the binary `path`, and +/// its Cedar translation. +fn connect_policy(name: &str, host: &str, path: &str) -> (String, String) { + let yaml = format!( + r#" +version: 1 +network_policies: + {name}: + name: {name} + endpoints: + - host: {host} + port: 443 + binaries: + - path: "{path}" +"# + ); + let cedar = format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{host}:443") +when {{ {} }}; +"#, + binary(path) + ); + (yaml, cedar) +} + +// --------------------------------------------------------------------------- +// opa.rs::from_proto_with_pid_expands_symlinks_in_container +// --------------------------------------------------------------------------- + +#[test] +#[cfg_attr( + not(target_os = "linux"), + ignore = "resolves symlinks through /proc//root, which only Linux provides" +)] +fn ported_from_proto_with_pid_expands_symlinks() { + const NAME: &str = "opa.rs::from_proto_with_pid_expands_symlinks_in_container"; + let Some(node) = Symlink::new("node", "node22", false) else { + return; + }; + let (yaml, cedar) = connect_policy("test", "example.com", &node.link); + let connect = |binary: &str, ancestors: &[&str]| { + Probe::Connect(network_with_ancestors( + "example.com", + 443, + binary, + ancestors, + )) + }; + assert_symlink_parity( + &Scenario { + name: "Symlinked binary resolved at load", + host: "example.com", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe(format!("{NAME} target"), connect(&node.target, &[])) + .asserting_yaml("allow"), + same_probe(format!("{NAME} link"), connect(&node.link, &[])), + // The added entry matches an ancestor like any exact entry. + same_probe( + format!("{NAME} target as ancestor"), + connect(BINARY, &[&node.target]), + ) + .asserting_yaml("allow"), + same_probe(format!("{NAME} other binary"), connect(BINARY, &[])) + .asserting_yaml("deny"), + ], + }, + Resolution::AtLoad, + ); +} + +// --------------------------------------------------------------------------- +// opa.rs::reload_from_proto_with_pid_resolves_symlinks +// --------------------------------------------------------------------------- + +#[test] +#[cfg_attr( + not(target_os = "linux"), + ignore = "resolves symlinks through /proc//root, which only Linux provides" +)] +fn ported_reload_with_pid_resolves_symlinks() { + const NAME: &str = "opa.rs::reload_from_proto_with_pid_resolves_symlinks"; + let Some(python) = Symlink::new("python3", "python3.11", false) else { + return; + }; + let (yaml, cedar) = connect_policy("python", "pypi.org", &python.link); + let connect = |binary: &str| Probe::Connect(network("pypi.org", 443, binary)); + assert_symlink_parity( + &Scenario { + name: "Symlinked binary resolved on reload", + host: "pypi.org", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe(format!("{NAME} target after"), connect(&python.target)) + .asserting_yaml("allow"), + same_probe(format!("{NAME} link after"), connect(&python.link)) + .asserting_yaml("allow"), + ], + }, + Resolution::OnReload { + before: vec![ + same_probe(format!("{NAME} target before"), connect(&python.target)) + .asserting_yaml("deny"), + same_probe(format!("{NAME} link before"), connect(&python.link)) + .asserting_yaml("allow"), + ], + }, + ); +} + +// --------------------------------------------------------------------------- +// opa.rs::exact_deny_symlink_expands_but_glob_deny_does_not +// --------------------------------------------------------------------------- + +/// The original's policy, with the `deny` policy's binary `deny_binary`, and +/// its Cedar translation with the `deny` binary condition `deny_condition`. +fn deny_policy(target: &str, deny_binary: &str, deny_condition: &str) -> (String, String) { + let yaml = format!( + r#" +version: 1 +network_policies: + grant: + endpoints: + - host: example.com + port: 443 + protocol: rest + enforcement: enforce + rules: [{{ allow: {{ method: GET, path: "/**" }} }}] + binaries: [{{ path: "{target}" }}] + deny: + endpoints: + - host: example.com + port: 443 + protocol: rest + enforcement: enforce + rules: [{{ allow: {{ method: GET, path: "/**" }} }}] + deny_rules: [{{ method: "*", path: "/**" }}] + binaries: [{{ path: "{deny_binary}" }}] +filesystem_policy: + include_workdir: false + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"# + ); + let grant = binary(target); + let cedar = format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"example.com:443") +when {{ {grant} || {deny_condition} }}; +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"example.com:443") +when {{ + ({grant} || {deny_condition}) + && ["GET", "HEAD"].contains(context.method) + && context.path like("/**", "/") +}}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"example.com:443") +when {{ {deny_condition} && context.path like("/**", "/") }}; +"# + ); + (yaml, cedar) +} + +#[test] +#[cfg_attr( + not(target_os = "linux"), + ignore = "resolves symlinks through /proc//root, which only Linux provides" +)] +fn ported_exact_deny_symlink_expands_but_glob_deny_does_not() { + const NAME: &str = "opa.rs::exact_deny_symlink_expands_but_glob_deny_does_not"; + let Some(python) = Symlink::new("python", "python3", true) else { + return; + }; + let get_from = |binary: &str| request_in(l7_ctx("example.com", 443, binary), rest("GET", "/")); + + // The maximum policy denies through the link, which resolves to the + // target the grant names. + let (yaml, cedar) = deny_policy(&python.target, &python.link, &binary(&python.link)); + assert_symlink_parity( + &Scenario { + name: "Exact deny binary through a symlink", + host: "example.com", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe(format!("{NAME} maximum"), get_from(&python.target)) + .asserting_yaml("deny"), + same_probe( + format!("{NAME} maximum connect"), + Probe::Connect(network("example.com", 443, &python.target)), + ) + .asserting_yaml("allow"), + same_probe(format!("{NAME} maximum other binary"), get_from(BINARY)) + .asserting_yaml("deny"), + ], + }, + Resolution::AtLoad, + ); + + // The candidate policy's glob covers the link's directory, not the + // target's, and is never resolved. + let glob = format!("{}/*", python.link_dir()); + let (yaml, cedar) = deny_policy(&python.target, &glob, &binary_glob(&glob)); + assert_symlink_parity( + &Scenario { + name: "Glob deny binary over a symlink's directory", + host: "example.com", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe(format!("{NAME} candidate"), get_from(&python.target)) + .asserting_yaml("allow"), + same_probe(format!("{NAME} candidate link"), get_from(&python.link)) + .asserting_yaml("deny"), + ], + }, + Resolution::AtLoad, + ); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_proxy_dns.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_proxy_dns.rs new file mode 100644 index 0000000000..72bc1c3770 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_proxy_dns.rs @@ -0,0 +1,644 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported proxy, destination, and policy DNS decision tests. +//! +//! Many of the original tests assert outcomes that come from fixed checks +//! applied after the policy decision: always-blocked loopback and link-local +//! addresses, blocked control-plane ports, binary digest pinning, trusted +//! gateway configuration, and the policy DNS store's observation records. +//! Those checks do not depend on the policy engine. These ports cover only the +//! policy-dependent part: whether the connection is allowed, whether its host +//! counts as exactly declared (which admits private addresses), and whether +//! policy DNS considers the name eligible. + +use super::*; + +/// A YAML and Cedar policy allowing `binary` to reach one exactly named +/// `host:port`, as the original tests' single-endpoint fixtures do. +fn single_exact_endpoint(host: &str, port: u16, binary: &str) -> (String, String) { + let yaml = format!( + r#" +network_policies: + allowed: + name: allowed + endpoints: + - {{ host: "{host}", port: {port} }} + binaries: + - {{ path: {binary} }} +"# + ); + let cedar = format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{host}:{port}") +when {{ context.binary_path == "{binary}" }}; +"# + ); + (yaml, cedar) +} + +/// Runs a scenario over [`single_exact_endpoint`]. +fn assert_single_exact_endpoint(name: &str, host: &str, port: u16, cases: Vec) { + let (yaml, cedar) = single_exact_endpoint(host, port, BINARY); + assert_parity(&Scenario { + name, + host, + yaml: &yaml, + cedar: &cedar, + cases, + }); +} + +/// The redactor rewrites the allowed JSON-RPC method to `[REDACTED]`; the +/// re-evaluation of the rewritten request must deny it. +#[test] +fn ported_jsonrpc_reevaluation_after_redaction() { + const NODE: &str = "/usr/bin/node"; + let ctx = || l7_ctx("api.example.test", 80, NODE); + let call_to = |method: &str| jsonrpc_request("/rpc", vec![call(method, None, None)]); + assert_parity(&Scenario { + name: "Forward middleware re-evaluation", + host: "api.example.test", + yaml: r" +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: api.example.test + port: 80 + path: /rpc + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: sk-ABCDEFGHIJKLMNOP + binaries: + - { path: /usr/bin/node } +", + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.test:80") +when { context.binary_path == "/usr/bin/node" }; +@protocol("json-rpc") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:80") +when { + context.binary_path == "/usr/bin/node" + && context.path == "/rpc" + && !context.jsonrpc_response + && context.jsonrpc_method == "sk-ABCDEFGHIJKLMNOP" +}; +"#, + cases: vec![ + same_probe( + "proxy.rs::forward_middleware_pipeline_denies_policy_invalid_transformation inspection", + Probe::Inspection(network("api.example.test", 80, NODE)), + ), + same_probe( + "proxy.rs::forward_middleware_pipeline_denies_policy_invalid_transformation original", + request_in(ctx(), call_to("sk-ABCDEFGHIJKLMNOP")), + ), + same_probe( + "proxy.rs::forward_middleware_pipeline_denies_policy_invalid_transformation redacted", + request_in(ctx(), call_to("[REDACTED]")), + ), + ], + }); +} + +/// `POLICY_DNS_OPEN_POLICY` from `proxy.rs`. The rejection of an +/// observation-only record as an invalid destination is a store check and is +/// not covered; the policy part is that pypi.org:80 is allowed and eligible +/// while unknown.example is denied and ineligible. +#[test] +fn ported_staged_transparent_open_policy_dns() { + assert_parity(&Scenario { + name: "Staged transparent open", + host: "pypi.org", + yaml: r" +network_policies: + database: + name: database + endpoints: + - { host: db.example, port: 5432, protocol: tcp } + binaries: [{ path: /usr/bin/curl }] + pypi: + name: pypi + endpoints: + - { host: pypi.org, port: 80 } + binaries: [{ path: /usr/bin/curl }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +", + cedar: r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example:5432") +when { context.binary_path == "/usr/bin/curl" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:80") +when { context.binary_path == "/usr/bin/curl" }; +"#, + cases: vec![ + same_probe( + "proxy.rs::staged_transparent_open_never_relays_an_observation_that_policy_allows connect", + Probe::Connect(network("pypi.org", 80, BINARY)), + ), + same_probe( + "proxy.rs::staged_transparent_open_never_relays_an_observation_that_policy_allows dns", + Probe::DnsEligible { + name: "pypi.org".to_string(), + port: 80, + }, + ), + same_probe( + "proxy.rs::staged_transparent_open_proposes_a_denied_observation_hostname connect", + Probe::Connect(network("unknown.example", 443, BINARY)), + ), + same_probe( + "proxy.rs::staged_transparent_open_proposes_a_denied_observation_hostname dns", + Probe::DnsEligible { + name: "unknown.example".to_string(), + port: 443, + }, + ), + ], + }); +} + +/// The metadata address is declared, so policy allows it; the proxy blocks it +/// afterwards as an invalid destination regardless of policy, which this +/// probe does not cover. +#[test] +fn ported_staged_transparent_open_waits_for_l4_policy() { + assert_parity(&Scenario { + name: "Transparent open L4 policy", + host: "203.0.113.7", + yaml: r" +network_policies: + allowed: + name: allowed + endpoints: + - host: 203.0.113.7 + port: 443 + - host: 169.254.169.254 + port: 80 + binaries: + - path: /usr/bin/curl +", + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"203.0.113.7:443") +when { context.binary_path == "/usr/bin/curl" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"169.254.169.254:80") +when { context.binary_path == "/usr/bin/curl" }; +"#, + cases: vec![ + same_probe( + "proxy.rs::staged_transparent_open_waits_for_l4_policy allowed", + Probe::Connect(network("203.0.113.7", 443, BINARY)), + ), + same_probe( + "proxy.rs::staged_transparent_open_waits_for_l4_policy metadata declared", + Probe::Connect(network("169.254.169.254", 80, BINARY)), + ), + same_probe( + "proxy.rs::staged_transparent_open_waits_for_l4_policy unlisted", + Probe::Connect(network("203.0.113.8", 443, BINARY)), + ), + ], + }); +} + +/// Digest pinning denies the replacement executable in both original tests; +/// pinning is engine-independent, so only the initial allow is compared. +#[test] +fn ported_supplied_identity_client() { + const CLIENT: &str = "/sandbox/bin/client"; + assert_parity(&Scenario { + name: "Supplied identity with L7 rules", + host: "api.example.com", + yaml: r" +network_policies: + credentialed: + name: credentialed + endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: /allowed } + binaries: + - path: /sandbox/bin/client +", + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.binary_path == "/sandbox/bin/client" }; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { + context.binary_path == "/sandbox/bin/client" + && ["GET", "HEAD"].contains(context.method) + && context.path == "/allowed" +}; +"#, + cases: vec![same_probe( + "proxy.rs::supplied_identity_rejects_same_path_replacement", + Probe::Connect(network("api.example.com", 443, CLIENT)), + )], + }); + + let (yaml, cedar) = single_exact_endpoint("api.example.com", 443, CLIENT); + assert_parity(&Scenario { + name: "Supplied identity", + host: "api.example.com", + yaml: &yaml, + cedar: &cedar, + cases: vec![same_probe( + "proxy.rs::supplied_identity_pin_survives_policy_reload", + Probe::Connect(network("api.example.com", 443, CLIENT)), + )], + }); +} + +/// The policy authorizes through the launcher ancestor; the ancestor digest +/// pin that later denies is engine-independent. +#[test] +fn ported_supplied_identity_ancestor() { + assert_parity(&Scenario { + name: "Supplied identity ancestor", + host: "api.example.com", + yaml: r" +network_policies: + allowed: + name: allowed + endpoints: + - host: api.example.com + port: 443 + binaries: + - path: /sandbox/bin/launcher +", + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { + context.binary_path == "/sandbox/bin/launcher" + || context.ancestors.contains("/sandbox/bin/launcher") +}; +"#, + cases: vec![ + same_probe( + "proxy.rs::supplied_identity_rejects_replaced_authorizing_ancestor", + Probe::Connect(network_with_ancestors( + "api.example.com", + 443, + "/sandbox/bin/client", + &["/sandbox/bin/launcher"], + )), + ), + same_probe( + "proxy.rs::supplied_identity_rejects_replaced_authorizing_ancestor no ancestor", + Probe::Connect(network("api.example.com", 443, "/sandbox/bin/client")), + ), + ], + }); +} + +/// The original checks that an exactly declared host may resolve to a +/// private hosts-file address; the policy part is the exact-host treatment. +#[test] +fn ported_declared_endpoint_private_hosts_file() { + assert_single_exact_endpoint( + "Declared endpoint hosts file", + "searxng.local", + 8080, + vec![same_probe( + "proxy.rs::test_declared_endpoint_private_hosts_file_resolution_allowed", + Probe::ExactHost(network("searxng.local", 8080, BINARY)), + )], + ); +} + +/// A declared loopback endpoint is exactly declared by policy; the SSRF 403 +/// comes from the always-blocked address check, which this probe does not +/// cover. +#[test] +fn ported_declared_loopback_is_exact() { + assert_single_exact_endpoint( + "Declared loopback 443", + "127.0.0.1", + 443, + vec![same_probe( + "proxy.rs::connect_handler_returns_ssrf_403_for_internal_address_not_503", + Probe::ExactHost(network("127.0.0.1", 443, BINARY)), + )], + ); + assert_single_exact_endpoint( + "Declared loopback 80", + "127.0.0.1", + 80, + vec![ + same_probe( + "proxy.rs::forward_handler_preserves_ssrf_response_and_denial_stage", + Probe::ExactHost(network("127.0.0.1", 80, BINARY)), + ), + same_probe( + "proxy/destination.rs::declared_endpoint_preserves_its_denial_classification", + Probe::ExactHost(network("127.0.0.1", 80, BINARY)), + ), + ], + ); +} + +/// The destination address filter tests take an `ExactDeclaredHost` plan; +/// the policy decides that mode. Dropping loopback answers and blocking +/// control-plane ports are fixed checks this probe does not cover. +#[test] +fn ported_address_filter_exact_hosts() { + assert_single_exact_endpoint( + "Address filter private", + "private.example", + 443, + vec![same_probe( + "proxy/destination.rs::address_filter_exact_host_allows_private_but_not_always_blocked", + Probe::ExactHost(network("private.example", 443, BINARY)), + )], + ); + assert_single_exact_endpoint( + "Address filter loopback", + "loopback.example", + 443, + vec![same_probe( + "proxy/destination.rs::address_filter_rejects_always_blocked_only_answer", + Probe::ExactHost(network("loopback.example", 443, BINARY)), + )], + ); + assert_single_exact_endpoint( + "Address filter control plane", + "api.example", + 6443, + vec![same_probe( + "proxy/destination.rs::address_filter_rejects_control_plane_port", + Probe::ExactHost(network("api.example", 6443, BINARY)), + )], + ); +} + +/// The destination tests that take a `DefaultPublicOnly` plan: a host matched +/// only by a glob is not exactly declared, so it gets public-only filtering. +/// The original's `mixed.example` becomes `mixed.example.com` under +/// `*.example.com`, because both engines reject a wildcard top-level domain +/// such as `*.example`. +#[test] +fn ported_address_filter_default_public_only() { + assert_parity(&Scenario { + name: "Address filter glob", + host: "mixed.example.com", + yaml: r#" +network_policies: + allowed: + name: allowed + endpoints: + - { host: "*.example.com", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.example.com", ".") + && resource.port == 443 + && context.binary_path == "/usr/bin/curl" +}; +"#, + cases: vec![same_probe( + "proxy/destination.rs::address_filter_retains_public_answer_from_mixed_set", + Probe::ExactHost(network("mixed.example.com", 443, BINARY)), + )], + }); + assert_parity(&Scenario { + name: "Default mode glob", + host: "loopback.example.com", + yaml: r#" +network_policies: + allowed: + name: allowed + endpoints: + - { host: "*.example.com", port: 80 } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.example.com", ".") + && resource.port == 80 + && context.binary_path == "/usr/bin/curl" +}; +"#, + cases: vec![ + same_probe( + "proxy/destination.rs::default_mode_classifies_loopback_as_internal_address glob host", + Probe::ExactHost(network("loopback.example.com", 80, BINARY)), + ), + same_probe( + "proxy/destination.rs::default_mode_classifies_loopback_as_internal_address literal", + Probe::ExactHost(network("127.0.0.1", 80, BINARY)), + ), + ], + }); +} + +/// `BASE_POLICY` from `policy_dns/mod.rs`. The tests query names in mixed case +/// or with a trailing dot; the service normalizes them before checking +/// eligibility, so the probes use the normalized name. Answer filtering +/// (dropping loopback) and observation records are not covered. +#[test] +fn ported_policy_dns_base_policy() { + const PSQL: &str = "/usr/bin/psql"; + assert_parity(&Scenario { + name: "Policy DNS base", + host: "db.example", + yaml: r" +network_policies: + database: + name: database + endpoints: + - { host: db.example, port: 5432, protocol: tcp } + binaries: [{ path: /usr/bin/psql }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +", + cedar: r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example:5432") +when { context.binary_path == "/usr/bin/psql" }; +"#, + cases: vec![ + same_probe( + "policy_dns/mod.rs::eligible_name_filters_answers_and_publishes_bounded_mapping dns", + Probe::DnsEligible { + name: "db.example".to_string(), + port: 5432, + }, + ), + same_probe( + "policy_dns/mod.rs::eligible_name_filters_answers_and_publishes_bounded_mapping exact", + Probe::ExactHost(network("db.example", 5432, PSQL)), + ), + same_probe( + "policy_dns/mod.rs::stages_unknown_name_without_upstream_resolution", + Probe::DnsEligible { + name: "other.example".to_string(), + port: 80, + }, + ), + same_probe( + "policy_dns/mod.rs::unknown_name_correlates_without_authorizing_a_port", + Probe::DnsEligible { + name: "pypi.org".to_string(), + port: 80, + }, + ), + ], + }); +} + +/// A wildcard host is DNS eligible but not exactly declared, so its private +/// answer is rejected by public-only filtering. +#[test] +fn ported_policy_dns_wildcard() { + assert_parity(&Scenario { + name: "Policy DNS wildcard", + host: "db.example.com", + yaml: r" +network_policies: + database: + name: database + endpoints: + - { host: '*.example.com', port: 5432, protocol: tcp } + binaries: [{ path: /usr/bin/psql }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +", + cedar: r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.example.com", ".") + && resource.port == 5432 + && context.binary_path == "/usr/bin/psql" +}; +"#, + cases: vec![ + same_probe( + "policy_dns/mod.rs::wildcard_is_eligible_but_uses_public_only_destination_rules dns", + Probe::DnsEligible { + name: "db.example.com".to_string(), + port: 5432, + }, + ), + same_probe( + "policy_dns/mod.rs::wildcard_is_eligible_but_uses_public_only_destination_rules exact", + Probe::ExactHost(network("db.example.com", 5432, "/usr/bin/psql")), + ), + ], + }); +} + +/// `HOST_GATEWAY_POLICY` from `policy_dns/mod.rs`, with the alias substituted. +fn gateway_alias_policies(alias: &str) -> (String, String) { + let yaml = format!( + r" +network_policies: + gateway: + name: gateway + endpoints: + - {{ host: {alias}, port: 8080, protocol: tcp }} + binaries: [{{ path: /usr/bin/client }}] +filesystem_policy: {{ include_workdir: true, read_only: [], read_write: [] }} +landlock: {{ compatibility: best_effort }} +process: {{ run_as_user: sandbox, run_as_group: sandbox }} +" + ); + let cedar = format!( + r#" +@transport("tcp") +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{alias}:8080") +when {{ context.binary_path == "/usr/bin/client" }}; +"# + ); + (yaml, cedar) +} + +/// The trusted gateway address, its family, and its absence are gateway +/// configuration checked after eligibility; the policy part is that the +/// declared alias is eligible and exactly declared. +#[test] +fn ported_policy_dns_gateway_alias() { + const CLIENT: &str = "/usr/bin/client"; + let (yaml, cedar) = gateway_alias_policies("host.openshell.internal"); + let dns = || Probe::DnsEligible { + name: "host.openshell.internal".to_string(), + port: 8080, + }; + let exact = || Probe::ExactHost(network("host.openshell.internal", 8080, CLIENT)); + assert_parity(&Scenario { + name: "Policy DNS gateway alias", + host: "host.openshell.internal", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_probe( + "policy_dns/mod.rs::reserved_gateway_alias_pins_only_the_exact_trusted_address dns", + dns(), + ), + same_probe( + "policy_dns/mod.rs::reserved_gateway_alias_pins_only_the_exact_trusted_address exact", + exact(), + ), + same_probe( + "policy_dns/mod.rs::reserved_gateway_alias_accepts_an_exact_private_backend_gateway dns", + dns(), + ), + same_probe( + "policy_dns/mod.rs::reserved_gateway_alias_accepts_an_exact_private_backend_gateway exact", + exact(), + ), + same_probe( + "policy_dns/mod.rs::reserved_gateway_alias_rejects_wrong_address_family_without_resolver_fallback", + dns(), + ), + ], + }); + + for alias in [ + "host.openshell.internal", + "host.containers.internal", + "host.docker.internal", + ] { + let (yaml, cedar) = gateway_alias_policies(alias); + assert_parity(&Scenario { + name: alias, + host: alias, + yaml: &yaml, + cedar: &cedar, + cases: vec![same_probe( + "policy_dns/mod.rs::reserved_gateway_alias_without_trusted_address_never_queries_resolver", + Probe::DnsEligible { + name: alias.to_string(), + port: 8080, + }, + )], + }); + } +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_relay.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_relay.rs new file mode 100644 index 0000000000..87ad2790ef --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_relay.rs @@ -0,0 +1,1276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ported YAML decision tests from the L7 relay, GraphQL inspection, and proxy +//! compatibility tests. +//! +//! Most originals send bytes through the relay. Each port checks the policy +//! decision the original asserts (forwarded or 403) as a request probe built +//! from the same body, parsed by the same inspector the relay uses. Relay +//! behavior that does not depend on the policy engine (middleware chains, +//! credential resolution, authority checks, framing) is not ported. + +use super::*; + +use crate::l7::jsonrpc::{ + JsonRpcInspectionMode, JsonRpcInspectionOptions, parse_jsonrpc_body, + parse_jsonrpc_body_with_options, +}; +use openshell_core::mcp::McpProtocolVersion; + +const NODE: &str = "/usr/bin/node"; +const PYTHON: &str = "/usr/bin/python3"; + +/// A request case the engines must agree on, in an explicit context. +fn same_in(name: &'static str, ctx: &L7EvalContext, request: L7RequestInfo) -> Case { + same_probe(name, request_in(ctx.clone(), request)) +} + +/// A request carrying `body` parsed as generic JSON-RPC, as the relay does. +pub(super) fn jsonrpc_body(method: &str, target: &str, body: &str) -> L7RequestInfo { + L7RequestInfo { + jsonrpc: Some(parse_jsonrpc_body( + body.as_bytes(), + JsonRpcInspectionMode::JsonRpc, + )), + ..rest(method, target) + } +} + +/// A request carrying `body` parsed as MCP at the 2025-11-25 revision. +pub(super) fn mcp_body(method: &str, target: &str, body: &str) -> L7RequestInfo { + L7RequestInfo { + jsonrpc: Some(parse_jsonrpc_body_with_options( + body.as_bytes(), + JsonRpcInspectionOptions::mcp_selected(McpProtocolVersion::V2025_11_25, true), + )), + ..rest(method, target) + } +} + +/// A POST carrying a GraphQL JSON envelope, classified as the relay does. +pub(super) fn graphql_body(target: &str, body: &serde_json::Value) -> L7RequestInfo { + L7RequestInfo { + graphql: Some(crate::l7::graphql::classify_json_envelope_value(body)), + ..rest("POST", target) + } +} + +/// Call `index` of a JSON-RPC batch as the relay evaluates it. +/// +/// The relay authorizes a batch by evaluating each call as its own request +/// (`jsonrpc_request_for_call` in `l7/relay.rs`), so a batch probe must do +/// the same rather than send the whole batch to the engine. +pub(super) fn batch_call(request: &L7RequestInfo, index: usize) -> L7RequestInfo { + let info = request.jsonrpc.as_ref().expect("JSON-RPC request"); + assert!( + info.is_batch && !info.has_response, + "batch without responses" + ); + L7RequestInfo { + jsonrpc: Some(JsonRpcRequestInfo { + mcp_revision: info.mcp_revision, + ..jsonrpc_info(vec![info.calls[index].clone()]) + }), + ..request.clone() + } +} + +/// The empty `GET` an MCP client sends to open its receive stream. +fn mcp_receive_stream(target: &str) -> L7RequestInfo { + L7RequestInfo { + jsonrpc: Some(JsonRpcRequestInfo::receive_stream()), + ..rest("GET", target) + } +} + +#[test] +fn ported_graphql_field_policy_allows_absolute_form_chunked_post() { + // The original also declares a persisted-query registry, a Cedar endpoint + // setting (see `ported_phase2`). This request is not a persisted query, + // so the registry does not affect its decision. + let ctx = l7_ctx("host.openshell.internal", 8080, PYTHON); + assert_parity(&Scenario { + name: "graphql.rs field policy", + host: "host.openshell.internal", + yaml: r" +network_policies: + test_graphql_l7: + name: test_graphql_l7 + endpoints: + - host: host.openshell.internal + port: 8080 + protocol: graphql + enforcement: enforce + persisted_queries: allow_registered + graphql_persisted_queries: + abc123: + operation_type: query + operation_name: Viewer + fields: [viewer] + rules: + - allow: + operation_type: query + fields: [viewer] + - allow: + operation_type: mutation + fields: [createIssue] + deny_rules: + - operation_type: mutation + fields: [deleteRepository] + binaries: + - { path: /usr/bin/python3 } +", + cedar: r#" +@protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"host.openshell.internal:8080") +when { + context.binary_path == "/usr/bin/python3" + && ((context.graphql_operation_type == "query" + && context.graphql_fields.containsAny(["viewer"]) + && ["viewer"].containsAll(context.graphql_fields)) + || (context.graphql_operation_type == "mutation" + && context.graphql_fields.containsAny(["createIssue"]) + && ["createIssue"].containsAll(context.graphql_fields))) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"host.openshell.internal:8080") +when { + context.graphql_operation_type == "mutation" + && context.graphql_fields.containsAny(["deleteRepository"]) +}; +"#, + cases: vec![same_in( + "graphql.rs::absolute_form_chunked_graphql_post_is_allowed_by_field_policy", + &ctx, + graphql_body( + "/graphql", + &serde_json::json!({"query": "query Viewer { viewer { login } }"}), + ), + )], + }); +} + +#[test] +fn ported_identity_required_policy_binary_match() { + assert_parity(&Scenario { + name: "compatibility.rs identity", + host: "target.example", + yaml: r" +network_policies: + proxy_compatibility: + name: proxy_compatibility + endpoints: + - host: target.example + port: 443 + binaries: + - path: /usr/bin/curl +", + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"target.example:443") +when { context.binary_path == "/usr/bin/curl" }; +"#, + cases: vec![ + same_probe( + "compatibility.rs::identity_required_policy_accepts_real_binary_and_rejects_empty_exec_path/real binary", + Probe::Connect(network("target.example", 443, BINARY)), + ), + same_probe( + "compatibility.rs::identity_required_policy_accepts_real_binary_and_rejects_empty_exec_path/empty path", + Probe::Connect(network("target.example", 443, "")), + ), + ], + }); +} + +/// The `rest_api` policy from `middleware_relay_context_with_enforcement`, +/// without its middleware chain, which does not take part in the policy +/// decision. +fn middleware_rest_yaml(enforcement: &str) -> String { + format!( + r#" +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.test + port: 8080 + protocol: rest + enforcement: {enforcement} + rules: + - allow: + method: POST + path: "/v1/**" + binaries: + - {{ path: /usr/bin/curl }} +"# + ) +} + +fn middleware_rest_cedar(enforcement_annotation: &str) -> String { + format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.test:8080") +when {{ context.binary_path == "/usr/bin/curl" }}; +@protocol("rest"){enforcement_annotation} +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:8080") +when {{ + context.binary_path == "/usr/bin/curl" + && context.method == "POST" + && context.path like("/v1/**", "/") +}}; +"# + ) +} + +#[test] +fn ported_l7_denial_precedes_credential_endpoint_resolution() { + // The original checks that the policy denial comes before credential + // resolution; the engine-independent ordering is not ported. + let ctx = l7_ctx("api.example.test", 8080, BINARY); + let yaml = middleware_rest_yaml("enforce"); + let cedar = middleware_rest_cedar(""); + assert_parity(&Scenario { + name: "relay.rs rest_api enforce", + host: "api.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![same_in( + "relay.rs::l7_denial_precedes_credential_endpoint_resolution", + &ctx, + rest("GET", "/outside"), + )], + }); +} + +#[test] +fn ported_audit_endpoint_forwards_policy_denied_request() { + // The relay forwards the request because the policy denies it on an + // audit endpoint: both engines must deny it and select audit enforcement. + let ctx = l7_ctx("api.example.test", 8080, BINARY); + let yaml = middleware_rest_yaml("audit"); + let cedar = middleware_rest_cedar("\n@enforcement(\"audit\")"); + assert_parity(&Scenario { + name: "relay.rs rest_api audit", + host: "api.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_in( + "relay.rs::audit_endpoint_forwards_policy_denied_request_through_healthy_chain/decision", + &ctx, + rest("GET", "/other"), + ), + same_probe( + "relay.rs::audit_endpoint_forwards_policy_denied_request_through_healthy_chain/enforcement", + Probe::Inspection(network("api.example.test", 8080, BINARY)), + ), + ], + }); +} + +/// The `jsonrpc_api` policy from `jsonrpc_transforming_relay_parts`, without +/// the rewriting middleware. +fn transforming_jsonrpc_yaml(enforcement: &str) -> String { + format!( + r" +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: api.example.test + port: 443 + protocol: json-rpc + enforcement: {enforcement} + rules: + - allow: + method: reports.list + binaries: + - {{ path: /usr/bin/node }} +" + ) +} + +fn transforming_jsonrpc_cedar(enforcement_annotation: &str) -> String { + format!( + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.test:443") +when {{ context.binary_path == "/usr/bin/node" }}; +@protocol("json-rpc"){enforcement_annotation} +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:443") +when {{ + context.binary_path == "/usr/bin/node" + && !context.jsonrpc_response + && context.jsonrpc_method == "reports.list" +}}; +"# + ) +} + +const ORIGINAL_JSONRPC_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"reports.list"}"#; +const REWRITTEN_JSONRPC_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"admin.delete"}"#; + +#[test] +fn ported_transformed_jsonrpc_body_is_reevaluated_and_denied() { + let ctx = l7_ctx("api.example.test", 443, NODE); + let yaml = transforming_jsonrpc_yaml("enforce"); + let cedar = transforming_jsonrpc_cedar(""); + assert_parity(&Scenario { + name: "relay.rs transformed JSON-RPC enforce", + host: "api.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_in( + "relay.rs::transformed_jsonrpc_body_is_reevaluated_and_denied/original", + &ctx, + jsonrpc_body("POST", "/rpc", ORIGINAL_JSONRPC_BODY), + ), + same_in( + "relay.rs::transformed_jsonrpc_body_is_reevaluated_and_denied/rewritten", + &ctx, + jsonrpc_body("POST", "/rpc", REWRITTEN_JSONRPC_BODY), + ), + ], + }); +} + +#[test] +fn ported_transformed_jsonrpc_body_policy_deny_forwards_under_audit() { + let ctx = l7_ctx("api.example.test", 443, NODE); + let yaml = transforming_jsonrpc_yaml("audit"); + let cedar = transforming_jsonrpc_cedar("\n@enforcement(\"audit\")"); + assert_parity(&Scenario { + name: "relay.rs transformed JSON-RPC audit", + host: "api.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_in( + "relay.rs::transformed_jsonrpc_body_policy_deny_forwards_under_audit/rewritten", + &ctx, + jsonrpc_body("POST", "/rpc", REWRITTEN_JSONRPC_BODY), + ), + same_probe( + "relay.rs::transformed_jsonrpc_body_policy_deny_forwards_under_audit/enforcement", + Probe::Inspection(network("api.example.test", 443, NODE)), + ), + ], + }); +} + +#[test] +fn ported_transformed_graphql_body_is_reevaluated_and_denied() { + let ctx = l7_ctx("api.example.test", 443, NODE); + assert_parity(&Scenario { + name: "relay.rs transformed GraphQL", + host: "api.example.test", + yaml: r" +network_policies: + graphql_api: + name: graphql_api + endpoints: + - host: api.example.test + port: 443 + protocol: graphql + enforcement: enforce + rules: + - allow: + operation_type: query + fields: [viewer] + binaries: + - { path: /usr/bin/node } +", + cedar: r#" +@protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:443") +when { + context.binary_path == "/usr/bin/node" + && context.graphql_operation_type == "query" + && context.graphql_fields.containsAny(["viewer"]) + && ["viewer"].containsAll(context.graphql_fields) +}; +"#, + cases: vec![ + same_in( + "relay.rs::transformed_graphql_body_is_reevaluated_and_denied/original", + &ctx, + graphql_body( + "/graphql", + &serde_json::json!({"query": "query { viewer }"}), + ), + ), + same_in( + "relay.rs::transformed_graphql_body_is_reevaluated_and_denied/rewritten", + &ctx, + graphql_body( + "/graphql", + &serde_json::json!({"query": "mutation { deleteRepository }"}), + ), + ), + ], + }); +} + +#[test] +fn ported_jsonrpc_batch_evaluates_each_call() { + // The original's log-format assertions do not depend on the engine. A + // batch containing a response frame is evaluated whole first, and a batch + // of calls is evaluated call by call, as `evaluate_l7_request` does. + let ctx = l7_ctx("api.example.test", 443, NODE); + let allowed_batch = jsonrpc_body( + "POST", + "/rpc", + r#"[ + {"jsonrpc":"2.0","id":1,"method":"reports.list"}, + {"jsonrpc":"2.0","id":2,"method":"reports.search","params":{"query":"private_query_value"}} + ]"#, + ); + let denied_batch = jsonrpc_body( + "POST", + "/rpc", + r#"[ + {"jsonrpc":"2.0","id":1,"method":"reports.list"}, + {"jsonrpc":"2.0","id":2,"method":"reports.search","params":{"query":"private_query_value"}}, + {"jsonrpc":"2.0","id":3,"method":"reports.delete","params":{"id":"purge_cache"}} + ]"#, + ); + assert_parity(&Scenario { + name: "relay.rs JSON-RPC batch", + host: "api.example.test", + yaml: r#" +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: api.example.test + port: 443 + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: "reports.list" + - allow: + method: "reports.search" + deny_rules: + - method: "reports.delete" + binaries: + - { path: /usr/bin/node } +"#, + cedar: r#" +@protocol("json-rpc") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:443") +when { + context.binary_path == "/usr/bin/node" + && !context.jsonrpc_response + && ["reports.list", "reports.search"].contains(context.jsonrpc_method) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:443") +when { context.jsonrpc_method == "reports.delete" }; +"#, + cases: vec![ + same_in( + "relay.rs::jsonrpc_batch_evaluates_each_call/allowed batch, call 1", + &ctx, + batch_call(&allowed_batch, 0), + ), + same_in( + "relay.rs::jsonrpc_batch_evaluates_each_call/allowed batch, call 2", + &ctx, + batch_call(&allowed_batch, 1), + ), + same_in( + "relay.rs::jsonrpc_batch_evaluates_each_call/batch with response", + &ctx, + jsonrpc_body( + "POST", + "/rpc", + r#"[ + {"jsonrpc":"2.0","id":1,"method":"reports.list"}, + {"jsonrpc":"2.0","id":2,"result":{"ok":true}} + ]"#, + ), + ), + same_in( + "relay.rs::jsonrpc_batch_evaluates_each_call/response only", + &ctx, + jsonrpc_body( + "POST", + "/rpc", + r#"{"jsonrpc":"2.0","id":2,"result":{"ok":true}}"#, + ), + ), + same_in( + "relay.rs::jsonrpc_batch_evaluates_each_call/denied batch, call 1", + &ctx, + batch_call(&denied_batch, 0), + ), + same_in( + "relay.rs::jsonrpc_batch_evaluates_each_call/denied batch, call 2", + &ctx, + batch_call(&denied_batch, 1), + ), + same_in( + "relay.rs::jsonrpc_batch_evaluates_each_call/denied batch, call 3", + &ctx, + batch_call(&denied_batch, 2), + ), + ], + }); +} + +#[test] +fn ported_jsonrpc_request_params_do_not_affect_method_policy() { + let ctx = l7_ctx("api.example.test", 443, NODE); + assert_parity(&Scenario { + name: "relay.rs JSON-RPC params", + host: "api.example.test", + yaml: r#" +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: api.example.test + port: 443 + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: "reports.search" + binaries: + - { path: /usr/bin/node } +"#, + cedar: r#" +@protocol("json-rpc") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:443") +when { + context.binary_path == "/usr/bin/node" + && !context.jsonrpc_response + && context.jsonrpc_method == "reports.search" +}; +"#, + cases: vec![ + same_in( + "relay.rs::jsonrpc_request_params_do_not_affect_method_policy/object params", + &ctx, + jsonrpc_body( + "POST", + "/rpc", + r#"{"jsonrpc":"2.0","id":1,"method":"reports.search","params":{"query":"delete_resource","filters":{"scope":"workspace/secret"}}}"#, + ), + ), + same_in( + "relay.rs::jsonrpc_request_params_do_not_affect_method_policy/array params", + &ctx, + jsonrpc_body( + "POST", + "/rpc", + r#"{"jsonrpc":"2.0","id":1,"method":"reports.search","params":["ignored",{"nested":true}]}"#, + ), + ), + ], + }); +} + +#[test] +fn ported_mcp_tool_deny_rule_blocks_tools_call() { + // MCP endpoints in YAML implicitly admit the receive stream and response + // frames; the second Cedar permit states that explicitly. + let ctx = l7_ctx("api.example.test", 443, NODE); + assert_parity(&Scenario { + name: "relay.rs MCP tool deny", + host: "api.example.test", + yaml: r#" +network_policies: + mcp_api: + name: mcp_api + endpoints: + - host: api.example.test + port: 443 + path: "/mcp" + protocol: mcp + enforcement: enforce + mcp: + max_body_bytes: 131072 + rules: + - allow: + method: initialize + - allow: + method: tools/list + - allow: + method: tools/call + tool: read_status + deny_rules: + - method: tools/call + tool: delete_resource + binaries: + - { path: /usr/bin/node } +"#, + cedar: r#" +@protocol("mcp") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:443") +when { + context.binary_path == "/usr/bin/node" + && context.path == "/mcp" + && !context.jsonrpc_response + && context.mcp_method_class == "available" + && (["initialize", "tools/list"].contains(context.jsonrpc_method) + || (context.jsonrpc_method == "tools/call" && context.mcp_tool == "read_status")) +}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:443") +when { + context.binary_path == "/usr/bin/node" + && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.test:443") +when { context.jsonrpc_method == "tools/call" && context.mcp_tool == "delete_resource" }; +"#, + cases: vec![ + same_in( + "relay.rs::mcp_tool_deny_rule_blocks_tools_call/read_status", + &ctx, + mcp_body( + "POST", + "/mcp", + r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_status","arguments":{}}}"#, + ), + ), + same_in( + "relay.rs::mcp_tool_deny_rule_blocks_tools_call/delete_resource", + &ctx, + mcp_body( + "POST", + "/mcp", + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_resource","arguments":{"scope":"workspace/main"}}}"#, + ), + ), + ], + }); +} + +#[test] +fn ported_mcp_legacy_receive_stream_get() { + // `mcp.versions: ["2025-11-25"]` is a Cedar endpoint setting (see + // `ported_phase2`); every request here uses that revision, which is also + // the default, so no setting is needed. + let ctx = l7_ctx("mcp.example.test", 8000, PYTHON); + let delete_body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_resource","arguments":{}}}"#; + let read_body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_status","arguments":{}}}"#; + assert_parity(&Scenario { + name: "relay.rs MCP legacy receive stream", + host: "mcp.example.test", + yaml: r#" +network_policies: + mcp_api: + name: mcp_api + endpoints: + - host: mcp.example.test + port: 8000 + path: /mcp + protocol: mcp + enforcement: enforce + mcp: + versions: ["2025-11-25"] + rules: + - allow: + method: tools/call + tool: read_status + deny_rules: + - method: tools/call + tool: delete_resource + binaries: + - { path: /usr/bin/python3 } +"#, + cedar: r#" +@protocol("mcp") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/mcp" + && !context.jsonrpc_response + && context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" + && context.mcp_tool == "read_status" +}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { context.jsonrpc_method == "tools/call" && context.mcp_tool == "delete_resource" }; +"#, + cases: vec![ + same_in( + "relay.rs::mcp_legacy_receive_stream_get_does_not_admit_tool_bodies_or_delete/empty GET", + &ctx, + mcp_receive_stream("/mcp"), + ), + same_in( + "relay.rs::mcp_legacy_receive_stream_get_does_not_admit_tool_bodies_or_delete/GET with denied tool", + &ctx, + mcp_body("GET", "/mcp", delete_body), + ), + same_in( + "relay.rs::mcp_legacy_receive_stream_get_does_not_admit_tool_bodies_or_delete/GET with allowed tool", + &ctx, + mcp_body("GET", "/mcp", read_body), + ), + // The relay rejects this unparseable body with 400 before policy + // runs; this checks that neither engine allows it on its own. + same_in( + "relay.rs::mcp_legacy_receive_stream_get_does_not_admit_tool_bodies_or_delete/empty DELETE", + &ctx, + mcp_body("DELETE", "/mcp", ""), + ), + ], + }); +} + +#[test] +fn ported_mcp_relay_forwards_jsonrpc_response_frame() { + let ctx = l7_ctx("mcp.example.test", 8000, PYTHON); + assert_parity(&Scenario { + name: "relay.rs MCP response frame", + host: "mcp.example.test", + yaml: r" +network_policies: + mcp_api: + name: mcp_api + endpoints: + - host: mcp.example.test + port: 8000 + path: /mcp + protocol: mcp + enforcement: enforce + rules: + - allow: + method: initialize + binaries: + - { path: /usr/bin/python3 } +", + cedar: r#" +@protocol("mcp") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/mcp" + && !context.jsonrpc_response + && context.mcp_method_class == "available" + && context.jsonrpc_method == "initialize" +}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response) +}; +"#, + cases: vec![same_in( + "relay.rs::mcp_relay_forwards_jsonrpc_response_frame", + &ctx, + mcp_body( + "POST", + "/mcp", + r#"{"jsonrpc":"2.0","id":7,"result":{"action":"accept","content":{}}}"#, + ), + )], + }); +} + +#[test] +fn ported_jsonrpc_relay_initialize_allow_list() { + let ctx = l7_ctx("jsonrpc.example.test", 8000, PYTHON); + assert_parity(&Scenario { + name: "relay.rs JSON-RPC relay", + host: "jsonrpc.example.test", + yaml: r#" +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: jsonrpc.example.test + port: 8000 + path: "/rpc" + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: initialize + binaries: + - { path: /usr/bin/python3 } +"#, + cedar: r#" +@protocol("json-rpc") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"jsonrpc.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/rpc" + && !context.jsonrpc_response + && context.jsonrpc_method == "initialize" +}; +"#, + cases: vec![ + same_in( + "relay.rs::jsonrpc_relay_forwards_allowed_method", + &ctx, + jsonrpc_body( + "POST", + "/rpc", + r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ), + ), + same_in( + "relay.rs::jsonrpc_relay_denies_method_not_in_allow_list", + &ctx, + jsonrpc_body( + "POST", + "/rpc", + r#"{"jsonrpc":"2.0","id":1,"method":"reports.search","params":{"query":"list_repos"}}"#, + ), + ), + ], + }); +} + +/// The per-protocol policy from `chunked_http_pipeline_authorizes_each_request`. +fn pipeline_yaml(protocol: &str, endpoint_path: &str, rules: &str) -> String { + format!( + r" +network_policies: + mcp_api: + name: mcp_api + endpoints: + - host: mcp.example.test + port: 8000 + path: {endpoint_path} + protocol: {protocol} + enforcement: enforce + rules: + - allow: {{ {rules} }} + binaries: + - {{ path: /usr/bin/python3 }} +" + ) +} + +#[test] +fn ported_chunked_http_pipeline_authorizes_each_request() { + // Each pipeline sends an allowed first request and a second request that + // is allowed (`echo`) or denied (`blocked`); the original asserts the + // policy decision for each. + let ctx = l7_ctx("mcp.example.test", 8000, PYTHON); + let mcp_call = |name: &str| { + mcp_body( + "POST", + "/mcp", + &serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": name, "arguments": {}} + }) + .to_string(), + ) + }; + let jsonrpc_call = |name: &str| { + jsonrpc_body( + "POST", + "/mcp", + &serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": name}).to_string(), + ) + }; + let graphql_query = |name: &str| { + graphql_body( + "/mcp", + &serde_json::json!({"query": format!("query {{ {name} }}")}), + ) + }; + + let yaml = pipeline_yaml("mcp", "/mcp", "method: tools/call, tool: echo"); + assert_parity(&Scenario { + name: "relay.rs pipeline mcp", + host: "mcp.example.test", + yaml: &yaml, + cedar: r#" +@protocol("mcp") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/mcp" + && !context.jsonrpc_response + && context.mcp_method_class == "available" + && context.jsonrpc_method == "tools/call" + && context.mcp_tool == "echo" +}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response) +}; +"#, + cases: vec![ + same_in( + "relay.rs::chunked_http_pipeline_authorizes_each_request/mcp echo", + &ctx, + mcp_call("echo"), + ), + same_in( + "relay.rs::chunked_http_pipeline_authorizes_each_request/mcp blocked", + &ctx, + mcp_call("blocked"), + ), + ], + }); + + let yaml = pipeline_yaml("json-rpc", "/mcp", "method: echo"); + assert_parity(&Scenario { + name: "relay.rs pipeline json-rpc", + host: "mcp.example.test", + yaml: &yaml, + cedar: r#" +@protocol("json-rpc") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/mcp" + && !context.jsonrpc_response + && context.jsonrpc_method == "echo" +}; +"#, + cases: vec![ + same_in( + "relay.rs::chunked_http_pipeline_authorizes_each_request/json-rpc echo", + &ctx, + jsonrpc_call("echo"), + ), + same_in( + "relay.rs::chunked_http_pipeline_authorizes_each_request/json-rpc blocked", + &ctx, + jsonrpc_call("blocked"), + ), + ], + }); + + let yaml = pipeline_yaml("graphql", "/mcp", "operation_type: query, fields: [echo]"); + assert_parity(&Scenario { + name: "relay.rs pipeline graphql", + host: "mcp.example.test", + yaml: &yaml, + cedar: r#" +@protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/mcp" + && context.graphql_operation_type == "query" + && context.graphql_fields.containsAny(["echo"]) + && ["echo"].containsAll(context.graphql_fields) +}; +"#, + cases: vec![ + same_in( + "relay.rs::chunked_http_pipeline_authorizes_each_request/graphql echo", + &ctx, + graphql_query("echo"), + ), + same_in( + "relay.rs::chunked_http_pipeline_authorizes_each_request/graphql blocked", + &ctx, + graphql_query("blocked"), + ), + ], + }); + + let yaml = pipeline_yaml("rest", "/mcp/**", "method: POST, path: /mcp/allowed"); + assert_parity(&Scenario { + name: "relay.rs pipeline rest", + host: "mcp.example.test", + yaml: &yaml, + cedar: r#" +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.method == "POST" + && context.path == "/mcp/allowed" +}; +"#, + cases: vec![ + same_in( + "relay.rs::chunked_http_pipeline_authorizes_each_request/rest allowed", + &ctx, + rest("POST", "/mcp/allowed"), + ), + same_in( + "relay.rs::chunked_http_pipeline_authorizes_each_request/rest blocked", + &ctx, + rest("POST", "/mcp/blocked"), + ), + ], + }); +} + +/// The `route_api` policy from `assert_endpoint_path_deny`: an endpoint scoped +/// to `selector` that denies POST, and an unscoped endpoint that allows all. +fn endpoint_path_deny_yaml(selector: &str) -> String { + format!( + r#" +network_policies: + route_api: + endpoints: + - host: gateway.example.test + port: 443 + path: "{selector}" + protocol: rest + enforcement: enforce + rules: + - allow: {{ method: "*", path: "**" }} + deny_rules: + - {{ method: POST, path: "**" }} + - host: gateway.example.test + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: {{ method: "*", path: "**" }} + binaries: + - {{ path: /usr/bin/node }} +"# + ) +} + +/// Both endpoints are REST with enforcement, so one Cedar endpoint permits +/// everything and forbids POST under the selector. +fn endpoint_path_deny_cedar(selector_condition: &str) -> String { + format!( + r#" +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"gateway.example.test:443") +when {{ context.binary_path == "/usr/bin/node" }}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"gateway.example.test:443") +when {{ context.method == "POST" && ({selector_condition}) }}; +"# + ) +} + +#[test] +fn ported_endpoint_path_deny_subtree_selector() { + let ctx = l7_ctx("gateway.example.test", 443, NODE); + let yaml = endpoint_path_deny_yaml("/p/**"); + let cedar = + endpoint_path_deny_cedar(r#"context.path == "/p" || context.path like("/p/**", "/")"#); + assert_parity(&Scenario { + name: "relay.rs endpoint path deny /p/**", + host: "gateway.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_in( + "relay.rs::endpoint_path_deny_covers_subtree_root", + &ctx, + rest("POST", "/p"), + ), + same_in( + "relay.rs::endpoint_path_deny_preserves_matching_and_nonmatching_controls/POST /p/child", + &ctx, + rest("POST", "/p/child"), + ), + same_in( + "relay.rs::endpoint_path_deny_preserves_matching_and_nonmatching_controls/GET /p", + &ctx, + rest("GET", "/p"), + ), + same_in( + "relay.rs::endpoint_path_deny_preserves_matching_and_nonmatching_controls/POST /public", + &ctx, + rest("POST", "/public"), + ), + same_in( + "relay.rs::endpoint_path_deny_preserves_matching_and_nonmatching_controls/POST /prefix", + &ctx, + rest("POST", "/prefix"), + ), + ], + }); +} + +#[test] +fn ported_endpoint_path_deny_single_star_selector() { + // YAML treats a trailing `/*` endpoint selector as multi-segment, so the + // Cedar translation uses `/v1/**`. + let ctx = l7_ctx("gateway.example.test", 443, NODE); + let yaml = endpoint_path_deny_yaml("/v1/*"); + let cedar = endpoint_path_deny_cedar(r#"context.path like("/v1/**", "/")"#); + assert_parity(&Scenario { + name: "relay.rs endpoint path deny /v1/*", + host: "gateway.example.test", + yaml: &yaml, + cedar: &cedar, + cases: vec![ + same_in( + "relay.rs::endpoint_path_deny_covers_multisegment_star", + &ctx, + rest("POST", "/v1/a/b"), + ), + same_in( + "control: GET under the selector", + &ctx, + rest("GET", "/v1/a/b"), + ), + same_in( + "control: POST outside the selector", + &ctx, + rest("POST", "/v2/a"), + ), + same_in( + "relay.rs::endpoint_path_deny_preserves_matching_and_nonmatching_controls/POST /v1/a", + &ctx, + rest("POST", "/v1/a"), + ), + ], + }); +} + +#[test] +fn ported_route_selected_unmatched_path_is_denied() { + // The original also checks the denied policy activity; emission does not + // depend on the engine. `allow_encoded_slash` is a Cedar endpoint setting + // (see `ported_phase2`) and does not affect this unencoded path. + let ctx = l7_ctx("gateway.example.test", 443, NODE); + assert_parity(&Scenario { + name: "relay.rs route selection", + host: "gateway.example.test", + yaml: r#" +network_policies: + route_api: + name: route_api + endpoints: + - host: gateway.example.test + port: 443 + path: /repos/** + protocol: rest + enforcement: enforce + allow_encoded_slash: true + rules: + - allow: + method: GET + path: "/repos/**" + - host: gateway.example.test + port: 443 + path: /admin/** + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/admin/**" + binaries: + - { path: /usr/bin/node } +"#, + cedar: r#" +@protocol("rest") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"gateway.example.test:443") +when { + context.binary_path == "/usr/bin/node" + && context.method == "GET" + && (context.path like("/repos/**", "/") || context.path like("/admin/**", "/")) +}; +"#, + cases: vec![same_in( + "relay.rs::route_selected_unmatched_path_emits_denied_policy_activity", + &ctx, + rest("GET", "/other"), + )], + }); +} + +#[test] +fn ported_single_endpoint_graphql_post_still_inspects_body() { + // The original's authority-mismatch cases (no Host header, credential + // marker) do not depend on the engine and are not ported. + let ctx = l7_ctx("graphql.example.test", 8000, PYTHON); + let query = |field: &str| { + graphql_body( + "/graphql", + &serde_json::json!({"query": format!("{{{field}}}"), "variables": {"token": ""}}), + ) + }; + assert_parity(&Scenario { + name: "relay.rs single-endpoint GraphQL", + host: "graphql.example.test", + yaml: r" +network_policies: + graphql_api: + name: graphql_api + endpoints: + - host: graphql.example.test + port: 8000 + path: /graphql + protocol: graphql + enforcement: enforce + rules: + - allow: + operation_type: query + fields: [viewer] + binaries: + - { path: /usr/bin/python3 } +", + cedar: r#" +@protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"graphql.example.test:8000") +when { + context.binary_path == "/usr/bin/python3" + && context.path == "/graphql" + && context.graphql_operation_type == "query" + && context.graphql_fields.containsAny(["viewer"]) + && ["viewer"].containsAll(context.graphql_fields) +}; +"#, + cases: vec![ + same_in( + "relay.rs::single_endpoint_graphql_post_still_inspects_body/viewer", + &ctx, + query("viewer"), + ), + same_in( + "relay.rs::single_endpoint_graphql_post_still_inspects_body/admin", + &ctx, + query("admin"), + ), + ], + }); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/provider_rules.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/provider_rules.rs new file mode 100644 index 0000000000..be72bc9f66 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/provider_rules.rs @@ -0,0 +1,552 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Token-grant owner parity for provider rules compiled to Cedar. +//! +//! A Cedar sandbox selects token-grant owners by evaluating its provider +//! rules compiled to Cedar, where a YAML sandbox evaluates them with Rego. +//! These tests load every shipped provider profile as the gateway composes +//! it, give the Cedar sandbox a policy that allows every probed request, and +//! require both engines to admit the same owners. A sweep of glob, query, +//! and deny matchers checks the translated matcher semantics against Rego. + +use super::*; + +use openshell_core::proto::{ + L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, +}; + +/// The binary no shipped profile lists. +const UNLISTED: &str = "/usr/bin/unlisted-client"; +/// A host no shipped profile names. +const OTHER_HOST: &str = "other.example.test"; + +/// Loads every shipped provider profile, keyed by file name. +fn shipped_profiles() -> Vec<(String, openshell_providers::ProviderTypeProfile)> { + let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../providers"); + let mut profiles: Vec<_> = std::fs::read_dir(&directory) + .expect("providers directory") + .map(|entry| entry.expect("directory entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "yaml")) + .map(|path| { + let text = std::fs::read_to_string(&path).expect("profile reads"); + let profile = openshell_providers::parse_profile_yaml(&text) + .unwrap_or_else(|error| panic!("{}: profile parses: {error}", path.display())); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + (name, profile) + }) + .collect(); + profiles.sort_by(|left, right| left.0.cmp(&right.0)); + assert!(profiles.len() >= 10, "shipped profiles load"); + profiles +} + +/// The provider rule the gateway composes for `profile`, with each +/// endpoint stamped with its token-grant owner. +pub(super) fn composed_rule( + profile: &openshell_providers::ProviderTypeProfile, +) -> NetworkPolicyRule { + let rule_name = openshell_policy::provider_rule_name(&profile.id); + let mut rule = profile.network_policy_rule(&rule_name); + let provider_id = format!("provider-{}", profile.id); + let owners = openshell_core::policy_identity::provider_token_grant_owners(&provider_id, &rule); + for (endpoint, owner) in rule.endpoints.iter_mut().zip(owners) { + endpoint.token_grant_owner = owner; + } + rule +} + +/// A concrete host an endpoint host or host glob matches. +fn concrete_host(host: &str) -> String { + host.replace("**", "x") + .replace('*', "x") + .to_ascii_lowercase() +} + +/// A Cedar policy allowing every request to each `host:port`, inspecting +/// `graphql` paths as GraphQL. +fn allow_all(endpoints: &[(String, u16)], graphql: &[(String, u16, String)]) -> String { + let mut cedar = String::new(); + for (host, port) in endpoints { + let _ = write!( + cedar, + r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{host}:{port}"); +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:{port}"); +"# + ); + } + for (host, port, path) in graphql { + let _ = write!( + cedar, + r#" +@path("{path}") @protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:{port}"); +"# + ); + } + cedar +} + +/// The Cedar endpoints that allow a profile's probes, and the probes. +struct ProfileProbes { + /// `host:port` pairs to allow. + endpoints: Vec<(String, u16)>, + /// `host:port` and path of each GraphQL endpoint. + graphql_paths: Vec<(String, u16, String)>, + cases: Vec, +} + +/// The owner probes for one profile. +fn profile_probes( + profile: &openshell_providers::ProviderTypeProfile, + rule: &NetworkPolicyRule, +) -> ProfileProbes { + let binary = rule + .binaries + .iter() + .map(|binary| binary.path.as_str()) + .find(|path| !path.contains('*')) + .unwrap_or(BINARY) + .to_string(); + let mut endpoints = vec![(OTHER_HOST.to_string(), 443)]; + let mut graphql_paths = Vec::new(); + let mut cases = Vec::new(); + let mut probe = |name: String, ctx: L7EvalContext, request: L7RequestInfo| { + cases.push(same_probe( + format!("{}/{name}", profile.id), + owners_in(ctx, request), + )); + }; + for endpoint in &rule.endpoints { + if endpoint.host.is_empty() { + continue; + } + let host = concrete_host(&endpoint.host); + let port = endpoint.ports.first().copied().unwrap_or(endpoint.port); + let port = u16::try_from(port).expect("port"); + if !endpoints.contains(&(host.clone(), port)) { + endpoints.push((host.clone(), port)); + } + let ctx = || l7_ctx(&host, port, &binary); + if endpoint.protocol == "graphql" { + graphql_paths.push((host.clone(), port, endpoint.path.clone())); + let request = |operations| L7RequestInfo { + target: endpoint.path.clone(), + ..graphql(operations) + }; + for (name, operations) in [ + ( + "query", + vec![operation("query", Some("Viewer"), &["viewer"])], + ), + ( + "mutation", + vec![operation("mutation", None, &["createIssue"])], + ), + ( + "query and mutation", + vec![ + operation("query", None, &["viewer"]), + operation("mutation", None, &["createIssue"]), + ], + ), + ( + "subscription", + vec![operation("subscription", None, &["x"])], + ), + ] { + probe( + format!("{host}{} graphql {name}", endpoint.path), + ctx(), + request(operations), + ); + } + continue; + } + let mut paths = vec![ + "/".to_string(), + "/v1/messages".to_string(), + "/o/r/git-upload-pack".to_string(), + "/openai/v1/chat".to_string(), + ]; + if !endpoint.path.is_empty() { + paths.push(endpoint.path.replace("/**", "/x")); + } + for path in &paths { + for method in ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] { + probe(format!("{host} {method} {path}"), ctx(), rest(method, path)); + } + } + probe( + format!("{host} GET from an unlisted binary"), + l7_ctx(&host, port, UNLISTED), + rest("GET", "/"), + ); + let mut child = l7_ctx(&host, port, "/bin/sh"); + child.ancestors = vec![binary.clone()]; + probe( + format!("{host} GET from a child of the binary"), + child, + rest("GET", "/"), + ); + probe( + format!("{host} GET to another host"), + l7_ctx(OTHER_HOST, 443, &binary), + rest("GET", "/"), + ); + } + ProfileProbes { + endpoints, + graphql_paths, + cases, + } +} + +#[test] +fn shipped_provider_profiles_select_the_same_owners() { + let mut probes = 0; + let mut admitted = 0; + let mut report = String::new(); + let mut failures = Vec::new(); + for (file, profile) in shipped_profiles() { + let rule = composed_rule(&profile); + let ProfileProbes { + endpoints, + graphql_paths, + cases, + } = profile_probes(&profile, &rule); + let cedar = allow_all(&endpoints, &graphql_paths); + let scenario = ProviderScenario { + name: &file, + host: OTHER_HOST, + rules: vec![rule.clone()], + cedar: &cedar, + cases, + }; + let engines = Engines::load_providers(&scenario); + for case in &scenario.cases { + let (yaml, cedar) = engines.outcomes(OTHER_HOST, &case.probe); + probes += 1; + if yaml != "none" { + admitted += 1; + } + if yaml != cedar { + failures.push(format!( + "{file} / {}: YAML {yaml} but Cedar {cedar}", + case.name + )); + } + } + // Only binary globs, which Cedar cannot match against ancestors, + // are compiled other than exactly. + let rules = HashMap::from([(rule.name.clone(), rule.clone())]); + if let Some(compiled) = + crate::cedar_provider_rules::compile(&rules, 0).expect("provider rules compile") + { + assert!(compiled.fail_closed_reason().is_none(), "{file}"); + for note in compiled.untranslated() { + assert_eq!( + note.location, + openshell_policy_cedar::RuleLocation::Binaries, + "{file}: {note}" + ); + let _ = writeln!(report, "{file}: {note}"); + } + } + } + println!("{probes} owner probes, {admitted} admitting an owner\n{report}"); + assert!(failures.is_empty(), "{}", failures.join("\n")); + assert!( + admitted > 100, + "the probes admit owners: {admitted} of {probes}" + ); +} + +const HOST: &str = "api.example.test"; + +fn owned(rules: Vec, deny_rules: Vec) -> NetworkPolicyRule { + NetworkPolicyRule { + name: "owner".to_string(), + endpoints: vec![NetworkEndpoint { + host: HOST.to_string(), + port: 443, + protocol: "rest".to_string(), + token_grant_owner: "owner".to_string(), + rules, + deny_rules, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: BINARY.to_string(), + }], + } +} + +fn get(path: &str) -> L7Rule { + L7Rule { + allow: Some(L7Allow { + method: "GET".to_string(), + path: path.to_string(), + ..Default::default() + }), + } +} + +/// Runs owner probes for `requests` against one provider rule set. +fn sweep( + name: &str, + rules: Vec, + requests: Vec<(String, L7RequestInfo)>, +) -> usize { + let cedar = allow_all(&[(HOST.to_string(), 443)], &[]); + let cases: Vec = requests + .into_iter() + .map(|(label, request)| { + same_probe( + format!("{name}/{label}"), + owners_in(l7_ctx(HOST, 443, BINARY), request), + ) + }) + .collect(); + let count = cases.len(); + assert_provider_parity(&ProviderScenario { + name, + host: HOST, + rules, + cedar: &cedar, + cases, + }); + count +} + +const PATHS: &[&str] = &[ + "/", + "/a", + "/a/", + "/ab", + "/a/b", + "/a/b/c", + "/a/b/c/d", + "/x/a/b", + "/a.b", + "/a/git-upload-pack", + "/a/b/git-upload-pack", + "/git-upload-pack", + "/a//b", +]; + +const PATTERNS: &[&str] = &[ + "**", + "/**", + "/a/**", + "/a/*", + "/a*", + "/a**", + "/a/**b", + "/a/**/b", + "/**/git-upload-pack", + "**/b", + "/*/b", + "/a/*/c", + "/a/**/c/**", + "/a.*", +]; + +#[test] +fn rule_path_globs_match_as_in_rego() { + let mut probes = 0; + for pattern in PATTERNS { + let requests = PATHS + .iter() + .map(|path| ((*path).to_string(), rest("GET", path))) + .collect(); + probes += sweep( + &format!("allow {pattern}"), + vec![owned(vec![get(pattern)], Vec::new())], + requests, + ); + let deny = L7DenyRule { + method: "GET".to_string(), + path: (*pattern).to_string(), + ..Default::default() + }; + let requests = PATHS + .iter() + .map(|path| ((*path).to_string(), rest("GET", path))) + .collect(); + probes += sweep( + &format!("deny {pattern}"), + vec![owned(vec![get("**")], vec![deny])], + requests, + ); + } + println!("{probes} path glob probes"); +} + +#[test] +fn endpoint_paths_match_as_in_rego() { + for endpoint_path in ["/a/**", "/a/*", "/a", "/a*/b", "**", "/a.b/**"] { + let mut rule = owned(vec![get("**")], Vec::new()); + rule.endpoints[0].path = endpoint_path.to_string(); + let requests = PATHS + .iter() + .map(|path| ((*path).to_string(), rest("GET", path))) + .collect(); + sweep( + &format!("endpoint path {endpoint_path}"), + vec![rule], + requests, + ); + } +} + +fn with_query(path: &str, query: &[(&str, &[&str])]) -> L7RequestInfo { + L7RequestInfo { + query_params: query + .iter() + .map(|(key, values)| { + ( + (*key).to_string(), + values.iter().map(ToString::to_string).collect(), + ) + }) + .collect(), + ..rest("GET", path) + } +} + +#[test] +fn query_matchers_match_as_in_rego() { + let matcher = |glob: &str, any: &[&str]| L7QueryMatcher { + glob: glob.to_string(), + any: any.iter().map(ToString::to_string).collect(), + }; + let queries: Vec<(String, L7RequestInfo)> = [ + &[][..], + &[("env", &["prod"][..])], + &[("env", &["prod-a"][..])], + &[("env", &["prod-a", "prod-b"][..])], + &[("env", &["prod-a", "dev"][..])], + &[("env", &["prod.a"][..])], + &[("env", &["staging"][..])], + &[("env", &["prod-a"][..]), ("force", &["true"][..])], + &[("env", &["prod-a"][..]), ("force", &["no", "true"][..])], + &[("force", &["false"][..])], + ] + .iter() + .map(|query| (format!("{query:?}"), with_query("/a", query))) + .collect(); + for (label, allow) in [ + ("glob", matcher("prod-*", &[])), + ("any", matcher("", &["prod-*", "staging"])), + ] { + let mut rule = get("/a"); + rule.allow + .as_mut() + .unwrap() + .query + .insert("env".to_string(), allow); + let deny = L7DenyRule { + method: "*".to_string(), + path: "**".to_string(), + query: HashMap::from([("force".to_string(), matcher("true", &[]))]), + ..Default::default() + }; + sweep( + &format!("query {label}"), + vec![owned(vec![rule], vec![deny])], + queries.clone(), + ); + } +} + +#[test] +fn methods_and_global_denies_match_as_in_rego() { + let methods = [ + "GET", "get", "HEAD", "POST", "post", "PUT", "OPTIONS", "DELETE", + ]; + let requests: Vec<_> = methods + .iter() + .map(|method| ((*method).to_string(), rest(method, "/a"))) + .collect(); + let rule = |method: &str| L7Rule { + allow: Some(L7Allow { + method: method.to_string(), + path: "**".to_string(), + ..Default::default() + }), + }; + for method in ["GET", "post", "*", "DELETE"] { + sweep( + &format!("method {method}"), + vec![owned(vec![rule(method)], Vec::new())], + requests.clone(), + ); + } + // A deny rule of another provider, without an owner, removes every owner. + let mut other = owned( + vec![get("/elsewhere")], + vec![L7DenyRule { + method: "POST".to_string(), + path: "/a".to_string(), + ..Default::default() + }], + ); + other.name = "other".to_string(); + other.endpoints[0].token_grant_owner = String::new(); + sweep( + "global deny", + vec![owned(vec![rule("*")], Vec::new()), other], + requests, + ); +} + +/// Untranslatable globs: an allow rule admits less, a deny rule denies more, +/// and a glob Rego could fail to compile admits no owner at all. +#[test] +fn untranslatable_globs_never_admit_more_than_rego() { + let cedar = allow_all(&[(HOST.to_string(), 443)], &[]); + let mut narrower = 0; + for (pattern, deny) in [ + ("/a/?", false), + ("/a/[bc]", false), + ("/a/?", true), + ("/a/[bc]", true), + ] { + let rule = if deny { + owned( + vec![get("**")], + vec![L7DenyRule { + method: "GET".to_string(), + path: pattern.to_string(), + ..Default::default() + }], + ) + } else { + owned(vec![get(pattern)], Vec::new()) + }; + let engines = Engines::load_providers(&ProviderScenario { + name: pattern, + host: HOST, + rules: vec![rule], + cedar: &cedar, + cases: Vec::new(), + }); + for path in PATHS.iter().chain(&["/a/c", "/a/bc"]) { + let probe = owners_in(l7_ctx(HOST, 443, BINARY), rest("GET", path)); + let (yaml, cedar) = engines.outcomes(HOST, &probe); + assert!( + cedar == "none" || cedar == yaml, + "{pattern} (deny: {deny}) {path}: YAML {yaml} but Cedar {cedar}" + ); + if cedar != yaml { + narrower += 1; + } + } + } + assert!(narrower > 0, "untranslatable globs admit fewer owners"); +} diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/scenarios.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/scenarios.rs new file mode 100644 index 0000000000..ca000f7f39 --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/scenarios.rs @@ -0,0 +1,500 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Hand-written parity scenarios for REST, JSON-RPC, MCP, and GraphQL. + +use super::*; + +#[test] +fn rest_parity() { + assert_parity(&Scenario { + name: "REST", + host: "api.example.com", + yaml: r#" +network_policies: + api: + name: api + endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/repos/**" } + - allow: { method: POST, path: "/repos/*/*/issues" } + - allow: { method: GET, path: "/files/report**" } + deny_rules: + - { method: "*", path: "/repos/*/*/hooks/**" } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { + context.binary_path == "/usr/bin/curl" + && ((context.method == "GET" && context.path like("/repos/**", "/")) + || (context.method == "POST" && context.path like("/repos/*/*/issues", "/")) + || (context.method == "GET" && context.path like("/files/report*", "/"))) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.path like("/repos/*/*/hooks/**", "/") }; +"#, + cases: vec![ + same("GET under /repos", rest("GET", "/repos/nvidia/openshell")), + same( + "POST to issues", + rest("POST", "/repos/nvidia/openshell/issues"), + ), + same( + "POST to pulls", + rest("POST", "/repos/nvidia/openshell/pulls"), + ), + same( + "deny rule on hooks", + rest("GET", "/repos/nvidia/openshell/hooks/1"), + ), + same("other method", rest("DELETE", "/repos/nvidia/openshell")), + same("other path", rest("GET", "/orgs/nvidia")), + same("lowercase method", rest("get", "/repos/nvidia/openshell")), + diverges( + "HEAD under a GET rule", + rest("HEAD", "/repos/nvidia/openshell"), + "YAML lets a GET rule allow HEAD; a Cedar policy must list HEAD", + ), + same("empty trailing segment", rest("GET", "/repos/")), + same("no trailing segment", rest("GET", "/repos")), + // YAML reads the rule's `report**` as `report*`, within one + // segment; the Cedar policy writes it that way. + same( + "`**` inside a segment, same segment", + rest("GET", "/files/report-2024"), + ), + same( + "`**` inside a segment, deeper path", + rest("GET", "/files/report/2024/q1"), + ), + ], + }); + // The literal translation of `report**` means something else in Cedar, + // whose `**` crosses segments, so Cedar refuses it at load. + assert_cedar_rejects( + "REST / `**` inside a segment", + r#" +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.method == "GET" && context.path like("/files/report**", "/") }; +"#, + "`**` next to a character other than the delimiter", + ); +} + +#[test] +fn json_rpc_parity() { + let response_frame = L7RequestInfo { + jsonrpc: Some(JsonRpcRequestInfo { + has_response: true, + ..jsonrpc_info(Vec::new()) + }), + ..rest("POST", "/rpc") + }; + assert_parity(&Scenario { + name: "JSON-RPC", + host: "rpc.example.com", + yaml: r" +network_policies: + rpc: + name: rpc + endpoints: + - host: rpc.example.com + port: 443 + path: /rpc + protocol: json-rpc + enforcement: enforce + rules: + - allow: { method: eth_call } + - allow: { method: eth_blockNumber } + binaries: + - { path: /usr/bin/curl } +", + cedar: r#" +@protocol("json-rpc") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"rpc.example.com:443") +when { + context.binary_path == "/usr/bin/curl" + && context.path == "/rpc" + && !context.jsonrpc_response + && ["eth_call", "eth_blockNumber"].contains(context.jsonrpc_method) +}; +"#, + cases: vec![ + same( + "listed method", + jsonrpc_request("/rpc", vec![call("eth_call", None, None)]), + ), + same( + "listed method sent with GET", + L7RequestInfo { + action: "GET".to_string(), + ..jsonrpc_request("/rpc", vec![call("eth_call", None, None)]) + }, + ), + same( + "unlisted method", + jsonrpc_request("/rpc", vec![call("eth_sendTransaction", None, None)]), + ), + same( + "other path", + jsonrpc_request("/other", vec![call("eth_call", None, None)]), + ), + same("response frame", response_frame), + ], + }); +} + +#[test] +fn mcp_parity() { + use McpMethodClassification::{Available, Extension}; + let receive_stream = L7RequestInfo { + jsonrpc: Some(JsonRpcRequestInfo { + receive_stream: true, + ..jsonrpc_info(Vec::new()) + }), + ..rest("GET", "/mcp") + }; + let response_frame = L7RequestInfo { + jsonrpc: Some(JsonRpcRequestInfo { + has_response: true, + ..jsonrpc_info(Vec::new()) + }), + ..rest("POST", "/mcp") + }; + assert_parity(&Scenario { + name: "MCP", + host: "mcp.example.com", + yaml: r#" +network_policies: + mcp: + name: mcp + endpoints: + - host: mcp.example.com + port: 443 + path: /mcp + protocol: mcp + enforcement: enforce + rules: + - allow: { method: initialize } + - allow: { method: tools/list } + - allow: { method: tools/call, tool: "github.*" } + deny_rules: + - { method: tools/call, tool: github.delete_repo } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +@protocol("mcp") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.com:443") +when { + context.binary_path == "/usr/bin/curl" + && context.path == "/mcp" + && !context.jsonrpc_response + && context.mcp_method_class == "available" + && (["initialize", "tools/list"].contains(context.jsonrpc_method) + || (context.jsonrpc_method == "tools/call" && context.mcp_tool like("github.*", "."))) +}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.com:443") +when { + context.binary_path == "/usr/bin/curl" + && context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.com:443") +when { context.jsonrpc_method == "tools/call" && context.mcp_tool == "github.delete_repo" }; +"#, + cases: vec![ + same("initialize", mcp("initialize", None, Available)), + same( + "allowed tool call sent with GET", + L7RequestInfo { + action: "GET".to_string(), + ..mcp("tools/call", Some("github.search"), Available) + }, + ), + same("tools/list", mcp("tools/list", None, Available)), + same( + "tool matching the glob", + mcp("tools/call", Some("github.search"), Available), + ), + same( + "tool glob does not cross a dot", + mcp("tools/call", Some("github.search.code"), Available), + ), + same( + "tool deny rule", + mcp("tools/call", Some("github.delete_repo"), Available), + ), + same( + "tool outside the glob", + mcp("tools/call", Some("slack.post"), Available), + ), + same( + "unlisted core method", + mcp("resources/read", None, Available), + ), + same("extension method", mcp("x/custom", None, Extension)), + same("receive stream", receive_stream), + same("response frame", response_frame.clone()), + same( + "response frame sent with GET", + L7RequestInfo { + action: "GET".to_string(), + ..response_frame + }, + ), + ], + }); +} + +#[test] +fn graphql_parity() { + let persisted = GraphqlOperationInfo { + persisted_query: true, + persisted_query_hash: Some("abc123".to_string()), + ..operation("", None, &[]) + }; + let parse_error = L7RequestInfo { + graphql: Some(GraphqlRequestInfo { + operations: Vec::new(), + error: Some("syntax error".to_string()), + }), + ..rest("POST", "/graphql") + }; + assert_parity(&Scenario { + name: "GraphQL", + host: "api.github.com", + yaml: r#" +network_policies: + github: + name: github + endpoints: + - host: api.github.com + port: 443 + path: /graphql + protocol: graphql + enforcement: enforce + rules: + - allow: { operation_type: query } + - allow: { operation_type: mutation, fields: [createIssue, commentOnIssue] } + - allow: { operation_type: subscription, operation_name: "Watch*" } + deny_rules: + - { operation_type: mutation, fields: [deleteRepository] } + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +@protocol("graphql") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { + context.binary_path == "/usr/bin/curl" + && context.path == "/graphql" + && (context.graphql_operation_type == "query" + || (context.graphql_operation_type == "mutation" + && context.graphql_fields.containsAny(["createIssue", "commentOnIssue"]) + && ["createIssue", "commentOnIssue"].containsAll(context.graphql_fields)) + || (context.graphql_operation_type == "subscription" + && context.graphql_operation_name like "Watch*")) +}; +forbid (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443") +when { + context.graphql_operation_type == "mutation" + && context.graphql_fields.containsAny(["deleteRepository"]) +}; +"#, + cases: vec![ + same( + "query", + graphql(vec![operation("query", Some("Viewer"), &["viewer"])]), + ), + same( + "query type in uppercase", + graphql(vec![operation("QUERY", None, &["viewer"])]), + ), + same( + "mutation with listed fields", + graphql(vec![operation( + "mutation", + None, + &["createIssue", "commentOnIssue"], + )]), + ), + same( + "mutation with an unlisted field", + graphql(vec![operation( + "mutation", + None, + &["createIssue", "unknownField"], + )]), + ), + same( + "mutation with a denied field", + graphql(vec![operation( + "mutation", + None, + &["createIssue", "deleteRepository"], + )]), + ), + same( + "mutation with no fields", + graphql(vec![operation("mutation", None, &[])]), + ), + same( + "subscription matching the name glob", + graphql(vec![operation( + "subscription", + Some("WatchIssues"), + &["issueAdded"], + )]), + ), + same( + "subscription outside the name glob", + graphql(vec![operation( + "subscription", + Some("Other"), + &["issueAdded"], + )]), + ), + same( + "batch with one disallowed operation", + graphql(vec![ + operation("query", None, &["viewer"]), + operation("mutation", None, &["unknownField"]), + ]), + ), + same("hash-only persisted query", graphql(vec![persisted])), + // The relay rejects a request that failed GraphQL parsing before + // policy runs; this case checks that neither engine allows it on + // its own. Rego reports conflicting deny reasons here (an + // evaluation error), which the relay would also treat as a + // denial, so the error is the expected YAML outcome. + same("parse error", parse_error).allowing_error(), + ], + }); +} + +#[test] +fn connection_dns_and_inspection_parity() { + assert_parity(&Scenario { + name: "Connections", + host: "api.example.com", + yaml: r#" +network_policies: + api: + name: api + endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + - host: "*.cdn.example.com" + port: 443 + binaries: + - { path: /usr/bin/curl } +"#, + cedar: r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { context.binary_path == "/usr/bin/curl" }; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.host like("*.cdn.example.com", ".") + && resource.port == 443 + && context.binary_path == "/usr/bin/curl" +}; +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443") +when { ["GET", "HEAD", "OPTIONS"].contains(context.method) }; +"#, + cases: vec![ + same_probe( + "declared binary connects", + Probe::Connect(network("api.example.com", 443, BINARY)), + ), + same_probe( + "other binary is refused", + Probe::Connect(network("api.example.com", 443, "/usr/bin/wget")), + ), + same_probe( + "other port is refused", + Probe::Connect(network("api.example.com", 8443, BINARY)), + ), + same_probe( + "glob host connects", + Probe::Connect(network("img.cdn.example.com", 443, BINARY)), + ), + same_probe( + "glob does not cross a label", + Probe::Connect(network("a.b.cdn.example.com", 443, BINARY)), + ), + same_probe( + "exact host is exactly declared", + Probe::ExactHost(network("api.example.com", 443, BINARY)), + ), + same_probe( + "glob host is not exactly declared", + Probe::ExactHost(network("img.cdn.example.com", 443, BINARY)), + ), + same_probe( + "inspected endpoint", + Probe::Inspection(network("api.example.com", 443, BINARY)), + ), + same_probe( + "uninspected endpoint", + Probe::Inspection(network("img.cdn.example.com", 443, BINARY)), + ), + same_probe( + "exact host is DNS eligible", + Probe::DnsEligible { + name: "api.example.com".to_string(), + port: 443, + }, + ), + same_probe( + "glob host is DNS eligible", + Probe::DnsEligible { + name: "img.cdn.example.com".to_string(), + port: 443, + }, + ), + same_probe( + "undeclared host is not DNS eligible", + Probe::DnsEligible { + name: "example.org".to_string(), + port: 443, + }, + ), + same_probe( + "read-only preset allows GET", + request_in( + l7_ctx("api.example.com", 443, BINARY), + rest("GET", "/anything"), + ), + ), + same_probe( + "read-only preset denies POST", + request_in( + l7_ctx("api.example.com", 443, BINARY), + rest("POST", "/anything"), + ), + ), + ], + }); +} diff --git a/crates/openshell-supervisor-network/src/cedar_provider_rules.rs b/crates/openshell-supervisor-network/src/cedar_provider_rules.rs new file mode 100644 index 0000000000..cacf5c088b --- /dev/null +++ b/crates/openshell-supervisor-network/src/cedar_provider_rules.rs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Provider rules of a Cedar sandbox, compiled to Cedar for token grants. +//! +//! The gateway delivers attached providers' rules to a Cedar sandbox in +//! `provider_credential_rules`, with each endpoint stamped with the owner of +//! its token grants. This module validates and normalizes them as the YAML +//! engine would load them (protocol spellings, MCP tool aliases, access +//! presets, and symlinked binaries resolved in the sandbox), then hands them +//! to [`TokenGrantRules`], which compiles them to Cedar. No Rego is involved. + +use std::collections::HashMap; + +use miette::Result; +use openshell_core::proto::{NetworkPolicyRule, SandboxPolicy as ProtoSandboxPolicy}; +use openshell_ocsf::{ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ocsf_emit}; +use openshell_policy_cedar::{ + ProviderEndpoint, ProviderPolicy, RuleMatcher, TokenGrantRules, ValueMatcher, +}; +use serde_json::Value; + +use crate::policy_data::{ + endpoint_policy_value, normalize_endpoint_protocols, normalize_l7_policy_rule_aliases, +}; + +/// Compiles provider rules for token-grant owner selection. +/// +/// Returns `None` when no provider endpoint carries an owner. Binary paths +/// are resolved in the root filesystem of `entrypoint_pid` (none when it is +/// 0), as a YAML sandbox resolves them. +/// +/// # Errors +/// +/// Returns an error when the rules fail the validation a YAML sandbox +/// applies to them, or the generated Cedar does not load. +pub fn compile( + rules: &HashMap, + entrypoint_pid: u32, +) -> Result> { + let stamped = rules + .values() + .flat_map(|rule| &rule.endpoints) + .any(|endpoint| !endpoint.token_grant_owner.is_empty()); + if !stamped { + return Ok(None); + } + let policies = provider_policies(rules, entrypoint_pid)?; + let compiled = TokenGrantRules::compile(&policies) + .map_err(|e| miette::miette!("provider credential rules failed to compile: {e}"))?; + report(&compiled); + Ok(Some(compiled)) +} + +/// Validates and normalizes `rules` into the compiler's input. +fn provider_policies( + rules: &HashMap, + entrypoint_pid: u32, +) -> Result> { + let policy = openshell_policy::validate_and_canonicalize_sandbox_policy(ProtoSandboxPolicy { + network_policies: rules.clone(), + ..Default::default() + }) + .map_err(|error| { + miette::miette!( + "provider credential rules failed validation ({} violations)", + error.violations().len() + ) + })?; + let ambiguities = openshell_policy::find_endpoint_ambiguities(&policy); + if !ambiguities.is_empty() { + return Err(miette::miette!( + "provider credential rules have {} ambiguous endpoint selectors", + ambiguities.len() + )); + } + + let network_policies: serde_json::Map = policy + .network_policies + .iter() + .map(|(key, rule)| { + let binaries: Vec = rule + .binaries + .iter() + .flat_map(|binary| { + let resolved = crate::binary_resolution::resolve_policy_binary( + &binary.path, + entrypoint_pid, + ); + std::iter::once(binary.path.clone()).chain(resolved) + }) + .map(|path| serde_json::json!({ "path": path })) + .collect(); + let endpoints: Vec = rule + .endpoints + .iter() + .map(|endpoint| endpoint_policy_value(endpoint, "")) + .collect(); + ( + key.clone(), + serde_json::json!({ "endpoints": endpoints, "binaries": binaries }), + ) + }) + .collect(); + let mut data = serde_json::json!({ "network_policies": network_policies }); + normalize_endpoint_protocols(&mut data); + let (errors, _) = crate::l7::validate_l7_policies(&data); + if !errors.is_empty() { + return Err(miette::miette!( + "provider credential rules have {} invalid L7 configurations", + errors.len() + )); + } + normalize_l7_policy_rule_aliases(&mut data); + let _ = crate::l7::expand_access_presets(&mut data); + + let mut policies: Vec = data["network_policies"] + .as_object() + .into_iter() + .flatten() + .map(|(name, policy)| ProviderPolicy { + name: name.clone(), + binaries: strings(&policy["binaries"], |binary| binary["path"].as_str()), + endpoints: policy["endpoints"] + .as_array() + .into_iter() + .flatten() + .map(provider_endpoint) + .collect(), + }) + .collect(); + policies.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(policies) +} + +/// Collects the strings `get` reads from each element of `array`. +fn strings<'a>(array: &'a Value, get: impl Fn(&'a Value) -> Option<&'a str>) -> Vec { + array + .as_array() + .into_iter() + .flatten() + .filter_map(get) + .map(str::to_string) + .collect() +} + +fn text(value: &Value, key: &str) -> String { + value[key].as_str().unwrap_or_default().to_string() +} + +/// Reads one normalized endpoint as the Rego rules read it. +fn provider_endpoint(endpoint: &Value) -> ProviderEndpoint { + ProviderEndpoint { + host: text(endpoint, "host"), + ports: endpoint["ports"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_u64) + .filter_map(|port| u16::try_from(port).ok()) + .collect(), + path: text(endpoint, "path"), + has_allowed_ips: endpoint["allowed_ips"] + .as_array() + .is_some_and(|ips| !ips.is_empty()), + protocol: text(endpoint, "protocol"), + token_grant_owner: text(endpoint, "token_grant_owner"), + mcp_allow_all_known_methods: endpoint["mcp_allow_all_known_mcp_methods"] + .as_bool() + .unwrap_or(false), + // Rego reads an allow rule's selectors under `allow`. + rules: endpoint["rules"] + .as_array() + .into_iter() + .flatten() + .filter_map(|rule| rule.get("allow")) + .map(rule_matcher) + .collect(), + deny_rules: endpoint["deny_rules"] + .as_array() + .into_iter() + .flatten() + .map(rule_matcher) + .collect(), + } +} + +fn rule_matcher(rule: &Value) -> RuleMatcher { + let selector = |key: &str| rule.get(key).and_then(Value::as_str).map(str::to_string); + RuleMatcher { + method: selector("method"), + path: selector("path"), + command: selector("command"), + operation_type: selector("operation_type"), + operation_name: selector("operation_name"), + fields: strings(&rule["fields"], Value::as_str), + query: value_matchers(&rule["query"]), + params: value_matchers(&rule["params"]), + } +} + +/// Reads matchers as `query_value_matches` does: a string is one glob, and +/// an object matches its non-empty `glob` or any of its `any` globs. +fn value_matchers(matchers: &Value) -> std::collections::BTreeMap { + matchers + .as_object() + .into_iter() + .flatten() + .map(|(key, matcher)| { + let mut globs = Vec::new(); + if let Some(glob) = matcher.as_str() { + globs.push(glob.to_string()); + } + if let Some(glob) = matcher["glob"].as_str().filter(|glob| !glob.is_empty()) { + globs.push(glob.to_string()); + } + globs.extend(strings(&matcher["any"], Value::as_str)); + (key.clone(), ValueMatcher { globs }) + }) + .collect() +} + +/// Reports provider selectors compiled other than exactly. +fn report(compiled: &TokenGrantRules) { + if compiled.untranslated().is_empty() { + return; + } + let notes: Vec = compiled + .untranslated() + .iter() + .map(ToString::to_string) + .collect(); + let (severity, summary) = if compiled.fail_closed_reason().is_some() { + ( + SeverityId::Medium, + "no token grant is admitted for any request", + ) + } else { + ( + SeverityId::Low, + "some token grants are admitted for fewer requests", + ) + }; + tracing::warn!(rules = ?notes, "provider rules not translated exactly to Cedar; {summary}"); + ocsf_emit!( + ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(severity) + .status(StatusId::Success) + .state(StateId::Enabled, "provider token grants compiled") + .unmapped("untranslated_provider_rules", serde_json::json!(notes)) + .message(format!( + "CONFIG:TOKEN_GRANTS provider rules not translated exactly to Cedar [count:{}]; {summary}", + notes.len() + )) + .build() + ); +} diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index 5ae44dfff9..c356b5ddc9 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -27,12 +27,14 @@ use openshell_ocsf::{ }; use tokio::sync::mpsc::UnboundedSender; +use crate::cedar_only::CedarOnlyEngine; use crate::identity::BinaryIdentityCache; use crate::l7::tls::{ CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, write_ca_files, }; use crate::opa::OpaEngine; +use crate::policy_engine::PolicyEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::ProxyHandle; @@ -95,7 +97,11 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result, } -/// Parse an L7 endpoint config from a regorus Value (returned by Rego query). +/// Parse an L7 endpoint config from a policy endpoint config. /// -/// The value is expected to be the raw endpoint object from the Rego data, -/// containing fields: `protocol`, optionally `tls`, `enforcement`, and the -/// canonical `mcp_versions` array for MCP endpoints. -pub fn parse_l7_config(val: ®orus::Value) -> Option { - let protocol_val = get_object_str(val, "protocol")?; - let protocol = L7Protocol::parse(&protocol_val)?; - - let tls_value = get_object_str(val, "tls").unwrap_or_default(); - let enforcement_value = get_object_str(val, "enforcement").unwrap_or_default(); - let access_value = get_object_str(val, "access").unwrap_or_default(); - if !validate_endpoint_modes(&tls_value, &enforcement_value, &access_value).is_empty() { +/// Returns `None` for an endpoint without an inspected L7 protocol, or +/// whose settings the runtime rejects: an unknown `tls`, `enforcement`, +/// `access`, or `credential_signing` value, a signing mode without its +/// service, or an MCP endpoint without a canonical revision list. +pub fn parse_l7_config(config: &EndpointConfig) -> Option { + let protocol = config.protocol.l7()?; + + if config.tls == TlsSetting::Unknown + || config.enforcement == EnforcementSetting::Unknown + || config.access == AccessSetting::Unknown + { return None; } - let tls = match tls_value.as_str() { - "skip" => TlsMode::Skip, - "" => TlsMode::Auto, - _ => unreachable!("endpoint modes were validated above"), + let tls = match config.tls { + TlsSetting::Skip => TlsMode::Skip, + TlsSetting::Auto => TlsMode::Auto, + TlsSetting::Unknown => unreachable!("endpoint modes were validated above"), }; - let enforcement = match enforcement_value.as_str() { - "enforce" => EnforcementMode::Enforce, - "" | "audit" => EnforcementMode::Audit, - _ => unreachable!("endpoint modes were validated above"), + let enforcement = match config.enforcement { + EnforcementSetting::Enforce => EnforcementMode::Enforce, + EnforcementSetting::Default | EnforcementSetting::Audit => EnforcementMode::Audit, + EnforcementSetting::Unknown => unreachable!("endpoint modes were validated above"), }; - let allow_encoded_slash = get_object_bool(val, "allow_encoded_slash").unwrap_or(false); - let websocket_credential_rewrite = - get_object_bool(val, "websocket_credential_rewrite").unwrap_or(false); - let request_body_credential_rewrite = - get_object_bool(val, "request_body_credential_rewrite").unwrap_or(false); - let allow_uninspected_credentials = - get_object_bool(val, "allow_uninspected_credentials").unwrap_or(false); - let provider_credentialed = get_object_bool(val, "provider_credentialed").unwrap_or(false); - let endpoint_id = get_object_str(val, "endpoint_id").unwrap_or_default(); - let policy_hash = get_object_str(val, "policy_hash").unwrap_or_default(); - let websocket_graphql_policy = - protocol == L7Protocol::Websocket && endpoint_has_graphql_policy(val); - let graphql_max_body_bytes = get_object_u64(val, "graphql_max_body_bytes") - .and_then(|v| usize::try_from(v).ok()) + // A YAML endpoint opts in through its GraphQL rules or persisted-query + // settings. A Cedar endpoint carries no rules, so the Cedar engine sets + // the flag for a `websocket-graphql` endpoint. + let websocket_graphql_policy = protocol == L7Protocol::Websocket + && (config.websocket_graphql_policy || endpoint_has_graphql_policy(config)); + let graphql_max_body_bytes = usize::try_from(config.graphql_max_body_bytes) + .ok() .filter(|v| *v > 0) .unwrap_or(graphql::DEFAULT_MAX_BODY_BYTES); - let json_rpc_max_body_bytes = get_object_u64(val, "json_rpc_max_body_bytes") - .and_then(|v| usize::try_from(v).ok()) + let json_rpc_max_body_bytes = usize::try_from(config.json_rpc_max_body_bytes) + .ok() .filter(|v| *v > 0) .unwrap_or(jsonrpc::DEFAULT_MAX_BODY_BYTES); - let mcp_strict_tool_names = protocol == L7Protocol::Mcp - && get_object_bool(val, "mcp_strict_tool_names").unwrap_or(true); + let mcp_strict_tool_names = + protocol == L7Protocol::Mcp && config.mcp_strict_tool_names.unwrap_or(true); let mcp_versions = if protocol == L7Protocol::Mcp { - parse_canonical_mcp_versions(val)? + parse_canonical_mcp_versions(&config.mcp_versions)? } else { Vec::new() }; - let credential_signing = match get_object_str(val, "credential_signing").as_deref() { - Some("sigv4") => CredentialSigning::SigV4, - Some("sigv4:body") => CredentialSigning::SigV4Body, - Some("sigv4:no_body") => CredentialSigning::SigV4NoBody, - Some(other) if !other.is_empty() => { + let credential_signing = match config.credential_signing.as_str() { + "sigv4" => CredentialSigning::SigV4, + "sigv4:body" => CredentialSigning::SigV4Body, + "sigv4:no_body" => CredentialSigning::SigV4NoBody, + "" => CredentialSigning::None, + other => { let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::High) .status(openshell_ocsf::StatusId::Failure) @@ -389,13 +386,9 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { openshell_ocsf::ocsf_emit!(event); return None; } - _ => CredentialSigning::None, }; - let signing_service = get_object_str(val, "signing_service").unwrap_or_default(); - let signing_region = get_object_str(val, "signing_region").unwrap_or_default(); - - if credential_signing.is_sigv4() && signing_service.is_empty() { + if credential_signing.is_sigv4() && config.signing_service.is_empty() { let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::High) .status(openshell_ocsf::StatusId::Failure) @@ -408,24 +401,24 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { Some(L7EndpointConfig { protocol, - endpoint_id, - policy_hash, - path: get_object_str(val, "path").unwrap_or_default(), + endpoint_id: config.endpoint_id.clone(), + policy_hash: config.policy_hash.clone(), + path: config.path.clone(), tls, enforcement, graphql_max_body_bytes, json_rpc_max_body_bytes, mcp_strict_tool_names, mcp_versions, - allow_encoded_slash, - websocket_credential_rewrite, - request_body_credential_rewrite, - allow_uninspected_credentials, - provider_credentialed, + allow_encoded_slash: config.allow_encoded_slash, + websocket_credential_rewrite: config.websocket_credential_rewrite, + request_body_credential_rewrite: config.request_body_credential_rewrite, + allow_uninspected_credentials: config.allow_uninspected_credentials, + provider_credentialed: config.provider_credentialed, websocket_graphql_policy, credential_signing, - signing_service, - signing_region, + signing_service: config.signing_service.clone(), + signing_region: config.signing_region.clone(), }) } @@ -473,10 +466,10 @@ pub fn endpoint_path_matches(pattern: &str, path: &str) -> bool { /// /// Used to check for `tls: skip` even on L4-only endpoints (no `protocol` /// field) that explicitly opt out of TLS auto-detection. -pub fn parse_tls_mode(val: ®orus::Value) -> TlsMode { - match get_object_str(val, "tls").as_deref() { - Some("skip") => TlsMode::Skip, - _ => TlsMode::Auto, +pub fn parse_tls_mode(config: &EndpointConfig) -> TlsMode { + match config.tls { + TlsSetting::Skip => TlsMode::Skip, + TlsSetting::Auto | TlsSetting::Unknown => TlsMode::Auto, } } @@ -498,65 +491,20 @@ impl EndpointCredentialGuard { } } -pub fn parse_endpoint_credential_guard(val: ®orus::Value) -> EndpointCredentialGuard { +pub fn parse_endpoint_credential_guard(config: &EndpointConfig) -> EndpointCredentialGuard { EndpointCredentialGuard { - provider_credentialed: get_object_bool(val, "provider_credentialed").unwrap_or(false), - allow_uninspected_credentials: get_object_bool(val, "allow_uninspected_credentials") - .unwrap_or(false), - has_l7_protocol: get_object_str(val, "protocol").is_some(), - tls: parse_tls_mode(val), - } -} - -/// Extract a bool value from a regorus object. Returns `None` when the key -/// is absent or not a boolean. -fn get_object_bool(val: ®orus::Value, key: &str) -> Option { - let key_val = regorus::Value::String(key.into()); - match val { - regorus::Value::Object(map) => match map.get(&key_val) { - Some(regorus::Value::Bool(b)) => Some(*b), - _ => None, - }, - _ => None, - } -} - -fn get_object_u64(val: ®orus::Value, key: &str) -> Option { - let key_val = regorus::Value::String(key.into()); - match val { - regorus::Value::Object(map) => match map.get(&key_val) { - Some(regorus::Value::Number(n)) => n.as_u64(), - _ => None, - }, - _ => None, + provider_credentialed: config.provider_credentialed, + allow_uninspected_credentials: config.allow_uninspected_credentials, + // Any declared protocol counts, including explicit TCP. + has_l7_protocol: config.protocol != EndpointProtocol::None, + tls: parse_tls_mode(config), } } -/// Extract a string value from a regorus object. -fn get_object_str(val: ®orus::Value, key: &str) -> Option { - let key_val = regorus::Value::String(key.into()); - match val { - regorus::Value::Object(map) => match map.get(&key_val) { - Some(regorus::Value::String(s)) => { - let s = s.to_string(); - if s.is_empty() { None } else { Some(s) } - } - _ => None, - }, - _ => None, - } -} - -fn parse_canonical_mcp_versions(val: ®orus::Value) -> Option> { - let regorus::Value::Array(values) = get_object_value(val, "mcp_versions")? else { - return None; - }; - let versions = values +fn parse_canonical_mcp_versions(labels: &[String]) -> Option> { + let versions = labels .iter() - .map(|value| match value { - regorus::Value::String(value) => value.parse::().ok(), - _ => None, - }) + .map(|label| label.parse::().ok()) .collect::>>()?; // Both policy ingress paths promise a non-empty, strictly ordered list. @@ -568,58 +516,18 @@ fn parse_canonical_mcp_versions(val: ®orus::Value) -> Option bool { - has_non_empty_object_field(val, "graphql_persisted_queries") - || has_graphql_persisted_query_mode(val) - || rules_have_graphql_policy(val, "rules", true) - || rules_have_graphql_policy(val, "deny_rules", false) -} - -fn rules_have_graphql_policy(val: ®orus::Value, key: &str, allow_wrapped: bool) -> bool { - let Some(regorus::Value::Array(rules)) = get_object_value(val, key) else { - return false; - }; - rules.iter().any(|rule| { - let rule = if allow_wrapped { - get_object_value(rule, "allow").unwrap_or(rule) - } else { - rule - }; - has_graphql_rule_fields(rule) - }) -} - -fn has_graphql_rule_fields(val: ®orus::Value) -> bool { - has_non_empty_string_field(val, "operation_type") - || has_non_empty_string_field(val, "operation_name") - || has_non_empty_array_field(val, "fields") -} - -fn has_non_empty_string_field(val: ®orus::Value, key: &str) -> bool { - matches!(get_object_value(val, key), Some(regorus::Value::String(s)) if !s.is_empty()) -} - -fn has_non_empty_array_field(val: ®orus::Value, key: &str) -> bool { - matches!(get_object_value(val, key), Some(regorus::Value::Array(values)) if !values.is_empty()) -} - -fn has_non_empty_object_field(val: ®orus::Value, key: &str) -> bool { - matches!(get_object_value(val, key), Some(regorus::Value::Object(values)) if !values.is_empty()) -} - -fn has_graphql_persisted_query_mode(val: ®orus::Value) -> bool { - matches!( - get_object_value(val, "persisted_queries"), - Some(regorus::Value::String(mode)) if !mode.is_empty() && mode.as_ref() != "deny" - ) +fn endpoint_has_graphql_policy(config: &EndpointConfig) -> bool { + !config.graphql_persisted_queries.is_empty() + || has_graphql_persisted_query_mode(config) + || config + .rules + .iter() + .any(|rule| rule.graphql_selectors().is_set()) + || config.deny_rules.iter().any(|rule| rule.selectors.is_set()) } -fn get_object_value<'a>(val: &'a regorus::Value, key: &str) -> Option<&'a regorus::Value> { - let key_val = regorus::Value::String(key.into()); - match val { - regorus::Value::Object(map) => map.get(&key_val), - _ => None, - } +fn has_graphql_persisted_query_mode(config: &EndpointConfig) -> bool { + !config.persisted_queries.is_empty() && config.persisted_queries != "deny" } /// Check a glob pattern for obvious syntax issues. @@ -1952,7 +1860,7 @@ mod tests { #[test] fn parse_l7_config_rest_enforce() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "enforcement": "enforce", "host": "api.example.com", "port": 443}"#, ) .unwrap(); @@ -1964,7 +1872,7 @@ mod tests { #[test] fn parse_l7_config_defaults() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "host": "api.example.com", "port": 80}"#, ) .unwrap(); @@ -1997,7 +1905,7 @@ mod tests { .await .expect("install MCP test inventory"); let _reset = receiver.recv().await.expect("inventory reset"); - let value = regorus::Value::from_json_str(&format!( + let value = serde_json::from_str::(&format!( r#"{{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"{endpoint_id}","policy_hash":"policy","provider_credentialed":true}}"# )) .expect("parse MCP config JSON"); @@ -2039,7 +1947,7 @@ mod tests { .await .expect("install first policy inventory"); assert!(tracker.apply(receiver.recv().await.expect("first inventory reset"))); - let value = regorus::Value::from_json_str( + let value = serde_json::from_str::( r#"{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"endpoint:v1:shared","policy_hash":"policy-a"}"#, ) .expect("parse first policy selection"); @@ -2100,7 +2008,7 @@ mod tests { assert!(tracker.apply(receiver.recv().await.expect("first inventory reset"))); // The runtime can install the new policy before its inventory reset. // The new policy's result must not be attributed to the old inventory. - let value = regorus::Value::from_json_str( + let value = serde_json::from_str::( r#"{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"endpoint:v1:shared","policy_hash":"policy-b"}"#, ) .expect("parse replacement policy selection"); @@ -2148,7 +2056,7 @@ mod tests { .expect("install test inventory"); assert!(tracker.apply(receiver.recv().await.expect("inventory reset"))); let context = sender.capture().expect("capture installed authority"); - let value = regorus::Value::from_json_str( + let value = serde_json::from_str::( r#"{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"endpoint:v1:shared","policy_hash":"policy"}"#, ) .expect("parse selected MCP policy"); @@ -2231,7 +2139,7 @@ mod tests { ) .await .expect("install MCP test configuration"); - let value = regorus::Value::from_json_str( + let value = serde_json::from_str::( r#"{"protocol":"rest","endpoint_id":"endpoint:v1:not-mcp"}"#, ) .expect("parse REST config JSON"); @@ -2241,7 +2149,7 @@ mod tests { #[test] fn parse_l7_config_rejects_unknown_enforcement() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "enforcement": "enforc", "access": "read-only", "host": "api.example.com", "port": 443}"#, ) .unwrap(); @@ -2251,7 +2159,7 @@ mod tests { #[test] fn parse_credential_signing_sigv4() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "credential_signing": "sigv4", "signing_service": "bedrock", "host": "bedrock.us-east-1.amazonaws.com", "port": 443}"#, ).unwrap(); let config = parse_l7_config(&val).unwrap(); @@ -2261,7 +2169,7 @@ mod tests { #[test] fn parse_credential_signing_sigv4_body() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "credential_signing": "sigv4:body", "signing_service": "bedrock", "host": "bedrock.us-east-1.amazonaws.com", "port": 443}"#, ).unwrap(); let config = parse_l7_config(&val).unwrap(); @@ -2271,7 +2179,7 @@ mod tests { #[test] fn parse_credential_signing_sigv4_no_body() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "credential_signing": "sigv4:no_body", "signing_service": "s3", "host": "s3.us-east-1.amazonaws.com", "port": 443}"#, ).unwrap(); let config = parse_l7_config(&val).unwrap(); @@ -2286,7 +2194,7 @@ mod tests { #[test] fn parse_l7_config_websocket_protocol() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "websocket", "host": "gateway.example.com", "port": 443}"#, ) .unwrap(); @@ -2296,7 +2204,7 @@ mod tests { #[test] fn parse_l7_config_skip() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "tls": "skip", "host": "api.example.com", "port": 443}"#, ) .unwrap(); @@ -2307,20 +2215,21 @@ mod tests { #[test] fn parse_l7_config_no_protocol() { let val = - regorus::Value::from_json_str(r#"{"host": "api.example.com", "port": 443}"#).unwrap(); + serde_json::from_str::(r#"{"host": "api.example.com", "port": 443}"#) + .unwrap(); assert!(parse_l7_config(&val).is_none()); } #[test] fn parse_endpoint_credential_guard_handles_l4_and_opt_in() { - let guarded = regorus::Value::from_json_str( + let guarded = serde_json::from_str::( r#"{"host":"api.example.com","ports":[443],"provider_credentialed":true}"#, ) .unwrap(); let guard = parse_endpoint_credential_guard(&guarded); assert!(guard.blocks_connect()); - let opted_in = regorus::Value::from_json_str( + let opted_in = serde_json::from_str::( r#"{"host":"api.example.com","ports":[443],"provider_credentialed":true,"allow_uninspected_credentials":true}"#, ) .unwrap(); @@ -2329,7 +2238,7 @@ mod tests { #[test] fn generic_resolver_enables_rest_body_backstop_without_provider_marker() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol":"rest","host":"api.example.com","ports":[443]}"#, ) .unwrap(); @@ -2337,7 +2246,7 @@ mod tests { assert!(!config.deny_uninspected_body_credentials(false)); assert!(config.deny_uninspected_body_credentials(true)); - let opted_in = regorus::Value::from_json_str( + let opted_in = serde_json::from_str::( r#"{"protocol":"rest","host":"api.example.com","ports":[443],"allow_uninspected_credentials":true}"#, ) .unwrap(); @@ -2350,7 +2259,7 @@ mod tests { #[test] fn parse_l7_config_allow_encoded_slash_defaults_false() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "host": "api.example.com", "port": 443}"#, ) .unwrap(); @@ -2360,7 +2269,7 @@ mod tests { #[test] fn parse_l7_config_allow_encoded_slash_opt_in() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "host": "gitlab.example.com", "port": 443, "allow_encoded_slash": true}"#, ) .unwrap(); @@ -2370,7 +2279,7 @@ mod tests { #[test] fn parse_l7_config_mcp_strict_tool_names_defaults_true() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "mcp", "host": "mcp.example.com", "port": 443, "mcp_versions": ["2025-11-25"]}"#, ) .unwrap(); @@ -2380,7 +2289,7 @@ mod tests { #[test] fn parse_l7_config_mcp_strict_tool_names_can_disable() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "mcp", "host": "mcp.example.com", "port": 443, "mcp_strict_tool_names": false, "mcp_versions": ["2025-11-25"]}"#, ) .unwrap(); @@ -2390,7 +2299,7 @@ mod tests { #[test] fn parse_l7_config_requires_canonical_mcp_versions() { - let explicit = regorus::Value::from_json_str( + let explicit = serde_json::from_str::( r#"{"protocol": "mcp", "mcp_versions": ["2025-03-26", "2025-11-25"]}"#, ) .unwrap(); @@ -2406,21 +2315,25 @@ mod tests { r#"{"protocol": "mcp"}"#, r#"{"protocol": "mcp", "mcp_versions": []}"#, r#"{"protocol": "mcp", "mcp_versions": ["2026-01-01"]}"#, - r#"{"protocol": "mcp", "mcp_versions": [1]}"#, r#"{"protocol": "mcp", "mcp_versions": ["2025-11-25", "2025-11-25"]}"#, r#"{"protocol": "mcp", "mcp_versions": ["2025-11-25", "2025-03-26"]}"#, ] { - let value = regorus::Value::from_json_str(invalid).unwrap(); + let value = serde_json::from_str::(invalid).unwrap(); assert!( parse_l7_config(&value).is_none(), "non-canonical MCP runtime config must be rejected: {invalid}" ); } + // A non-string revision is not an endpoint config at all. + assert!( + serde_json::from_str::(r#"{"protocol": "mcp", "mcp_versions": [1]}"#) + .is_err() + ); } #[test] fn parse_l7_config_keeps_mcp_versions_off_other_protocols() { - let value = regorus::Value::from_json_str( + let value = serde_json::from_str::( r#"{"protocol": "json-rpc", "mcp_versions": ["2025-11-25"]}"#, ) .unwrap(); @@ -2430,7 +2343,7 @@ mod tests { #[test] fn parse_l7_config_websocket_credential_rewrite_defaults_false() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "host": "gateway.example.com", "port": 443}"#, ) .unwrap(); @@ -2440,7 +2353,7 @@ mod tests { #[test] fn parse_l7_config_websocket_credential_rewrite_opt_in() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "host": "gateway.example.com", "port": 443, "websocket_credential_rewrite": true}"#, ) .unwrap(); @@ -2450,7 +2363,7 @@ mod tests { #[test] fn parse_l7_config_request_body_credential_rewrite_defaults_false() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "host": "slack.com", "port": 443}"#, ) .unwrap(); @@ -2460,7 +2373,7 @@ mod tests { #[test] fn parse_l7_config_request_body_credential_rewrite_opt_in() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "rest", "host": "slack.com", "port": 443, "request_body_credential_rewrite": true}"#, ) .unwrap(); @@ -2470,7 +2383,7 @@ mod tests { #[test] fn parse_l7_config_websocket_graphql_policy_defaults_false() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "websocket", "host": "gateway.example.com", "port": 443, "rules": [{"allow": {"method": "GET", "path": "/graphql"}}, {"allow": {"method": "WEBSOCKET_TEXT", "path": "/graphql"}}]}"#, ) .unwrap(); @@ -2480,7 +2393,7 @@ mod tests { #[test] fn parse_l7_config_websocket_graphql_policy_detects_operation_rules() { - let val = regorus::Value::from_json_str( + let val = serde_json::from_str::( r#"{"protocol": "websocket", "host": "gateway.example.com", "port": 443, "rules": [{"allow": {"method": "GET", "path": "/graphql"}}, {"allow": {"operation_type": "subscription", "fields": ["messageAdded"]}}]}"#, ) .unwrap(); diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 9603f65a02..5785e98fd6 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -19,7 +19,11 @@ use crate::l7::middleware::{ use crate::l7::provider::{L7Provider, RelayOutcome}; use crate::l7::rest::WebSocketExtensionMode; use crate::l7::{EndpointObserver, EnforcementMode, L7EndpointConfig, L7Protocol, L7RequestInfo}; -use crate::opa::{PolicyGenerationGuard, TunnelPolicyEngine}; +// Test fixtures in child modules build the engine their tunnels pin to. +#[cfg(test)] +use crate::policy_engine::PolicyEngine; +use crate::policy_engine::{PassthroughMiddleware, TunnelPolicyEngine}; +use crate::policy_types::PolicyGenerationGuard; use miette::{IntoDiagnostic, Result, miette}; use openshell_core::activity::{ActivitySender, try_record_activity}; use openshell_core::endpoint_status::{EndpointObservationSender, EndpointResult}; @@ -3103,14 +3107,14 @@ pub fn evaluate_l7_request( && !jsonrpc.calls.is_empty() { if jsonrpc.has_response { - let (allowed, reason) = evaluate_l7_request_once(engine, ctx, request)?; + let (allowed, reason) = engine.evaluate_request(ctx, request)?; if !allowed { return Ok((false, reason)); } } for call in &jsonrpc.calls { let item_request = jsonrpc_request_for_call(request, call); - let (allowed, reason) = evaluate_l7_request_once(engine, ctx, &item_request)?; + let (allowed, reason) = engine.evaluate_request(ctx, &item_request)?; if !allowed { return Ok((false, reason)); } @@ -3118,7 +3122,7 @@ pub fn evaluate_l7_request( return Ok((true, String::new())); } - evaluate_l7_request_once(engine, ctx, request) + engine.evaluate_request(ctx, request) } fn evaluate_jsonrpc_l7_request_for_log( @@ -3128,7 +3132,7 @@ fn evaluate_jsonrpc_l7_request_for_log( jsonrpc: &crate::l7::jsonrpc::JsonRpcRequestInfo, ) -> Result { if jsonrpc.has_response { - let (allowed, reason) = evaluate_l7_request_once(engine, ctx, request)?; + let (allowed, reason) = engine.evaluate_request(ctx, request)?; if !allowed || !jsonrpc.is_batch || jsonrpc.calls.is_empty() { return Ok(JsonRpcEvaluation { allowed, @@ -3143,7 +3147,7 @@ fn evaluate_jsonrpc_l7_request_for_log( let mut first_denied_reason = None; for call in &jsonrpc.calls { let item_request = jsonrpc_request_for_call(request, call); - let (allowed, reason) = evaluate_l7_request_once(engine, ctx, &item_request)?; + let (allowed, reason) = engine.evaluate_request(ctx, &item_request)?; if !allowed { if first_denied_reason.is_none() { first_denied_reason = Some(reason); @@ -3175,7 +3179,7 @@ fn evaluate_jsonrpc_l7_request_for_log( }); } - let (allowed, reason) = evaluate_l7_request_once(engine, ctx, request)?; + let (allowed, reason) = engine.evaluate_request(ctx, request)?; Ok(JsonRpcEvaluation { allowed, reason, @@ -3334,7 +3338,9 @@ fn emit_transformed_body_decision( ocsf_emit!(event); } -fn jsonrpc_policy_input(info: &crate::l7::jsonrpc::JsonRpcRequestInfo) -> serde_json::Value { +pub(crate) fn jsonrpc_policy_input( + info: &crate::l7::jsonrpc::JsonRpcRequestInfo, +) -> serde_json::Value { let call = if info.is_batch { None } else { @@ -3359,7 +3365,7 @@ fn jsonrpc_policy_input(info: &crate::l7::jsonrpc::JsonRpcRequestInfo) -> serde_ /// A batch may be allowed by several endpoints, but one credential owner must /// admit every member before its token can authenticate the combined request. -fn admitted_token_grant_owners( +pub(crate) fn admitted_token_grant_owners( engine: &TunnelPolicyEngine, ctx: &L7EvalContext, request: &L7RequestInfo, @@ -3395,30 +3401,13 @@ fn admitted_token_grant_owners_once( ctx: &L7EvalContext, request: &L7RequestInfo, ) -> Result> { - if engine.is_stale() { - return Err(miette!("policy changed before token grant selection")); - } - let mut engine = engine - .engine() - .lock() - .map_err(|_| miette!("OPA engine lock poisoned"))?; - crate::opa::set_regorus_input(&mut engine, l7_request_policy_input(ctx, request))?; - let owners = engine - .eval_rule("data.openshell.sandbox.allowed_token_grant_owners".into()) - .map_err(|error| miette!("{error}"))?; - let regorus::Value::Array(owners) = owners else { - return Err(miette!("invalid credential owner admission result")); - }; - owners - .iter() - .map(|owner| match owner { - regorus::Value::String(owner) => Ok(owner.to_string()), - _ => Err(miette!("invalid credential owner identity")), - }) - .collect() + engine.admitted_token_grant_owners(ctx, request) } -fn l7_request_policy_input(ctx: &L7EvalContext, request: &L7RequestInfo) -> serde_json::Value { +pub(crate) fn l7_request_policy_input( + ctx: &L7EvalContext, + request: &L7RequestInfo, +) -> serde_json::Value { serde_json::json!({ "network": { "host": ctx.host, "port": ctx.port }, "exec": { @@ -3436,60 +3425,20 @@ fn l7_request_policy_input(ctx: &L7EvalContext, request: &L7RequestInfo) -> serd }) } -fn evaluate_l7_request_once( - engine: &TunnelPolicyEngine, - ctx: &L7EvalContext, - request: &L7RequestInfo, -) -> Result<(bool, String)> { - if engine.is_stale() { - return Err(miette!( - "L7 tunnel policy generation is stale [captured_generation:{} current_generation:{}]", - engine.captured_generation(), - engine.current_generation(), - )); - } - - let input = l7_request_policy_input(ctx, request); - - let mut engine = engine - .engine() - .lock() - .map_err(|_| miette!("OPA engine lock poisoned"))?; - - crate::opa::set_regorus_input(&mut engine, input)?; - - let allowed = engine - .eval_rule("data.openshell.sandbox.allow_request".into()) - .map_err(|e| miette!("{e}"))?; - let allowed = allowed == regorus::Value::from(true); - - let reason = if allowed { - String::new() - } else { - let val = engine - .eval_rule("data.openshell.sandbox.request_deny_reason".into()) - .map_err(|e| miette!("{e}"))?; - match val { - regorus::Value::String(s) => s.to_string(), - regorus::Value::Undefined => "request denied by policy".to_string(), - other => other.to_string(), - } - }; - - Ok((allowed, reason)) -} - /// Relay HTTP traffic with credential injection only (no L7 OPA evaluation). /// /// Used when TLS is auto-terminated but no L7 policy (`protocol` + `access`/`rules`) /// is configured. Parses HTTP requests minimally to rewrite credential /// placeholders and log requests for observability, then forwards everything. +/// +/// `middleware`, when given, is where each request's middleware chain is +/// selected from; see [`PassthroughMiddleware`]. pub async fn relay_passthrough_with_credentials( client: &mut C, upstream: &mut U, ctx: &L7EvalContext, generation_guard: &PolicyGenerationGuard, - middleware_engine: Option<&crate::opa::OpaEngine>, + middleware: Option<&PassthroughMiddleware<'_>>, ) -> Result<()> where C: AsyncRead + AsyncWrite + Unpin + Send, @@ -3569,13 +3518,13 @@ where let request_id = uuid::Uuid::new_v4().to_string(); let mut response_selection = None; - let req = if let Some(engine) = middleware_engine { + let req = if let Some(middleware) = middleware { let input = middleware_network_input(ctx); - let (chain, generation) = engine.query_middleware_chain_with_generation(&input)?; - if generation != generation_guard.captured_generation() { + // Stop if the tunnel's policy is stale or, for a live selection, + // the middleware changed between the selection and the check. + let Some((chain, runner)) = middleware.select(&input, generation_guard)? else { return Ok(()); - } - let runner = engine.middleware_runner()?; + }; let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( request_id.clone(), chain, @@ -3841,7 +3790,7 @@ mod tests { receiver.recv().await, Some(EndpointStatusCommand::Reset { .. }) )); - let value = regorus::Value::from_json_str( + let value = serde_json::from_str::( r#"{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"endpoint:v1:body-test","policy_hash":"policy","provider_credentialed":true}"#, ) .expect("parse endpoint configuration"); @@ -7754,14 +7703,17 @@ network_policies: let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); - let engine_task = Arc::clone(&engine); + let engine_task = PolicyEngine::from(Arc::clone(&engine)); let relay = tokio::spawn(async move { + let middleware = engine_task + .passthrough_middleware() + .expect("passthrough middleware"); relay_passthrough_with_credentials( &mut relay_client, &mut relay_upstream, &ctx, &generation_guard, - Some(engine_task.as_ref()), + Some(&middleware), ) .await }); diff --git a/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests.rs b/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests.rs index eb34e325b9..f5191fc2f7 100644 --- a/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests.rs +++ b/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests.rs @@ -279,7 +279,7 @@ impl Fixture { body_pending: Option>, ) -> Connection { let configs = self.configs.clone(); - let middleware_engine = self.engine.clone(); + let middleware_engine = PolicyEngine::from(self.engine.clone()); let engine = self .engine .clone_engine_for_tunnel(self.generation) diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 7b85942666..154f056247 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -6189,7 +6189,7 @@ mod tests { .expect("install MCP test inventory"); let reset = receiver.recv().await.expect("inventory reset"); assert!(matches!(reset, EndpointStatusCommand::Reset { .. })); - let value = regorus::Value::from_json_str(&format!( + let value = serde_json::from_str::(&format!( r#"{{"protocol":"mcp","mcp_versions":["2025-11-25"],"endpoint_id":"{endpoint_id}","policy_hash":"policy","provider_credentialed":true}}"# )) .expect("parse MCP config JSON"); @@ -6204,7 +6204,7 @@ mod tests { for disconnect_client in [false, true] { let (observer, mut receiver) = test_endpoint_observer().await; let config = crate::l7::parse_l7_config( - ®orus::Value::from_json_str( + &serde_json::from_str::( r#"{"protocol":"mcp","mcp_versions":["2025-11-25"]}"#, ) .expect("parse MCP config JSON"), diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index d9fcd004b6..fbaef391ba 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -5,20 +5,30 @@ //! Networking component of the `OpenShell` supervisor. //! -//! Owns the egress proxy, L7 enforcement, OPA policy engine, identity cache, -//! TLS interception, and credential injection. The denial-event channel is +//! Owns the egress proxy, L7 enforcement, the OPA and Cedar policy engines, +//! identity cache, TLS interception, and credential injection. The denial-event channel is //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. +mod binary_resolution; +pub mod cedar_only; +#[cfg(test)] +mod cedar_parity_tests; +mod cedar_provider_rules; mod google_cloud_metadata; #[cfg(target_os = "windows")] pub mod host; pub mod identity; pub mod identity_source; pub mod l7; +mod network_middleware; pub mod opa; +pub(crate) mod policy_data; pub(crate) mod policy_dns; +pub mod policy_engine; pub mod policy_local; +pub mod policy_types; +pub mod policy_validation; pub mod procfs; pub mod proxy; pub mod run; diff --git a/crates/openshell-supervisor-network/src/network_middleware.rs b/crates/openshell-supervisor-network/src/network_middleware.rs new file mode 100644 index 0000000000..95cae44b5a --- /dev/null +++ b/crates/openshell-supervisor-network/src/network_middleware.rs @@ -0,0 +1,475 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Engine-neutral network middleware selection and state. +//! +//! [`MiddlewareConfigs`] holds one policy revision's `network_middlewares` +//! and selects the ordered chain for a destination host in Rust, with the +//! same host-selector semantics as endpoint admission. Both engines use it: +//! a YAML sandbox converts the configs out of its Rego data, and a Cedar +//! sandbox builds them from the policy proto. +//! +//! [`MiddlewareState`] is a Cedar sandbox's live middleware: the active +//! configs, the connected service registry, and a generation counter that +//! each middleware change and each committed runtime configuration advance. +//! It replaces the empty-policy OPA engine Cedar sandboxes once carried for +//! this purpose, so a Cedar sandbox evaluates no Rego. +//! +//! Configs are [`MiddlewareConfig`] values: the OPA engine converts its Rego +//! data into them, and the Cedar engine builds them from the proto. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +use miette::Result; +use openshell_core::host_pattern::HostSelector; +use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; +use openshell_supervisor_middleware::{ChainEntry, ChainRunner, MiddlewareRegistry}; +use tokio::sync::watch; + +use crate::policy_types::{ + MiddlewareConfig, MiddlewareEndpointSelector, PolicyGenerationGuard, generation_guard_for, +}; + +/// The `network_middlewares` of one policy revision, ready for selection. +/// +/// Cloning is cheap and shares the configs. +#[derive(Clone, Debug, Default)] +pub struct MiddlewareConfigs(Arc<[MiddlewareConfig]>); + +impl MiddlewareConfigs { + /// Holds `configs` in the order given. + pub fn new(configs: Vec) -> Self { + Self(configs.into()) + } + + /// Reads the `network_middlewares` of a policy proto, keyed by config + /// name in sorted order. + /// + /// Produces the same configs a YAML sandbox reads from the Rego data + /// built from the same proto. + pub fn from_proto(policy: &ProtoSandboxPolicy) -> Self { + let mut entries: Vec<_> = policy.network_middlewares.iter().collect(); + entries.sort_by_key(|(name, _)| name.as_str()); + Self::new( + entries + .into_iter() + .map(|(name, mw)| MiddlewareConfig { + policy_key: name.clone(), + middleware: mw.middleware.clone(), + order: mw.order, + config: mw.config.clone().unwrap_or_default(), + on_error: mw.on_error.clone(), + endpoints: mw + .endpoints + .as_ref() + .map(|selector| MiddlewareEndpointSelector { + include: selector.include.clone(), + exclude: selector.exclude.clone(), + }), + }) + .collect(), + ) + } + + /// Returns the ordered middleware chain whose selectors match `host`. + /// + /// # Errors + /// + /// Returns an error if a selector or `on_error` value is invalid, or more + /// stages match than the platform allows. + pub fn select(&self, host: &str) -> Result> { + let mut entries = Vec::new(); + for config in self.0.iter() { + if selector_matches(config, host)? { + if entries.len() >= openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES { + return Err(miette::miette!( + "selected middleware stage count exceeds platform maximum {}", + openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES + )); + } + entries.push(chain_entry(config)?); + } + } + openshell_supervisor_middleware::sort_chain_entries(&mut entries); + Ok(entries) + } + + /// Returns the number of configs. + pub fn len(&self) -> usize { + self.0.len() + } +} + +fn selector_matches(config: &MiddlewareConfig, host: &str) -> Result { + let Some(selector) = &config.endpoints else { + return Ok(false); + }; + let selector = HostSelector::new(&selector.include, &selector.exclude) + .map_err(|error| miette::miette!(error))?; + Ok(selector.matches(host)) +} + +fn chain_entry(config: &MiddlewareConfig) -> Result { + Ok(ChainEntry { + name: config.policy_key.clone(), + implementation: config.middleware.clone(), + order: config.order, + config: config.config.clone(), + on_error: openshell_supervisor_middleware::OnError::parse(&config.on_error)?, + }) +} + +/// The middleware configs and services in effect at one time. +#[derive(Clone)] +pub struct ActiveMiddleware { + pub configs: MiddlewareConfigs, + pub runner: ChainRunner, +} + +/// A Cedar sandbox's live middleware configs, services, and generation. +/// +/// The generation advances when the service registry is replaced, when a +/// runtime configuration is committed, and when a quarantine starts or ends +/// (see [`Self::change_configuration`]), under the write lock, so a reader +/// that observes a generation under the read lock also sees the middleware +/// that produced it. It is independent of the Cedar policy generation: a +/// middleware-only change does not close inspected tunnels, which keep the +/// middleware they started with. +pub struct MiddlewareState { + active: RwLock, + generation: Arc, + generation_tx: watch::Sender, +} + +impl std::fmt::Debug for MiddlewareState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MiddlewareState") + .field( + "configs", + &self.active.read().map(|active| active.configs.len()).ok(), + ) + .field("generation", &self.current_generation()) + .finish_non_exhaustive() + } +} + +impl MiddlewareState { + /// Starts at generation 0 with `configs` and no connected services. + pub fn new(configs: MiddlewareConfigs) -> Self { + let (generation_tx, _) = watch::channel(0); + Self { + active: RwLock::new(ActiveMiddleware { + configs, + runner: ChainRunner::default(), + }), + generation: Arc::new(AtomicU64::new(0)), + generation_tx, + } + } + + fn read(&self) -> Result> { + self.active + .read() + .map_err(|_| miette::miette!("middleware state lock poisoned")) + } + + fn write(&self) -> Result> { + self.active + .write() + .map_err(|_| miette::miette!("middleware state lock poisoned")) + } + + /// Advances the generation. Callers hold the write lock. + fn advance_generation(&self) -> u64 { + let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + self.generation_tx.send_replace(generation); + generation + } + + /// Returns the current middleware generation. + pub fn current_generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + /// Pins `expected_generation`. + /// + /// # Errors + /// + /// Returns an error if `expected_generation` is already stale. + pub fn generation_guard(&self, expected_generation: u64) -> Result { + generation_guard_for( + expected_generation, + self.current_generation(), + &self.generation, + &self.generation_tx, + ) + } + + /// Selects the chain for `host` and returns the generation it came from. + /// + /// # Errors + /// + /// Returns an error if the lock is poisoned or selection fails (see + /// [`MiddlewareConfigs::select`]). + pub fn select_with_generation(&self, host: &str) -> Result<(Vec, u64)> { + let active = self.read()?; + let generation = self.current_generation(); + Ok((active.configs.select(host)?, generation)) + } + + /// Returns the configs and services in effect now, for a tunnel to keep. + /// + /// # Errors + /// + /// Returns an error if the lock is poisoned. + pub fn snapshot(&self) -> Result { + Ok(self.read()?.clone()) + } + + /// Returns the services in effect now. + /// + /// # Errors + /// + /// Returns an error if the lock is poisoned. + pub fn runner(&self) -> Result { + Ok(self.read()?.runner.clone()) + } + + /// Replaces the service registry and advances the generation. + /// + /// # Errors + /// + /// Returns an error if the lock is poisoned. + pub fn replace_registry(&self, registry: MiddlewareRegistry) -> Result<()> { + let mut active = self.write()?; + active.runner = active.runner.with_replacement_registry(registry); + self.advance_generation(); + Ok(()) + } + + /// Activates `configs`, and `registry` when given, as a new generation. + /// + /// `prepare` runs under the write lock before the generation advances; + /// it acquires whatever else the commit needs and may fail. `publish` + /// runs with its result after the generation advances, so existing + /// guards are already stale, and before the new middleware is visible: + /// readers stay blocked until it returns. Neither may call back into + /// this state. + /// + /// # Errors + /// + /// Returns an error if the lock is poisoned or `prepare` fails; nothing + /// is changed then, no guard becomes stale, and `publish` is not called. + pub fn commit

( + &self, + configs: MiddlewareConfigs, + registry: Option, + prepare: impl FnOnce() -> Result

, + publish: impl FnOnce(P), + ) -> Result { + let mut active = self.write()?; + let prepared = prepare()?; + let runner = registry.map(|registry| active.runner.with_replacement_registry(registry)); + let generation = self.advance_generation(); + publish(prepared); + active.configs = configs; + if let Some(runner) = runner { + active.runner = runner; + } + drop(active); + self.generation_guard(generation) + } + + /// Applies a configuration change that keeps the middleware itself. + /// + /// `change` runs under the write lock and returns whether the + /// configuration changed; when it did, the generation advances so every + /// pinned guard becomes stale. Quarantine entry and exit use this: the + /// configuration a guard was pinned to is no longer the active one, even + /// though the middleware configs and services are unchanged. + /// + /// # Errors + /// + /// Returns an error if the lock is poisoned or `change` fails; the + /// generation does not advance then. + pub fn change_configuration(&self, change: impl FnOnce() -> Result<(T, bool)>) -> Result { + let _active = self.write()?; + let (result, changed) = change()?; + if changed { + self.advance_generation(); + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn matching_configs(count: usize) -> MiddlewareConfigs { + MiddlewareConfigs::new( + (0..count) + .map(|index| MiddlewareConfig { + policy_key: format!("stage-{index}"), + middleware: "openshell/regex".to_string(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api.example.com".to_string()], + exclude: Vec::new(), + }), + ..Default::default() + }) + .collect(), + ) + } + + #[test] + fn select_accepts_maximum_selected_stages() { + let chain = matching_configs(openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES) + .select("api.example.com") + .expect("maximum selected chain"); + assert_eq!( + chain.len(), + openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES + ); + } + + #[test] + fn select_rejects_selected_stages_over_capacity() { + let error = + matching_configs(openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES + 1) + .select("api.example.com") + .expect_err("selected chain over capacity"); + assert!( + error + .to_string() + .contains("selected middleware stage count exceeds platform maximum 10") + ); + } + + fn middleware_proto() -> ProtoSandboxPolicy { + let middleware = |order: i32, include: &str, exclude: &[&str]| { + openshell_core::proto::NetworkMiddlewareConfig { + middleware: "openshell/regex".to_string(), + order, + endpoints: Some(openshell_core::proto::MiddlewareEndpointSelector { + include: vec![include.to_string()], + exclude: exclude.iter().map(ToString::to_string).collect(), + }), + ..Default::default() + } + }; + let mut policy = ProtoSandboxPolicy::default(); + policy + .network_middlewares + .insert("late".to_string(), middleware(20, "**.example.com", &[])); + policy.network_middlewares.insert( + "early".to_string(), + middleware(10, "*.example.com", &["trusted.example.com"]), + ); + policy + } + + /// A Cedar sandbox selects from the proto exactly what a YAML sandbox + /// selects from the Rego data built from the same proto. + #[test] + fn proto_configs_select_like_the_rego_data_built_from_the_same_proto() { + let policy = middleware_proto(); + let opa = crate::opa::OpaEngine::from_proto(&policy).expect("YAML engine loads"); + let configs = MiddlewareConfigs::from_proto(&policy); + for host in [ + "api.example.com", + "trusted.example.com", + "deep.api.example.com", + "example.org", + ] { + let input = crate::policy_types::NetworkInput { + host: host.to_string(), + port: 443, + binary_path: "/usr/bin/curl".into(), + binary_sha256: String::new(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + let (rego, _) = opa + .query_middleware_chain_with_generation(&input) + .expect("Rego selection"); + let rust = configs.select(host).expect("Rust selection"); + let names = |chain: &[ChainEntry]| { + chain + .iter() + .map(|entry| (entry.name.clone(), entry.order)) + .collect::>() + }; + assert_eq!(names(&rust), names(®o), "host {host}"); + } + } + + #[test] + fn commit_advances_the_generation_before_publishing() { + let state = MiddlewareState::new(MiddlewareConfigs::default()); + let before = state.generation_guard(0).expect("current guard"); + let mut stale_during_prepare = true; + let mut stale_during_publish = false; + let guard = state + .commit( + MiddlewareConfigs::from_proto(&middleware_proto()), + None, + || { + stale_during_prepare = before.is_stale(); + Ok(()) + }, + |()| stale_during_publish = before.is_stale(), + ) + .expect("commit"); + assert!(!stale_during_prepare); + assert!(stale_during_publish); + assert_eq!(guard.captured_generation(), 1); + let (chain, generation) = state + .select_with_generation("api.example.com") + .expect("select"); + assert_eq!(generation, 1); + assert_eq!(chain.len(), 2); + } + + /// A failed preparation commits nothing: the guard stays current and the + /// middleware stays on the previous configs. + #[test] + fn commit_changes_nothing_when_preparation_fails() { + let state = MiddlewareState::new(MiddlewareConfigs::default()); + let before = state.generation_guard(0).expect("current guard"); + let mut published = false; + let error = state + .commit( + MiddlewareConfigs::from_proto(&middleware_proto()), + None, + || Err::<(), _>(miette::miette!("lock poisoned")), + |()| published = true, + ) + .expect_err("preparation fails"); + assert!(error.to_string().contains("lock poisoned")); + assert!(!published); + assert!(!before.is_stale()); + let (chain, generation) = state + .select_with_generation("api.example.com") + .expect("select"); + assert_eq!(generation, 0); + assert!(chain.is_empty()); + } + + #[test] + fn change_configuration_advances_only_when_changed() { + let state = MiddlewareState::new(MiddlewareConfigs::default()); + let before = state.generation_guard(0).expect("current guard"); + let kept: u64 = state + .change_configuration(|| Ok((7, false))) + .expect("unchanged"); + assert_eq!(kept, 7); + assert!(!before.is_stale()); + state + .change_configuration(|| Ok(((), true))) + .expect("changed"); + assert!(before.is_stale()); + assert_eq!(state.current_generation(), 1); + } +} diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 2d1a73a949..31e1e92473 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -10,15 +10,25 @@ //! The baked policy uses the `openshell.endpoint_path_matches` native builtin //! registered here to share endpoint selector semantics with Rust route selection. +use crate::binary_resolution::resolve_policy_binary; +use crate::network_middleware::MiddlewareConfigs; +use crate::policy_data::{ + endpoint_policy_value, normalize_endpoint_protocols, normalize_l7_policy_rule_aliases, +}; +use crate::policy_types::{ + EndpointConfig, MiddlewareConfig, MiddlewareEndpointSelector, generation_guard_for, +}; +use crate::policy_validation::{ + redacted_policy_violation_category, render_bounded_validation_diagnostics, + render_repeated_validation_diagnostic, +}; use miette::Result; -use openshell_core::host_pattern::HostSelector; -use openshell_core::mcp::is_mcp_protocol; use openshell_core::policy::{ FilesystemPolicy, LandlockCompatibility, LandlockPolicy, ProcessPolicy, }; use openshell_core::policy_identity::deterministic_policy_hash; use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; -use openshell_policy::{L7ConfigStanza, L7Protocol as PolicyL7Protocol, PolicyViolation}; +use openshell_policy::L7ConfigStanza; use openshell_policy_schema::{ FilesystemPolicy as AuthoredFilesystemPolicy, LandlockPolicy as AuthoredLandlockPolicy, ProcessPolicy as AuthoredProcessPolicy, @@ -57,11 +67,6 @@ fn new_regorus_engine() -> Result { Ok(engine) } -/// Maximum number of fixed categories in one policy-load error. -const POLICY_VALIDATION_DIAGNOSTIC_MAX_ITEMS: usize = 8; -/// Maximum byte length of one policy-load error, including its omission marker. -const POLICY_VALIDATION_DIAGNOSTIC_MAX_BYTES: usize = 512; - /// Implementation-owned middleware config validation supplied by the active /// in-process catalog for local policy files. pub type MiddlewareConfigValidator = @@ -74,59 +79,11 @@ pub struct PolicyDecision { pub matched_policy: Option, } -/// Network action returned by OPA `network_action` rule. -/// -/// - `Allow`: endpoint + binary explicitly matched in a network policy -/// - `Deny`: no matching policy -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum NetworkAction { - Allow { matched_policy: Option }, - Deny { reason: String }, -} - -/// Endpoint identity and metadata captured with one policy generation. -#[derive(Debug, Clone)] -pub struct MatchedEndpoint { - pub policy_name: String, - pub endpoint_index: usize, - pub endpoint: regorus::Value, -} - -/// Policy-DNS eligible endpoint metadata captured from one policy generation. -/// -/// This is policy data only. It deliberately contains no process identity or -/// network authorization decision. -#[derive(Debug, Clone)] -pub struct PolicyDnsEligibilitySnapshot { - pub endpoints: Vec, - pub generation: u64, - /// The generation is a fail-closed quarantine, so every name is refused. - pub fail_closed: bool, -} - -/// Atomic policy result used to authorize and materialize one egress request. -#[derive(Debug, Clone)] -pub struct EgressAuthorization { - pub action: NetworkAction, - pub endpoint_configs: Vec, - pub matched_endpoints: Vec, - pub exact_declared_endpoint_host: bool, - pub generation: u64, -} - -/// Input for a network access policy evaluation. -pub struct NetworkInput { - pub host: String, - pub port: u16, - pub binary_path: PathBuf, - pub binary_sha256: String, - /// Ancestor binary paths from process tree walk (parent, grandparent, ...). - pub ancestors: Vec, - /// Absolute paths extracted from `/proc//cmdline` of the socket-owning - /// process and its ancestors. Captures script paths (e.g. `/usr/local/bin/claude`) - /// that don't appear in `/proc//exe` because the interpreter (node) is the exe. - pub cmdline_paths: Vec, -} +pub use crate::policy_engine::TunnelPolicyEngine; +pub use crate::policy_types::{ + EgressAuthorization, MatchedEndpoint, NetworkAction, NetworkInput, + PolicyDnsEligibilitySnapshot, PolicyGenerationGuard, +}; fn inject_runtime_policy_data(data: &mut serde_json::Value, require_binary_identity: bool) { let Some(obj) = data.as_object_mut() else { @@ -203,100 +160,111 @@ pub(crate) fn test_opa_query_count() -> u64 { TEST_OPA_QUERY_COUNT.load(Ordering::SeqCst) } -/// Generation guard captured when an HTTP tunnel or request path starts. -#[derive(Clone, Debug)] -pub struct PolicyGenerationGuard { - captured_generation: u64, - current_generation: Arc, - generation_rx: watch::Receiver, +/// The Rego evaluator of a YAML sandbox's per-tunnel policy engine. +/// +/// Holds a clone of the engine's compiled policy, so a tunnel evaluates +/// without contending for the engine lock. The owning +/// [`TunnelPolicyEngine`] checks staleness before calling it. +pub(crate) struct RegoTunnelEvaluator { + engine: Mutex, } -impl PolicyGenerationGuard { - pub fn captured_generation(&self) -> u64 { - self.captured_generation - } - - pub fn current_generation(&self) -> u64 { - self.current_generation.load(Ordering::Acquire) - } - - pub fn is_stale(&self) -> bool { - self.current_generation() != self.captured_generation +impl RegoTunnelEvaluator { + fn lock(&self) -> Result> { + self.engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned")) } - pub fn ensure_current(&self) -> Result<()> { - if self.is_stale() { - return Err(miette::miette!( - "policy generation is stale [captured_generation:{} current_generation:{}]", - self.captured_generation(), - self.current_generation(), - )); - } - Ok(()) + /// Test-only: the tunnel's Rego engine, for evaluating rules directly. + #[cfg(test)] + pub(crate) fn engine_for_tests(&self) -> std::sync::MutexGuard<'_, regorus::Engine> { + self.lock().expect("OPA engine lock") } - /// Wait until the policy generation changes. + /// Queries the ordered middleware chain for a destination within this tunnel. /// - /// Relay boundaries use this to close even an idle or raw stream as soon - /// as a new generation (including fail-closed quarantine) is published. - pub async fn wait_until_stale(&self) { - let mut receiver = self.generation_rx.clone(); - while !self.is_stale() { - if receiver.changed().await.is_err() { - return; - } - } + /// # Errors + /// + /// Returns an error if the evaluation fails or the middleware data is malformed. + pub(crate) fn query_middleware_chain(&self, input: &NetworkInput) -> Result> { + query_middleware_chain_locked(&mut *self.lock()?, input) } -} -/// Per-tunnel L7 policy evaluator bound to the engine generation captured when -/// the tunnel was established. -pub struct TunnelPolicyEngine { - engine: Mutex, - generation_guard: PolicyGenerationGuard, - middleware_runner: ChainRunner, - websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget, -} - -impl TunnelPolicyEngine { - pub fn captured_generation(&self) -> u64 { - self.generation_guard.captured_generation() - } + /// Evaluates one L7 request and returns `(allowed, deny_reason)`. + /// + /// # Errors + /// + /// Returns an error if the evaluation fails. Callers treat an error as a denial. + pub(crate) fn evaluate_request( + &self, + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + ) -> Result<(bool, String)> { + let input = crate::l7::relay::l7_request_policy_input(ctx, request); - pub fn current_generation(&self) -> u64 { - self.generation_guard.current_generation() - } + let mut engine = self.lock()?; - pub fn is_stale(&self) -> bool { - self.generation_guard.is_stale() - } + set_regorus_input(&mut engine, input)?; - pub fn generation_guard(&self) -> &PolicyGenerationGuard { - &self.generation_guard - } + let allowed = engine + .eval_rule("data.openshell.sandbox.allow_request".into()) + .map_err(|e| miette::miette!("{e}"))?; + let allowed = allowed == regorus::Value::from(true); - pub(crate) fn engine(&self) -> &Mutex { - &self.engine - } + let reason = if allowed { + String::new() + } else { + let val = engine + .eval_rule("data.openshell.sandbox.request_deny_reason".into()) + .map_err(|e| miette::miette!("{e}"))?; + match val { + regorus::Value::String(s) => s.to_string(), + regorus::Value::Undefined => "request denied by policy".to_string(), + other => other.to_string(), + } + }; - pub(crate) fn middleware_runner(&self) -> &ChainRunner { - &self.middleware_runner + Ok((allowed, reason)) } - pub(crate) fn websocket_assembly_budget( + /// Returns the token-grant owners whose own endpoint admits one request. + /// + /// # Errors + /// + /// Returns an error if the evaluation fails. Callers inject no token on error. + pub(crate) fn admitted_token_grant_owners( &self, - ) -> crate::l7::websocket::WebSocketAssemblyBudget { - self.websocket_assembly_budget.clone() + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + ) -> Result> { + eval_admitted_token_grant_owners(&mut *self.lock()?, ctx, request) } +} - /// Query the ordered middleware chain for a destination within this tunnel. - pub fn query_middleware_chain(&self, input: &NetworkInput) -> Result> { - let mut engine = self - .engine - .lock() - .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; - query_middleware_chain_locked(&mut engine, input) - } +/// Evaluates `allowed_token_grant_owners` for one L7 request. +fn eval_admitted_token_grant_owners( + engine: &mut regorus::Engine, + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, +) -> Result> { + set_regorus_input( + engine, + crate::l7::relay::l7_request_policy_input(ctx, request), + )?; + let owners = engine + .eval_rule("data.openshell.sandbox.allowed_token_grant_owners".into()) + .map_err(|error| miette::miette!("{error}"))?; + let regorus::Value::Array(owners) = owners else { + return Err(miette::miette!("invalid credential owner admission result")); + }; + owners + .iter() + .map(|owner| match owner { + regorus::Value::String(owner) => Ok(owner.to_string()), + _ => Err(miette::miette!("invalid credential owner identity")), + }) + .collect() } impl OpaEngine { @@ -320,6 +288,25 @@ impl OpaEngine { } } + /// Loads YAML policy data as written, skipping the validation and + /// normalization [`Self::from_strings`] applies. + /// + /// For tests of the Rego rules' defense in depth against policies that + /// validation would reject. + #[cfg(test)] + pub(crate) fn from_strings_unvalidated(policy: &str, data_yaml: &str) -> Result { + let data: serde_json::Value = serde_yml::from_str(data_yaml) + .map_err(|e| miette::miette!("failed to parse YAML data: {e}"))?; + let mut engine = new_regorus_engine()?; + engine + .add_policy("policy.rego".into(), policy.into()) + .map_err(|_| miette::miette!("failed to load Rego policy"))?; + engine + .add_data_json(&data.to_string()) + .map_err(|_| miette::miette!("failed to load OPA policy data"))?; + Ok(Self::with_engine(engine, true)) + } + /// Whether network authorization requires a workload binary identity. pub const fn binary_identity_required(&self) -> bool { self.binary_identity_required @@ -472,25 +459,7 @@ impl OpaEngine { // Protobuf cannot distinguish an omitted repeated MCP version field // from an empty one. Canonicalize before any runtime consumer reads // the policy so both representations select the pinned default. - let proto = openshell_policy::validate_and_canonicalize_sandbox_policy(proto.clone()) - .map_err(|error| { - miette::miette!(render_bounded_validation_diagnostics( - "policy validation failed", - error - .violations() - .iter() - .map(redacted_policy_violation_category), - )) - })?; - - let ambiguities = openshell_policy::find_endpoint_ambiguities(&proto); - if !ambiguities.is_empty() { - return Err(miette::miette!(render_repeated_validation_diagnostic( - "network endpoint ambiguity validation failed", - ambiguities.len(), - "ambiguous network endpoint selectors", - ))); - } + let proto = crate::policy_validation::validate_sandbox_policy(proto)?; emit_binary_identity_mode(require_binary_identity, "proto"); let data_json_str = proto_to_opa_data_json(&proto, entrypoint_pid); @@ -627,6 +596,7 @@ impl OpaEngine { matched_endpoints: Vec::new(), exact_declared_endpoint_host: false, generation, + cedar_destination: None, }); } @@ -637,16 +607,10 @@ impl OpaEngine { .map_err(|e| miette::miette!("{e}"))?; let action_str = get_str(&result, "action").unwrap_or_default(); let matched_policy = get_str(&result, "matched_policy").filter(|name| !name.is_empty()); - let endpoint_configs = match get_field(&result, "endpoint_configs") { - Some(regorus::Value::Array(values)) => values.to_vec(), - _ => Vec::new(), - }; - let matched_endpoints = match get_field(&result, "matched_endpoints") { - Some(regorus::Value::Array(values)) => { - values.iter().filter_map(parse_matched_endpoint).collect() - } - _ => Vec::new(), - }; + let endpoint_configs = get_field(&result, "endpoint_configs") + .map_or_else(|| Ok(Vec::new()), |v| list_from_rego(v, "endpoint config"))?; + let matched_endpoints = get_field(&result, "matched_endpoints") + .map_or_else(|| Ok(Vec::new()), |v| list_from_rego(v, "matched endpoint"))?; let exact_declared_endpoint_host = get_bool(&result, "exact_declared_endpoint_host").unwrap_or(false); @@ -666,6 +630,7 @@ impl OpaEngine { matched_endpoints, exact_declared_endpoint_host, generation, + cedar_destination: None, }) } @@ -698,13 +663,7 @@ impl OpaEngine { let value = engine .eval_rule("data.openshell.sandbox.policy_dns_eligible_endpoint_records".into()) .map_err(|error| miette::miette!("{error}"))?; - let endpoints = match value { - regorus::Value::Array(values) => { - values.iter().filter_map(parse_matched_endpoint).collect() - } - regorus::Value::Undefined => Vec::new(), - other => parse_matched_endpoint(&other).into_iter().collect(), - }; + let endpoints = list_from_rego(&value, "policy DNS record")?; Ok(PolicyDnsEligibilitySnapshot { endpoints, @@ -873,7 +832,6 @@ impl OpaEngine { /// generation comparison and callback linearizes state derived from an OPA /// snapshot with every policy reload and fail-closed transition. Callers /// must not perform I/O or other long-running work in `operation`. - #[allow(dead_code)] pub(crate) fn with_current_generation( &self, expected_generation: u64, @@ -928,17 +886,12 @@ impl OpaEngine { /// Return a guard for a previously captured policy generation. pub fn generation_guard(&self, expected_generation: u64) -> Result { - let generation = self.current_generation(); - if generation != expected_generation { - return Err(miette::miette!( - "policy changed before HTTP relay started [expected_generation:{expected_generation} current_generation:{generation}]" - )); - } - Ok(PolicyGenerationGuard { - captured_generation: generation, - current_generation: Arc::clone(&self.generation), - generation_rx: self.generation_tx.subscribe(), - }) + generation_guard_for( + expected_generation, + self.current_generation(), + &self.generation, + &self.generation_tx, + ) } /// Query static sandbox configuration from the OPA data module. @@ -982,9 +935,9 @@ impl OpaEngine { /// Query the L7 endpoint config for a matched policy and host:port. /// /// After L4 evaluation allows a CONNECT, this method queries the Rego data - /// to get the full endpoint object for the matched policy. Returns the raw - /// `regorus::Value` which can be parsed by `l7::parse_l7_config()`. - pub fn query_endpoint_config(&self, input: &NetworkInput) -> Result> { + /// to get the full endpoint object for the matched policy. Returns the + /// typed config, which `l7::parse_l7_config()` validates. + pub fn query_endpoint_config(&self, input: &NetworkInput) -> Result> { Ok(self.query_endpoint_config_with_generation(input)?.0) } @@ -992,7 +945,7 @@ impl OpaEngine { pub fn query_endpoint_config_with_generation( &self, input: &NetworkInput, - ) -> Result<(Option, u64)> { + ) -> Result<(Option, u64)> { let (configs, generation) = self.query_endpoint_configs_with_generation(input)?; Ok((configs.into_iter().next(), generation)) } @@ -1001,7 +954,36 @@ impl OpaEngine { pub fn query_endpoint_configs_with_generation( &self, input: &NetworkInput, - ) -> Result<(Vec, u64)> { + ) -> Result<(Vec, u64)> { + let (val, generation) = self.query_endpoint_config_values(input)?; + Ok((list_from_rego(&val, "endpoint config")?, generation)) + } + + /// Returns the first matching endpoint object as the Rego data holds it. + /// + /// For diagnostics and tests of the Rego data shaping only; decisions + /// read the typed [`EndpointConfig`] from [`Self::query_endpoint_config`]. + #[doc(hidden)] + pub fn query_endpoint_config_json( + &self, + input: &NetworkInput, + ) -> Result> { + let (val, _) = self.query_endpoint_config_values(input)?; + let first = match val { + regorus::Value::Undefined => return Ok(None), + regorus::Value::Array(values) => match values.first() { + Some(first) => first.clone(), + None => return Ok(None), + }, + other => other, + }; + serde_json::to_value(first) + .map(Some) + .map_err(|e| miette::miette!("{e}")) + } + + /// Evaluates the matching endpoint configs rule for `input`. + fn query_endpoint_config_values(&self, input: &NetworkInput) -> Result<(regorus::Value, u64)> { #[cfg(test)] record_test_opa_query(); @@ -1018,12 +1000,7 @@ impl OpaEngine { let val = engine .eval_rule("data.openshell.sandbox._matching_endpoint_configs".into()) .map_err(|e| miette::miette!("{e}"))?; - - match val { - regorus::Value::Undefined => Ok((Vec::new(), generation)), - regorus::Value::Array(values) => Ok((values.to_vec(), generation)), - other => Ok((vec![other], generation)), - } + Ok((val, generation)) } /// Query every matching endpoint for credential-provenance gating. @@ -1035,7 +1012,7 @@ impl OpaEngine { pub fn query_endpoint_credential_guards( &self, input: &NetworkInput, - ) -> Result> { + ) -> Result> { let input_json = network_input_json(input); let mut engine = self @@ -1051,11 +1028,7 @@ impl OpaEngine { .eval_rule("data.openshell.sandbox.endpoint_credential_guards".into()) .map_err(|e| miette::miette!("{e}"))?; - match val { - regorus::Value::Undefined => Ok(Vec::new()), - regorus::Value::Array(values) => Ok(values.to_vec()), - other => Ok(vec![other]), - } + list_from_rego(&val, "endpoint credential guard") } /// Query the ordered middleware chain for an admitted destination. @@ -1081,7 +1054,7 @@ impl OpaEngine { pub fn query_allowed_ips(&self, input: &NetworkInput) -> Result> { Ok(self .query_endpoint_config(input)? - .map(|val| get_str_array(&val, "allowed_ips")) + .map(|config| config.allowed_ips) .unwrap_or_default()) } @@ -1128,16 +1101,19 @@ impl OpaEngine { "policy changed before L7 tunnel started [expected_generation:{expected_generation} current_generation:{generation}]" )); } - Ok(TunnelPolicyEngine { - engine: Mutex::new(engine.clone()), - generation_guard: PolicyGenerationGuard { - captured_generation: generation, - current_generation: Arc::clone(&self.generation), - generation_rx: self.generation_tx.subscribe(), + Ok(TunnelPolicyEngine::rego( + generation_guard_for( + expected_generation, + generation, + &self.generation, + &self.generation_tx, + )?, + self.middleware_runner()?, + self.websocket_assembly_budget(), + RegoTunnelEvaluator { + engine: Mutex::new(engine.clone()), }, - middleware_runner: self.middleware_runner()?, - websocket_assembly_budget: self.websocket_assembly_budget(), - }) + )) } } @@ -1150,51 +1126,6 @@ fn value_to_string(val: ®orus::Value) -> String { } } -/// Extract a string from a `regorus::Value` object field. -fn get_str(val: ®orus::Value, key: &str) -> Option { - let key_val = regorus::Value::String(key.into()); - match val { - regorus::Value::Object(map) => match map.get(&key_val) { - Some(regorus::Value::String(s)) => Some(s.to_string()), - _ => None, - }, - _ => None, - } -} - -/// Extract a bool from a `regorus::Value` object field. -fn get_bool(val: ®orus::Value, key: &str) -> Option { - let key_val = regorus::Value::String(key.into()); - match val { - regorus::Value::Object(map) => match map.get(&key_val) { - Some(regorus::Value::Bool(b)) => Some(*b), - _ => None, - }, - _ => None, - } -} - -/// Extract a string array from a `regorus::Value` object field. -fn get_str_array(val: ®orus::Value, key: &str) -> Vec { - let key_val = regorus::Value::String(key.into()); - match val { - regorus::Value::Object(map) => match map.get(&key_val) { - Some(regorus::Value::Array(arr)) => arr - .iter() - .filter_map(|v| { - if let regorus::Value::String(s) = v { - Some(s.to_string()) - } else { - None - } - }) - .collect(), - _ => vec![], - }, - _ => vec![], - } -} - fn network_input_json(input: &NetworkInput) -> serde_json::Value { let ancestor_strs: Vec = input .ancestors @@ -1237,159 +1168,124 @@ fn query_middleware_chain_locked( engine: &mut regorus::Engine, input: &NetworkInput, ) -> Result> { - let configs_val = engine + let configs = engine .eval_rule("data.openshell.sandbox.network_middlewares".into()) .map_err(|e| miette::miette!("{e}"))?; - let configs = parse_middleware_configs(&configs_val)?; - if configs.is_empty() { - return Ok(Vec::new()); - } - global_middleware_entries(&configs, &input.host) + middleware_configs_from_rego(&configs)?.select(&input.host) } -fn parse_middleware_configs(value: ®orus::Value) -> Result> { - match value { - regorus::Value::Undefined => Ok(Vec::new()), - regorus::Value::Object(configs) => configs - .iter() - .map(|(name, config)| { - let regorus::Value::String(_) = name else { - return Err(miette::miette!("network_middlewares keys must be strings")); - }; - let regorus::Value::Object(fields) = config else { - return Err(miette::miette!( - "network middleware config {name:?} must be an object" - )); - }; - let fields = fields - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .chain(std::iter::once(( - regorus::Value::String("__openshell_policy_key".into()), - name.clone(), - ))) - .collect::>(); - Ok(fields.into()) - }) - .collect(), - other => Err(miette::miette!( - "network_middlewares must be an object, got {other:?}" - )), - } +// --------------------------------------------------------------------------- +// Rego result conversion +// +// The only place Rego data becomes the engine-neutral types of +// `crate::policy_types`. Everything downstream of the OPA engine reads those +// types, so a malformed Rego result fails here, closed, instead of being read +// as defaults later. +// --------------------------------------------------------------------------- + +/// Converts one Rego value into typed policy data. +fn from_rego(value: ®orus::Value, what: &str) -> Result { + serde_json::to_value(value) + .and_then(serde_json::from_value) + .map_err(|error| { + // The detail may quote authored policy values; keep it out of the + // returned diagnostic. + tracing::debug!(%error, what, "Rego result has an unexpected shape"); + miette::miette!("{what} from Rego has an unexpected shape") + }) } -fn global_middleware_entries(configs: &[regorus::Value], host: &str) -> Result> { - let mut entries = Vec::new(); - for config in configs { - if middleware_selector_matches(config, host)? { - if entries.len() >= openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES { - return Err(miette::miette!( - "selected middleware stage count exceeds platform maximum {}", - openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES - )); - } - entries.push(chain_entry_from_value(config)?); - } +/// Converts a Rego list result, reading an undefined result as empty and a +/// single object as a one-element list. +fn list_from_rego( + value: ®orus::Value, + what: &str, +) -> Result> { + match value { + regorus::Value::Undefined => Ok(Vec::new()), + regorus::Value::Array(values) => values.iter().map(|v| from_rego(v, what)).collect(), + other => Ok(vec![from_rego(other, what)?]), } - openshell_supervisor_middleware::sort_chain_entries(&mut entries); - Ok(entries) } -fn middleware_selector_matches(config: ®orus::Value, host: &str) -> Result { - let Some(selector) = get_field(config, "endpoints") else { - return Ok(false); - }; - let include = get_str_array(selector, "include"); - let exclude = get_str_array(selector, "exclude"); - let selector = HostSelector::new(&include, &exclude).map_err(|error| miette::miette!(error))?; - Ok(selector.matches(host)) +/// One `network_middlewares` entry as the Rego data carries it. +#[derive(Default, serde::Deserialize)] +#[serde(default)] +struct RegoMiddlewareConfig { + middleware: String, + order: i32, + config: serde_json::Value, + on_error: String, + endpoints: Option, } -fn chain_entry_from_value(value: ®orus::Value) -> Result { - let name = get_str(value, "__openshell_policy_key").unwrap_or_default(); - let implementation = get_str(value, "middleware").unwrap_or_default(); - Ok(ChainEntry { - name, - implementation, - order: get_field(value, "order") - .and_then(|value| match value { - regorus::Value::Number(number) => number.as_i64(), - _ => None, +/// Converts the `network_middlewares` object of the Rego data, keyed by +/// config name in sorted order. +fn middleware_configs_from_rego(value: ®orus::Value) -> Result { + if matches!(value, regorus::Value::Undefined) { + return Ok(MiddlewareConfigs::default()); + } + let entries: std::collections::BTreeMap = + from_rego(value, "network_middlewares")?; + let configs = entries + .into_iter() + .map(|(policy_key, entry)| { + let config = match entry.config { + serde_json::Value::Object(fields) => { + openshell_core::proto_struct::json_object_to_struct(fields).map_err(|_| { + miette::miette!("network middleware config {policy_key:?} is invalid") + })? + } + _ => prost_types::Struct::default(), + }; + Ok(MiddlewareConfig { + policy_key, + middleware: entry.middleware, + order: entry.order, + config, + on_error: entry.on_error, + endpoints: entry.endpoints, }) - .and_then(|value| i32::try_from(value).ok()) - .unwrap_or_default(), - config: get_field(value, "config") - .map(regorus_value_to_struct) - .unwrap_or_default(), - on_error: openshell_supervisor_middleware::OnError::parse( - get_str(value, "on_error").as_deref().unwrap_or_default(), - )?, - }) + }) + .collect::>>()?; + Ok(MiddlewareConfigs::new(configs)) } -fn get_field<'a>(val: &'a regorus::Value, key: &str) -> Option<&'a regorus::Value> { - let key_val = regorus::Value::String(key.into()); - match val { - regorus::Value::Object(map) => map.get(&key_val), +/// Extracts a string from a `regorus::Value` object field. +fn get_str(val: ®orus::Value, key: &str) -> Option { + match get_field(val, key) { + Some(regorus::Value::String(s)) => Some(s.to_string()), _ => None, } } -fn parse_matched_endpoint(value: ®orus::Value) -> Option { - let policy_name = get_str(value, "policy_name")?; - let endpoint_index = match get_field(value, "endpoint_index")? { - regorus::Value::Number(number) => usize::try_from(number.as_i64()?).ok()?, - _ => return None, - }; - let endpoint = get_field(value, "endpoint")?.clone(); - - Some(MatchedEndpoint { - policy_name, - endpoint_index, - endpoint, - }) +/// Extracts a bool from a `regorus::Value` object field. +fn get_bool(val: ®orus::Value, key: &str) -> Option { + match get_field(val, key) { + Some(regorus::Value::Bool(b)) => Some(*b), + _ => None, + } } -fn regorus_value_to_struct(value: ®orus::Value) -> prost_types::Struct { - let regorus::Value::Object(map) = value else { - return prost_types::Struct::default(); - }; - prost_types::Struct { - fields: map +/// Extracts a string array from a `regorus::Value` object field. +fn get_str_array(val: ®orus::Value, key: &str) -> Vec { + match get_field(val, key) { + Some(regorus::Value::Array(arr)) => arr .iter() - .filter_map(|(key, value)| match key { - regorus::Value::String(key) => { - Some((key.to_string(), regorus_value_to_prost(value))) - } + .filter_map(|v| match v { + regorus::Value::String(s) => Some(s.to_string()), _ => None, }) .collect(), + _ => Vec::new(), } } -fn regorus_value_to_prost(value: ®orus::Value) -> prost_types::Value { - use prost_types::{ListValue, Struct, Value, value::Kind}; - Value { - kind: Some(match value { - regorus::Value::Bool(value) => Kind::BoolValue(*value), - regorus::Value::Number(value) => Kind::NumberValue(value.as_f64().unwrap_or_default()), - regorus::Value::String(value) => Kind::StringValue(value.to_string()), - regorus::Value::Array(values) => Kind::ListValue(ListValue { - values: values.iter().map(regorus_value_to_prost).collect(), - }), - regorus::Value::Object(values) => Kind::StructValue(Struct { - fields: values - .iter() - .filter_map(|(key, value)| match key { - regorus::Value::String(key) => { - Some((key.to_string(), regorus_value_to_prost(value))) - } - _ => None, - }) - .collect(), - }), - _ => Kind::NullValue(0), - }), +/// Returns a field of a `regorus::Value` object. +fn get_field<'a>(val: &'a regorus::Value, key: &str) -> Option<&'a regorus::Value> { + match val { + regorus::Value::Object(map) => map.get(®orus::Value::String(key.into())), + _ => None, } } @@ -1553,88 +1449,6 @@ fn validate_opa_static_settings(data: &mut serde_json::Value) -> Result<()> { Ok(()) } -/// Select a fixed category without formatting authored fields or nested reasons. -/// Keep this match exhaustive so new validator variants require an explicit -/// decision before their diagnostics can cross the supervisor load boundary. -fn redacted_policy_violation_category(violation: &PolicyViolation) -> &'static str { - match violation { - PolicyViolation::InvalidProcessIdentity { .. } => "invalid process identity", - PolicyViolation::InvalidLandlockCompatibility { .. } => "invalid Landlock compatibility", - PolicyViolation::PathTraversal { .. } - | PolicyViolation::RelativePath { .. } - | PolicyViolation::OverlyBroadPath { .. } - | PolicyViolation::FieldTooLong { .. } => "invalid filesystem path", - PolicyViolation::TooManyPaths { .. } => "filesystem path limit exceeded", - PolicyViolation::TldWildcard { .. } - | PolicyViolation::TcpEndpointIpLiteral { .. } - | PolicyViolation::InvalidTcpEndpointHost { .. } - | PolicyViolation::InvalidHostWildcard { .. } => "invalid network endpoint host", - PolicyViolation::MissingEndpointHost { .. } - | PolicyViolation::MissingTcpEndpointHost { .. } => "missing network endpoint host", - PolicyViolation::MissingEndpointPort { .. } - | PolicyViolation::InvalidEndpointPort { .. } => "invalid network endpoint port", - PolicyViolation::MissingSigningService { .. } => { - "incomplete credential signing configuration" - } - PolicyViolation::UnknownCredentialSigning { .. } => { - "invalid credential signing configuration" - } - PolicyViolation::CredentialSigningWithBodyRewrite { .. } => { - "conflicting credential rewrite configuration" - } - PolicyViolation::InvalidL7Endpoint { .. } => "invalid L7 endpoint configuration", - PolicyViolation::InvalidMiddlewareConfig { .. } => "invalid middleware configuration", - PolicyViolation::TooManyMiddlewareConfigs { .. } => { - "middleware configuration limit exceeded" - } - PolicyViolation::DuplicateMiddlewareOrder { .. } => "duplicate middleware order", - PolicyViolation::TooManyMiddlewareSelectorPatterns { .. } => { - "middleware selector limit exceeded" - } - PolicyViolation::MiddlewareTlsSkipConflict { .. } => { - "middleware conflicts with TLS inspection" - } - PolicyViolation::MissingMcpVersions { .. } => "missing MCP protocol version", - PolicyViolation::McpOptionsOnNonMcpEndpoint { .. } => "MCP options require MCP protocol", - PolicyViolation::UnsupportedMcpVersion { .. } => "unsupported MCP protocol version", - PolicyViolation::DuplicateMcpVersion { .. } => "duplicate MCP protocol version", - } -} - -/// Render only fixed, implementation-owned headings and categories. Reserve -/// space for the omission marker before adding each complete item; never copy -/// or truncate a validator's payload-bearing Display, Debug, or source chain. -fn render_bounded_validation_diagnostics( - heading: &'static str, - diagnostics: impl Iterator, -) -> String { - const OMITTED_SUFFIX: &str = "; additional violations omitted"; - let mut rendered = heading.to_string(); - for (index, category) in diagnostics.enumerate() { - let separator = if index == 0 { ": " } else { "; " }; - if index == POLICY_VALIDATION_DIAGNOSTIC_MAX_ITEMS - || rendered.len() + separator.len() + category.len() + OMITTED_SUFFIX.len() - > POLICY_VALIDATION_DIAGNOSTIC_MAX_BYTES - { - rendered.push_str(OMITTED_SUFFIX); - break; - } - rendered.push_str(separator); - rendered.push_str(category); - } - rendered -} - -/// String-only validators have no safe fields to expose; retain their stage -/// and bounded item count without inspecting their authored error text. -fn render_repeated_validation_diagnostic( - heading: &'static str, - count: usize, - category: &'static str, -) -> String { - render_bounded_validation_diagnostics(heading, std::iter::repeat_n(category, count)) -} - /// Preprocess YAML policy data: parse, validate shapes, normalize, expand presets, return JSON. fn preprocess_yaml_data( yaml_str: &str, @@ -1724,57 +1538,6 @@ fn preprocess_yaml_data( serde_json::to_string(&data).map_err(|_| miette::miette!("failed to serialize OPA policy data")) } -/// Canonicalize recognized protocol spellings before L7 validation or Rego use. -/// -/// Rust parses protocol names case-insensitively, while Rego compares stable -/// wire keys. Publishing one canonical spelling keeps rule validation, typed -/// routing, and authorization on the same protocol branch. -fn normalize_endpoint_protocols(data: &mut serde_json::Value) { - let Some(policies) = data - .get_mut("network_policies") - .and_then(serde_json::Value::as_object_mut) - else { - return; - }; - - for policy in policies.values_mut() { - let Some(endpoints) = policy - .get_mut("endpoints") - .and_then(serde_json::Value::as_array_mut) - else { - continue; - }; - - for endpoint in endpoints { - let Some(endpoint) = endpoint.as_object_mut() else { - continue; - }; - let Some(protocol) = endpoint.get("protocol").and_then(serde_json::Value::as_str) - else { - continue; - }; - let canonical = if protocol.eq_ignore_ascii_case("tcp") { - Some("tcp") - } else { - PolicyL7Protocol::parse(protocol).map(|protocol| match protocol { - PolicyL7Protocol::Rest => "rest", - PolicyL7Protocol::Websocket => "websocket", - PolicyL7Protocol::Graphql => "graphql", - PolicyL7Protocol::Sql => "sql", - PolicyL7Protocol::JsonRpc => "json-rpc", - PolicyL7Protocol::Mcp => "mcp", - }) - }; - if let Some(canonical) = canonical { - endpoint.insert( - "protocol".to_string(), - serde_json::Value::String(canonical.to_string()), - ); - } - } - } -} - /// Normalize endpoint port/ports in JSON data. /// /// YAML policies may use `port: N` (single) or `ports: [N, M]` (multi). @@ -1906,337 +1669,6 @@ fn normalize_l7_config_alias( } } -fn normalize_l7_policy_rule_aliases(data: &mut serde_json::Value) { - let Some(policies) = data - .get_mut("network_policies") - .and_then(|v| v.as_object_mut()) - else { - return; - }; - - for (_name, policy) in policies.iter_mut() { - let Some(endpoints) = policy.get_mut("endpoints").and_then(|v| v.as_array_mut()) else { - continue; - }; - - for ep in endpoints.iter_mut() { - let Some(ep_obj) = ep.as_object_mut() else { - continue; - }; - normalize_l7_rules_aliases(ep_obj); - } - } -} - -fn normalize_l7_rules_aliases(ep: &mut serde_json::Map) { - let protocol = ep - .get("protocol") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let mcp_allow_all_known_mcp_methods = ep - .get("mcp_allow_all_known_mcp_methods") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - if let Some(rules) = ep.get_mut("rules").and_then(|v| v.as_array_mut()) { - for rule in rules { - if let Some(allow) = rule - .get_mut("allow") - .and_then(serde_json::Value::as_object_mut) - { - normalize_l7_rule_aliases(allow, &protocol, mcp_allow_all_known_mcp_methods); - } else if let Some(allow) = rule.as_object_mut() { - normalize_l7_rule_aliases(allow, &protocol, mcp_allow_all_known_mcp_methods); - } - } - } - - if let Some(denies) = ep.get_mut("deny_rules").and_then(|v| v.as_array_mut()) { - for deny in denies { - if let Some(deny_obj) = deny.as_object_mut() { - normalize_l7_rule_aliases(deny_obj, &protocol, mcp_allow_all_known_mcp_methods); - } - } - } -} - -fn normalize_l7_rule_aliases( - rule: &mut serde_json::Map, - protocol: &str, - mcp_allow_all_known_mcp_methods: bool, -) { - if protocol == "mcp" { - let mut has_tool_selector = rule - .get("params") - .and_then(serde_json::Value::as_object) - .and_then(|params| params.get("name")) - .is_some_and(|v| !v.is_null()); - if let Some(tool) = rule.remove("tool").filter(|v| !v.is_null()) { - let params = rule - .entry("params".to_string()) - .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); - if let Some(params) = params.as_object_mut() { - params.entry("name".to_string()).or_insert(tool); - has_tool_selector = true; - } - } - - if mcp_allow_all_known_mcp_methods - && rule - .get("method") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .is_empty() - { - let method = if has_tool_selector { "tools/call" } else { "*" }; - rule.insert( - "method".to_string(), - serde_json::Value::String(method.to_string()), - ); - } - } - - // MCP tool aliases must move into params before matcher normalization so - // both authored forms produce the same endpoint configuration as protobuf. - normalize_l7_matcher_map(rule, "query"); - normalize_l7_matcher_map(rule, "params"); -} - -/// Normalize nonempty matcher leaves to the protobuf runtime representation. -/// -/// OPA data also accepts explicit `glob` and `any` objects. Keeping those intact -/// makes normalization idempotent for already lowered data and protobuf reloads. -fn normalize_l7_matcher_map(rule: &mut serde_json::Map, field: &str) { - let Some(matchers) = rule - .get_mut(field) - .and_then(serde_json::Value::as_object_mut) - else { - return; - }; - for matcher in matchers.values_mut() { - // Rego permits an empty scalar to match an empty query value, whereas - // an empty glob object never matches. Preserve this OPA-only form. - if matcher.as_str().is_some_and(|glob| !glob.is_empty()) { - *matcher = serde_json::json!({ "glob": matcher.take() }); - } - } -} - -/// Normalize a path by resolving `.` and `..` components without touching -/// the filesystem. Only works correctly for absolute paths. -#[cfg(any(target_os = "linux", test))] -fn normalize_path(path: &Path) -> PathBuf { - let mut result = PathBuf::new(); - for component in path.components() { - match component { - std::path::Component::ParentDir => { - result.pop(); - } - std::path::Component::CurDir => {} - other => result.push(other), - } - } - result -} - -// Only the Linux resolver constructs the non-`Literal` variants; on other -// platforms the stub returns `Literal`, so the rest look dead there. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] -#[derive(Debug, PartialEq, Eq)] -enum BinaryResolution { - /// Symlink resolved to a different canonical target — add it to policy. - Resolved(String), - /// Nothing to add: glob, pid 0, not a symlink, or already canonical. - Literal, - /// Candidate absent under an accessible process root — expected, quiet. - Absent, - /// The process root `/proc//root` itself is unreachable (pid gone or - /// denied) — actionable, and `CAP_SYS_PTRACE` guidance is relevant. - Inaccessible(std::io::ErrorKind), - /// The process root is reachable but the candidate path itself failed for a - /// non-NotFound reason (a parent component is permission-denied, or the path - /// forms a symlink loop) — actionable, but a target-path problem rather than - /// a process-root one, so `CAP_SYS_PTRACE` guidance does not apply. - CandidateInaccessible(std::io::ErrorKind), - /// A component mid symlink-chain failed to resolve. - ChainBroken(std::io::ErrorKind), -} - -impl BinaryResolution { - #[cfg_attr(not(target_os = "linux"), allow(dead_code))] - const fn classify_first_probe_error(kind: std::io::ErrorKind) -> Self { - // Callers reach this only after confirming the process root is - // reachable, so a non-NotFound failure here is specific to the - // candidate path, not the process root. - match kind { - std::io::ErrorKind::NotFound => Self::Absent, - _ => Self::CandidateInaccessible(kind), - } - } -} - -/// Resolve a policy binary path through the container's root filesystem. -/// -/// On Linux, `/proc//root/` provides access to the container's mount -/// namespace. If the policy path is a symlink inside the container -/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), the canonical target is -/// returned as [`BinaryResolution::Resolved`]. The outcome is classified as: -/// - [`BinaryResolution::Literal`] — not on Linux, `entrypoint_pid` is 0 -/// (container not yet started), the path contains glob characters, it is not -/// a symlink, or the resolved path equals the original. -/// - [`BinaryResolution::Absent`] — the candidate does not exist under an -/// otherwise reachable process root (expected; the caller logs it quietly). -/// - [`BinaryResolution::Inaccessible`] — `/proc//root` itself is -/// unreachable (pid gone or access denied). -/// - [`BinaryResolution::CandidateInaccessible`] — the process root is -/// reachable but the candidate path failed for a non-NotFound reason. -/// - [`BinaryResolution::ChainBroken`] — a component mid symlink-chain failed, -/// or the chain forms a cycle / exceeds the kernel symlink limit. -#[cfg(target_os = "linux")] -fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> BinaryResolution { - if policy_path.contains('*') || entrypoint_pid == 0 { - return BinaryResolution::Literal; - } - - // Confirm the process root itself is reachable before probing candidates. - // A leaf `ENOENT` (absent candidate) and an unreachable process root - // (pid gone -> `ENOENT`, or denied -> `EACCES`) are indistinguishable at - // the leaf path, so check the root first. Any failure here is an access - // problem, not an absent candidate, and must stay actionable. - if let Err(e) = std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) { - return BinaryResolution::Inaccessible(e.kind()); - } - - // Walk the symlink chain inside the container filesystem using - // read_link rather than canonicalize. canonicalize resolves - // /proc//root itself (a kernel pseudo-symlink to /) which - // strips the prefix we need. read_link only reads the target of - // the specified symlink, keeping us in the container's namespace. - let mut resolved = PathBuf::from(policy_path); - - // Set once the walk reaches a real (non-symlink) target or a terminal - // dead end. If the iteration cap is hit while every component is still a - // symlink, this stays false and the chain is treated as broken. - let mut reached_target = false; - - // Linux SYMLOOP_MAX is 40; stop before infinite loops - for _ in 0..40 { - let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); - - tracing::debug!( - "Symlink resolution: probing container_path={container_path} for policy_path={policy_path} pid={entrypoint_pid}" - ); - - let meta = match std::fs::symlink_metadata(&container_path) { - Ok(m) => m, - Err(e) => { - // First iteration is the original policy path: an absent - // candidate (NotFound) is expected, any other error means the - // process root itself is unreachable. Later iterations are - // chain components, so a failure there is a broken symlink - // chain. Classify without logging; the caller emits the log at - // the appropriate level. - if resolved.as_os_str() == policy_path { - // The up-front root check and this probe are separate - // syscalls; if the process exited in between, the whole - // `/proc/` tree is gone and the leaf probe also - // reports NotFound. Re-check the root so a vanished process - // stays classified as Inaccessible (actionable) rather than - // being masked as an absent candidate (quiet). - if e.kind() == std::io::ErrorKind::NotFound - && let Err(root_error) = - std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) - { - return BinaryResolution::Inaccessible(root_error.kind()); - } - return BinaryResolution::classify_first_probe_error(e.kind()); - } - return BinaryResolution::ChainBroken(e.kind()); - } - }; - - if !meta.file_type().is_symlink() { - // Reached a non-symlink — this is the final resolved target - reached_target = true; - break; - } - - let target = match std::fs::read_link(&container_path) { - Ok(t) => t, - // A symlink whose target can't be read is a broken chain; the - // caller logs it. - Err(e) => return BinaryResolution::ChainBroken(e.kind()), - }; - - if target.is_absolute() { - resolved = target; - } else if let Some(parent) = resolved.parent() { - // Relative symlink: resolve against the containing directory - // e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11 - resolved = normalize_path(&parent.join(&target)); - } else { - // No parent to resolve a relative target against — terminal. - reached_target = true; - break; - } - } - - if !reached_target { - // The cap was exhausted while following symlinks. Linux SYMLOOP_MAX is - // 40, so a chain of exactly 40 symlinks ending at a real file is still - // valid — after the 40th hop `resolved` may already point at that file. - // Inspect it once more without following another link: a non-symlink is - // the valid final target (fall through to the Literal/Resolved logic - // below), while another symlink (or an error) is a genuine cycle - // (a -> b -> a) or a chain deeper than the kernel allows. read_link - // resolves one hop at a time, so the kernel never surfaces ELOOP; treat - // those as a broken chain so the caller logs it and matches literally. - let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); - - match std::fs::symlink_metadata(&container_path) { - Ok(meta) if !meta.file_type().is_symlink() => {} - Ok(_) => { - return BinaryResolution::ChainBroken( - std::io::Error::from_raw_os_error(libc::ELOOP).kind(), - ); - } - Err(e) => return BinaryResolution::ChainBroken(e.kind()), - } - } - - let resolved_str = resolved.to_string_lossy().into_owned(); - - if resolved_str == policy_path { - BinaryResolution::Literal - } else { - // The caller logs the resolution at info level. - BinaryResolution::Resolved(resolved_str) - } -} - -#[cfg(not(target_os = "linux"))] -fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> BinaryResolution { - BinaryResolution::Literal -} - -fn l7_matchers_to_json( - matchers: &std::collections::HashMap, -) -> serde_json::Map { - matchers - .iter() - .map(|(key, matcher)| { - let mut matcher_json = serde_json::json!({}); - if !matcher.glob.is_empty() { - matcher_json["glob"] = matcher.glob.clone().into(); - } - if !matcher.any.is_empty() { - matcher_json["any"] = matcher.any.clone().into(); - } - (key.clone(), matcher_json) - }) - .collect() -} - /// Convert typed proto policy fields to JSON suitable for `engine.add_data_json()`. /// /// The rego rules reference `data.*` directly, so the JSON structure has @@ -2298,225 +1730,7 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St let endpoints: Vec = rule .endpoints .iter() - .map(|e| { - // Normalize port/ports: ports takes precedence, then - // single port promoted to array. Rego always sees "ports". - let ports: Vec = if !e.ports.is_empty() { - e.ports.clone() - } else if e.port > 0 { - vec![e.port] - } else { - vec![] - }; - let mut ep = serde_json::json!({"host": e.host, "ports": ports}); - if !e.path.is_empty() { - ep["path"] = e.path.clone().into(); - } - if !e.protocol.is_empty() { - ep["protocol"] = e.protocol.clone().into(); - } - if e.tls != 0 { - ep["tls"] = openshell_policy::network_tls_mode_to_str(e.tls) - .map_or_else(|| format!("unknown({})", e.tls), str::to_owned) - .into(); - } - if e.enforcement != 0 { - ep["enforcement"] = - openshell_policy::network_enforcement_mode_to_str(e.enforcement) - .map_or_else( - || format!("unknown({})", e.enforcement), - str::to_owned, - ) - .into(); - } - if e.access != 0 { - ep["access"] = openshell_policy::network_access_preset_to_str(e.access) - .map_or_else(|| format!("unknown({})", e.access), str::to_owned) - .into(); - } - if !e.rules.is_empty() { - let rules: Vec = e - .rules - .iter() - .map(|r| { - let a = r.allow.as_ref(); - let mut allow = serde_json::Map::new(); - if let Some(a) = a { - // Proto3 represents absent scalar selectors as empty - // strings. Omit them so protobuf and YAML rules expose - // the same selector families to runtime validation. - if !a.method.is_empty() { - allow.insert("method".to_string(), a.method.clone().into()); - } - if !a.path.is_empty() { - allow.insert("path".to_string(), a.path.clone().into()); - } - if !a.command.is_empty() { - allow - .insert("command".to_string(), a.command.clone().into()); - } - if !a.operation_type.is_empty() { - allow.insert( - "operation_type".to_string(), - a.operation_type.clone().into(), - ); - } - if !a.operation_name.is_empty() { - allow.insert( - "operation_name".to_string(), - a.operation_name.clone().into(), - ); - } - if !a.fields.is_empty() { - allow.insert("fields".to_string(), a.fields.clone().into()); - } - } - let query = a.map_or_else(serde_json::Map::new, |allow| { - l7_matchers_to_json(&allow.query) - }); - if !query.is_empty() { - allow.insert("query".to_string(), query.into()); - } - let params = a.map_or_else(serde_json::Map::new, |allow| { - l7_matchers_to_json(&allow.params) - }); - if !params.is_empty() { - allow.insert("params".to_string(), params.into()); - } - serde_json::json!({ "allow": allow }) - }) - .collect(); - ep["rules"] = rules.into(); - } - if !e.allowed_ips.is_empty() { - ep["allowed_ips"] = e.allowed_ips.clone().into(); - } - if e.advisor_proposed { - ep["advisor_proposed"] = true.into(); - } - if !e.deny_rules.is_empty() { - let deny_rules: Vec = e - .deny_rules - .iter() - .map(|d| { - let mut deny = serde_json::json!({}); - if !d.method.is_empty() { - deny["method"] = d.method.clone().into(); - } - if !d.path.is_empty() { - deny["path"] = d.path.clone().into(); - } - if !d.command.is_empty() { - deny["command"] = d.command.clone().into(); - } - if !d.operation_type.is_empty() { - deny["operation_type"] = d.operation_type.clone().into(); - } - if !d.operation_name.is_empty() { - deny["operation_name"] = d.operation_name.clone().into(); - } - if !d.fields.is_empty() { - deny["fields"] = d.fields.clone().into(); - } - let query = l7_matchers_to_json(&d.query); - if !query.is_empty() { - deny["query"] = query.into(); - } - let params = l7_matchers_to_json(&d.params); - if !params.is_empty() { - deny["params"] = params.into(); - } - deny - }) - .collect(); - ep["deny_rules"] = deny_rules.into(); - } - if e.allow_encoded_slash { - ep["allow_encoded_slash"] = true.into(); - } - if e.websocket_credential_rewrite { - ep["websocket_credential_rewrite"] = true.into(); - } - if e.request_body_credential_rewrite { - ep["request_body_credential_rewrite"] = true.into(); - } - if e.allow_uninspected_credentials { - ep["allow_uninspected_credentials"] = true.into(); - } - if e.provider_credentialed { - ep["provider_credentialed"] = true.into(); - } - if !e.token_grant_owner.is_empty() { - // Preserve the gateway's endpoint identity so admission - // can select its grants without reconstructing ownership - // from overlapping host and path selectors. - ep["token_grant_owner"] = e.token_grant_owner.clone().into(); - } - // Every inspected protocol must bind grant reporting to the - // policy selected before credential acquisition. - ep["policy_hash"] = policy_hash.clone().into(); - if is_mcp_protocol(&e.protocol) { - // Derive endpoint identity from the policy endpoint while - // it is still available. Request handling carries this - // opaque value through exact path selection and never - // recomputes identity from a concrete request host. - ep["endpoint_id"] = - openshell_core::endpoint_status::endpoint_id(e).into(); - } - if !e.credential_signing.is_empty() { - ep["credential_signing"] = e.credential_signing.clone().into(); - } - if !e.signing_service.is_empty() { - ep["signing_service"] = e.signing_service.clone().into(); - } - if !e.signing_region.is_empty() { - ep["signing_region"] = e.signing_region.clone().into(); - } - if let Some(binding) = &e.credential_binding { - ep["credential_binding"] = serde_json::json!({ - "provider": binding.provider.clone(), - }); - } - if !e.persisted_queries.is_empty() { - ep["persisted_queries"] = e.persisted_queries.clone().into(); - } - if !e.graphql_persisted_queries.is_empty() { - let persisted: serde_json::Map = e - .graphql_persisted_queries - .iter() - .map(|(key, op)| { - ( - key.clone(), - serde_json::json!({ - "operation_type": op.operation_type, - "operation_name": op.operation_name, - "fields": op.fields, - }), - ) - }) - .collect(); - ep["graphql_persisted_queries"] = persisted.into(); - } - if e.graphql_max_body_bytes > 0 { - ep["graphql_max_body_bytes"] = e.graphql_max_body_bytes.into(); - } - if e.json_rpc_max_body_bytes > 0 { - ep["json_rpc_max_body_bytes"] = e.json_rpc_max_body_bytes.into(); - } - if let Some(mcp) = &e.mcp { - if e.protocol.eq_ignore_ascii_case("mcp") { - ep["mcp_versions"] = mcp.versions.clone().into(); - } - if let Some(strict_tool_names) = mcp.strict_tool_names { - ep["mcp_strict_tool_names"] = strict_tool_names.into(); - } - if let Some(allow_all_known_mcp_methods) = mcp.allow_all_known_mcp_methods { - ep["mcp_allow_all_known_mcp_methods"] = - allow_all_known_mcp_methods.into(); - } - } - ep - }) + .map(|e| endpoint_policy_value(e, &policy_hash)) .collect(); let binaries: Vec = rule .binaries @@ -2524,46 +1738,8 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St .flat_map(|b| { let binary_entry = |path: &str| serde_json::json!({"path": path}); let mut entries = vec![binary_entry(&b.path)]; - match resolve_binary_in_container(&b.path, entrypoint_pid) { - BinaryResolution::Resolved(resolved) => { - tracing::info!( - "Resolved policy binary symlink: original={} resolved={resolved} pid={entrypoint_pid}", - b.path - ); - entries.push(binary_entry(&resolved)); - } - BinaryResolution::Absent => { - tracing::debug!( - "Policy binary candidate not present in container: path={} pid={entrypoint_pid}. Matched literally.", - b.path - ); - } - BinaryResolution::Inaccessible(kind) => { - tracing::warn!( - "Cannot access container filesystem for symlink resolution: path={} pid={entrypoint_pid} \ - error_kind={kind:?}. Binary paths in policy will be matched literally. \ - If this binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ - use the canonical path instead, or run with CAP_SYS_PTRACE.", - b.path - ); - } - BinaryResolution::CandidateInaccessible(kind) => { - tracing::warn!( - "Cannot access policy binary candidate path: path={} pid={entrypoint_pid} \ - error_kind={kind:?}. Binary path will be matched literally. \ - A parent component is permission-denied or the path forms a symlink loop; \ - the process root itself is reachable.", - b.path - ); - } - BinaryResolution::ChainBroken(kind) => { - tracing::warn!( - "Symlink chain broken during resolution: path={} pid={entrypoint_pid} error_kind={kind:?}. \ - Matched by original path only.", - b.path - ); - } - BinaryResolution::Literal => {}, + if let Some(resolved) = resolve_policy_binary(&b.path, entrypoint_pid) { + entries.push(binary_entry(&resolved)); } entries }) @@ -2577,37 +1753,7 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St }) .collect(); - let mut middleware_entries: Vec<_> = proto.network_middlewares.iter().collect(); - middleware_entries.sort_by_key(|(name, _)| name.as_str()); - let network_middlewares: serde_json::Map = middleware_entries - .into_iter() - .map(|(name, mw)| { - let mut value = serde_json::json!({ - "middleware": mw.middleware, - "order": mw.order, - }); - if !mw.name.is_empty() { - value["name"] = mw.name.clone().into(); - } - if let Some(config) = &mw.config { - value["config"] = openshell_core::proto_struct::struct_to_json_value(config); - } - if !mw.on_error.is_empty() { - value["on_error"] = mw.on_error.clone().into(); - } - if let Some(selector) = &mw.endpoints { - let mut endpoints = serde_json::json!({}); - if !selector.include.is_empty() { - endpoints["include"] = selector.include.clone().into(); - } - if !selector.exclude.is_empty() { - endpoints["exclude"] = selector.exclude.clone().into(); - } - value["endpoints"] = endpoints; - } - (name.clone(), value) - }) - .collect(); + let network_middlewares = crate::policy_data::network_middlewares_json(proto); serde_json::json!({ "filesystem_policy": filesystem_policy, @@ -2629,6 +1775,11 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St )] mod tests { use super::*; + use crate::binary_resolution::{BinaryResolution, normalize_path, resolve_binary_in_container}; + use crate::policy_validation::{ + POLICY_VALIDATION_DIAGNOSTIC_MAX_BYTES, POLICY_VALIDATION_DIAGNOSTIC_MAX_ITEMS, + }; + use openshell_core::mcp::is_mcp_protocol; use openshell_core::mcp::DEFAULT_MCP_PROTOCOL_VERSION; use openshell_core::proto::{ @@ -3357,6 +2508,9 @@ mod tests { }, ); ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -3659,25 +2813,13 @@ network_policies: cmdline_paths: vec![], }; let yaml_config = yaml_engine - .query_endpoint_config(&network_input) + .query_endpoint_config_json(&network_input) .expect("query YAML endpoint config") .unwrap_or_else(|| panic!("{protocol}: expected YAML endpoint config")); let proto_config = proto_engine - .query_endpoint_config(&network_input) + .query_endpoint_config_json(&network_input) .expect("query protobuf endpoint config") .unwrap_or_else(|| panic!("{protocol}: expected protobuf endpoint config")); - let yaml_config: serde_json::Value = serde_json::from_str( - &yaml_config - .to_json_str() - .expect("YAML endpoint config must serialize"), - ) - .expect("YAML endpoint config must be JSON"); - let proto_config: serde_json::Value = serde_json::from_str( - &proto_config - .to_json_str() - .expect("protobuf endpoint config must serialize"), - ) - .expect("protobuf endpoint config must be JSON"); let endpoint = proto.network_policies["parity"] .endpoints @@ -3801,22 +2943,25 @@ network_policies: yaml_engine.reload(TEST_POLICY, &normalized).unwrap(); proto_engine.reload_from_proto(&proto).unwrap(); } - let yaml_config = yaml_engine.query_endpoint_config(&input).unwrap().unwrap(); - let proto_config = proto_engine.query_endpoint_config(&input).unwrap().unwrap(); + let yaml_config = yaml_engine + .query_endpoint_config_json(&input) + .unwrap() + .unwrap(); + let proto_config = proto_engine + .query_endpoint_config_json(&input) + .unwrap() + .unwrap(); assert_endpoint_config_parity( - &serde_json::from_str(&yaml_config.to_json_str().unwrap()).unwrap(), - &serde_json::from_str(&proto_config.to_json_str().unwrap()).unwrap(), + &yaml_config, + &proto_config, &proto, &proto.network_policies["matchers"].endpoints[0], ); for engine in [&yaml_engine, &proto_engine] { let snapshot = engine.authorize_egress(&input).unwrap(); assert_eq!(snapshot.generation, u64::from(phase == "reload")); - assert_eq!( - yaml_config["provider_credentialed"], - regorus::Value::Bool(true) - ); - assert_eq!(yaml_config["advisor_proposed"], regorus::Value::Bool(true)); + assert_eq!(yaml_config["provider_credentialed"], true); + assert_eq!(yaml_config["advisor_proposed"], true); assert!(eval_l7(engine, &allowed), "{protocol} {phase}: allow"); assert!( !eval_l7(engine, &denied), @@ -3868,12 +3013,24 @@ network_policies: let yaml_config = yaml_engine.query_endpoint_config(&input).unwrap(); let proto_config = proto_engine.query_endpoint_config(&input).unwrap(); match (&yaml_config, &proto_config) { - (Some(yaml_config), Some(proto_config)) => assert_endpoint_config_parity( - &serde_json::from_str(&yaml_config.to_json_str().unwrap()).unwrap(), - &serde_json::from_str(&proto_config.to_json_str().unwrap()).unwrap(), - &proto, - &proto.network_policies["parity"].endpoints[0], - ), + (Some(yaml_config), Some(proto_config)) => { + // Grant reporting binds every protocol to the canonical + // policy. Derive this metadata independently and compare + // all remaining configuration. + assert!(yaml_config.policy_hash.is_empty()); + assert!(yaml_config.endpoint_id.is_empty()); + let endpoint = &proto.network_policies["parity"].endpoints[0]; + let mut expected = yaml_config.clone(); + expected.policy_hash = deterministic_policy_hash(&proto); + if is_mcp_protocol(&endpoint.protocol) { + expected.endpoint_id = + openshell_core::endpoint_status::endpoint_id(endpoint); + } + assert_eq!( + &expected, proto_config, + "{protocol}: endpoint configuration must match apart from verified gateway identity" + ); + } _ => assert_eq!(yaml_config, proto_config), } for engine in [&yaml_engine, &proto_engine] { @@ -3883,7 +3040,10 @@ network_policies: )); if protocol == "sql" { let config = engine.query_endpoint_config(&input).unwrap().unwrap(); - assert_eq!(config["enforcement"], regorus::Value::from("audit")); + assert_eq!( + config.enforcement, + crate::policy_types::EnforcementSetting::Audit + ); let mut request = l7_input(&input.host, 443, "", "/"); request["request"]["command"] = "SELECT".into(); assert!(eval_l7(engine, &request)); @@ -4120,16 +3280,12 @@ process: assert_eq!(snapshot.endpoints.len(), 4); assert_eq!(snapshot.endpoints[0].policy_name, "dns_transport"); assert_eq!(snapshot.endpoints[0].endpoint_index, 0); + assert_eq!(snapshot.endpoints[0].endpoint.host, "resolver.example"); assert_eq!( - get_str(&snapshot.endpoints[0].endpoint, "host").as_deref(), - Some("resolver.example") + snapshot.endpoints[0].endpoint.ports, + [53, 853], + "eligible endpoint must retain concrete ports" ); - let Some(regorus::Value::Array(ports)) = - get_field(&snapshot.endpoints[0].endpoint, "ports") - else { - panic!("eligible endpoint must retain concrete ports"); - }; - assert_eq!(ports.as_ref(), &[53.into(), 853.into()]); assert_eq!(snapshot.endpoints[1].endpoint_index, 1); assert_eq!(snapshot.endpoints[2].endpoint_index, 2); assert_eq!(snapshot.endpoints[3].endpoint_index, 4); @@ -5719,6 +4875,9 @@ process: }, ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -6342,6 +5501,9 @@ network_policies: ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -6413,6 +5575,9 @@ network_policies: ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -6489,6 +5654,9 @@ network_policies: ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -7709,9 +6877,13 @@ network_policies: )); } else { let config = config.expect("L7 endpoint configuration"); + let published = engine + .query_endpoint_config_json(&input) + .expect("query endpoint config") + .expect("published endpoint configuration"); assert_eq!( - config["protocol"], - regorus::Value::from(canonical), + published["protocol"], + serde_json::json!(canonical), "published endpoint protocol for {authored}" ); crate::l7::parse_l7_config(&config).expect("valid typed L7 configuration"); @@ -7888,12 +7060,10 @@ network_policies: .expect("expected MCP endpoint config"); let l7 = crate::l7::parse_l7_config(&config).expect("parse L7 endpoint config"); - let config_json: serde_json::Value = serde_json::from_str( - &config - .to_json_str() - .expect("endpoint config must serialize as JSON"), - ) - .expect("endpoint config must be valid JSON"); + let config_json = engine + .query_endpoint_config_json(&input) + .expect("query endpoint config") + .expect("endpoint config must be valid JSON"); assert_eq!( config_json["protocol"], serde_json::json!("mcp"), @@ -8232,6 +7402,9 @@ network_policies: }, ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -8289,6 +7462,9 @@ network_policies: }, ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -8347,6 +7523,9 @@ network_policies: }, ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -8407,6 +7586,9 @@ network_policies: }, ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -8466,6 +7648,9 @@ network_policies: }, ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -8527,7 +7712,7 @@ network_policies: .unwrap(); // Verify the cloned engine can evaluate let input_json = l7_input("api.example.com", 8080, "GET", "/repos/myorg/foo"); - let mut eng = cloned.engine().lock().unwrap(); + let mut eng = cloned.rego_evaluator_for_tests().engine_for_tests(); set_regorus_input(&mut eng, input_json).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) @@ -8569,7 +7754,7 @@ network_policies: let cloned = engine .clone_engine_for_tunnel(engine.current_generation()) .unwrap(); - let mut eng = cloned.engine().lock().unwrap(); + let mut eng = cloned.rego_evaluator_for_tests().engine_for_tests(); set_regorus_input(&mut eng, input_json).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) @@ -8655,12 +7840,10 @@ network_policies: .query_endpoint_config(&input) .expect("query endpoint config") .expect("expected MCP endpoint config"); - let config_json: serde_json::Value = serde_json::from_str( - &config - .to_json_str() - .expect("endpoint config must serialize as JSON"), - ) - .expect("endpoint config must be valid JSON"); + let config_json = engine + .query_endpoint_config_json(&input) + .expect("query endpoint config") + .expect("endpoint config must be valid JSON"); assert_eq!(config_json["protocol"], serde_json::json!("mcp")); let l7 = crate::l7::parse_l7_config(&config).expect("parse L7 endpoint config"); assert_eq!( @@ -9273,9 +8456,9 @@ process: .unwrap(); assert_eq!(generation, engine.current_generation()); assert_eq!(configs.len(), 2); - assert_eq!(get_str(&configs[0], "tls").as_deref(), Some("skip")); - assert_eq!(get_str_array(&configs[0], "allowed_ips"), ["192.168.1.100"]); - assert_eq!(get_str(&configs[1], "tls"), None); + assert_eq!(configs[0].tls, crate::policy_types::TlsSetting::Skip); + assert_eq!(configs[0].allowed_ips, ["192.168.1.100"]); + assert_eq!(configs[1].tls, crate::policy_types::TlsSetting::Auto); let selected = engine.query_endpoint_config(&input).unwrap().unwrap(); assert_eq!( @@ -9734,7 +8917,7 @@ process: assert!(authorization.exact_declared_endpoint_host); assert_eq!(authorization.endpoint_configs.len(), 1); assert_eq!( - get_str_array(&authorization.endpoint_configs[0], "allowed_ips"), + authorization.endpoint_configs[0].allowed_ips, vec!["10.0.5.0/24"] ); assert_eq!(authorization.matched_endpoints.len(), 1); @@ -9744,8 +8927,8 @@ process: ); assert_eq!(authorization.matched_endpoints[0].endpoint_index, 0); assert_eq!( - get_str(&authorization.matched_endpoints[0].endpoint, "host").as_deref(), - Some("my-service.corp.net") + authorization.matched_endpoints[0].endpoint.host, + "my-service.corp.net" ); } @@ -9792,8 +8975,8 @@ process: assert_eq!(matched.policy_name, "native_tcp"); assert_eq!(matched.endpoint_index, 0); assert_eq!( - get_str(&matched.endpoint, "protocol").as_deref(), - Some("tcp") + matched.endpoint.protocol, + crate::policy_types::EndpointProtocol::Tcp ); } @@ -9977,6 +9160,9 @@ network_policies: }, ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -10047,6 +9233,9 @@ network_policies: }, ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -10277,6 +9466,9 @@ network_policies: }, ); let proto = ProtoSandboxPolicy { + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), version: 1, filesystem: Some(ProtoFs { include_workdir: true, @@ -11257,6 +10449,9 @@ network_policies: process: None, network_policies, network_middlewares: std::collections::HashMap::default(), + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), }; let pid = std::process::id(); // accessible root, leaf paths absent @@ -11940,6 +11135,9 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), }; // Build engine with our PID (symlink resolution will work via /proc/self/root/) @@ -12103,6 +11301,9 @@ process: }), network_policies, network_middlewares: std::collections::HashMap::default(), + cedar_policy_source: String::new(), + provider_credential_rules: std::collections::HashMap::default(), + endpoint_settings: Vec::new(), }; // Initial load at pid=0 — no symlink expansion @@ -12196,47 +11397,6 @@ network_policies: ); } - fn matching_middleware_configs(count: usize) -> Vec { - (0..count) - .map(|index| { - regorus::Value::from(serde_json::json!({ - "name": format!("stage-{index}"), - "middleware": "openshell/regex", - "endpoints": {"include": ["api.example.com"]} - })) - }) - .collect() - } - - #[test] - fn middleware_chain_accepts_maximum_selected_stages() { - let configs = matching_middleware_configs( - openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES, - ); - - let chain = - global_middleware_entries(&configs, "api.example.com").expect("maximum selected chain"); - assert_eq!( - chain.len(), - openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES - ); - } - - #[test] - fn middleware_chain_rejects_selected_stages_over_capacity() { - let configs = matching_middleware_configs( - openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_STAGES + 1, - ); - - let error = global_middleware_entries(&configs, "api.example.com") - .expect_err("selected chain over capacity"); - assert!( - error - .to_string() - .contains("selected middleware stage count exceeds platform maximum 10") - ); - } - #[test] fn middleware_chain_uses_dns_label_glob_semantics() { let data = r#" diff --git a/crates/openshell-supervisor-network/src/opa/raw_schema.rs b/crates/openshell-supervisor-network/src/opa/raw_schema.rs index 9090265507..02ae769034 100644 --- a/crates/openshell-supervisor-network/src/opa/raw_schema.rs +++ b/crates/openshell-supervisor-network/src/opa/raw_schema.rs @@ -352,10 +352,9 @@ mod tests { assert_eq!(data, original); let engine = OpaEngine::from_strings(BAKED_POLICY_RULES, &data.to_string()).unwrap(); let endpoint = engine - .query_endpoint_config(&input("private-policy-host.test")) + .query_endpoint_config_json(&input("private-policy-host.test")) .unwrap() .unwrap(); - let endpoint: Value = serde_json::from_str(&endpoint.to_string()).unwrap(); assert_eq!(endpoint["allow_encoded_slash"], true); assert_eq!(endpoint["provider_credentialed"], true); assert_eq!(endpoint["policy_hash"], "private-hash"); diff --git a/crates/openshell-supervisor-network/src/policy_data.rs b/crates/openshell-supervisor-network/src/policy_data.rs new file mode 100644 index 0000000000..98a5ab80e8 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_data.rs @@ -0,0 +1,460 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Engine-neutral shaping of YAML network policy data. +//! +//! Builds the JSON form of proto network policies and normalizes it the way +//! every consumer reads it: canonical protocol names, MCP tool aliases moved +//! into `params`, and matcher leaves in object form. The Rego engine loads the +//! result as its data, and a Cedar sandbox compiles provider rules from it +//! (see `crate::cedar_provider_rules`). + +use openshell_core::mcp::is_mcp_protocol; +use openshell_policy::L7Protocol as PolicyL7Protocol; + +fn l7_matchers_to_json( + matchers: &std::collections::HashMap, +) -> serde_json::Map { + matchers + .iter() + .map(|(key, matcher)| { + let mut matcher_json = serde_json::json!({}); + if !matcher.glob.is_empty() { + matcher_json["glob"] = matcher.glob.clone().into(); + } + if !matcher.any.is_empty() { + matcher_json["any"] = matcher.any.clone().into(); + } + (key.clone(), matcher_json) + }) + .collect() +} + +/// Converts one policy endpoint into the JSON shape the L7 config parser reads. +/// +/// Shared by the Rego data builder and the Cedar engine, which emits provider +/// endpoint settings for connections it allows. `policy_hash` identifies the +/// policy for MCP endpoint observation. +pub fn endpoint_policy_value( + e: &openshell_core::proto::NetworkEndpoint, + policy_hash: &str, +) -> serde_json::Value { + // Normalize port/ports: ports takes precedence, then + // single port promoted to array. Rego always sees "ports". + let ports: Vec = if !e.ports.is_empty() { + e.ports.clone() + } else if e.port > 0 { + vec![e.port] + } else { + vec![] + }; + let mut ep = serde_json::json!({"host": e.host, "ports": ports}); + if !e.path.is_empty() { + ep["path"] = e.path.clone().into(); + } + if !e.protocol.is_empty() { + ep["protocol"] = e.protocol.clone().into(); + } + if e.tls != 0 { + ep["tls"] = openshell_policy::network_tls_mode_to_str(e.tls) + .map_or_else(|| format!("unknown({})", e.tls), str::to_owned) + .into(); + } + if e.enforcement != 0 { + ep["enforcement"] = openshell_policy::network_enforcement_mode_to_str(e.enforcement) + .map_or_else(|| format!("unknown({})", e.enforcement), str::to_owned) + .into(); + } + if e.access != 0 { + ep["access"] = openshell_policy::network_access_preset_to_str(e.access) + .map_or_else(|| format!("unknown({})", e.access), str::to_owned) + .into(); + } + if !e.rules.is_empty() { + let rules: Vec = e + .rules + .iter() + .map(|r| { + let a = r.allow.as_ref(); + let mut allow = serde_json::Map::new(); + if let Some(a) = a { + // Proto3 represents absent scalar selectors as empty + // strings. Omit them so protobuf and YAML rules expose + // the same selector families to runtime validation. + if !a.method.is_empty() { + allow.insert("method".to_string(), a.method.clone().into()); + } + if !a.path.is_empty() { + allow.insert("path".to_string(), a.path.clone().into()); + } + if !a.command.is_empty() { + allow.insert("command".to_string(), a.command.clone().into()); + } + if !a.operation_type.is_empty() { + allow.insert( + "operation_type".to_string(), + a.operation_type.clone().into(), + ); + } + if !a.operation_name.is_empty() { + allow.insert( + "operation_name".to_string(), + a.operation_name.clone().into(), + ); + } + if !a.fields.is_empty() { + allow.insert("fields".to_string(), a.fields.clone().into()); + } + } + let query = a.map_or_else(serde_json::Map::new, |allow| { + l7_matchers_to_json(&allow.query) + }); + if !query.is_empty() { + allow.insert("query".to_string(), query.into()); + } + let params = a.map_or_else(serde_json::Map::new, |allow| { + l7_matchers_to_json(&allow.params) + }); + if !params.is_empty() { + allow.insert("params".to_string(), params.into()); + } + serde_json::json!({ "allow": allow }) + }) + .collect(); + ep["rules"] = rules.into(); + } + if !e.allowed_ips.is_empty() { + ep["allowed_ips"] = e.allowed_ips.clone().into(); + } + if e.advisor_proposed { + ep["advisor_proposed"] = true.into(); + } + if !e.deny_rules.is_empty() { + let deny_rules: Vec = e + .deny_rules + .iter() + .map(|d| { + let mut deny = serde_json::json!({}); + if !d.method.is_empty() { + deny["method"] = d.method.clone().into(); + } + if !d.path.is_empty() { + deny["path"] = d.path.clone().into(); + } + if !d.command.is_empty() { + deny["command"] = d.command.clone().into(); + } + if !d.operation_type.is_empty() { + deny["operation_type"] = d.operation_type.clone().into(); + } + if !d.operation_name.is_empty() { + deny["operation_name"] = d.operation_name.clone().into(); + } + if !d.fields.is_empty() { + deny["fields"] = d.fields.clone().into(); + } + let query = l7_matchers_to_json(&d.query); + if !query.is_empty() { + deny["query"] = query.into(); + } + let params = l7_matchers_to_json(&d.params); + if !params.is_empty() { + deny["params"] = params.into(); + } + deny + }) + .collect(); + ep["deny_rules"] = deny_rules.into(); + } + if e.allow_encoded_slash { + ep["allow_encoded_slash"] = true.into(); + } + if e.websocket_credential_rewrite { + ep["websocket_credential_rewrite"] = true.into(); + } + if e.request_body_credential_rewrite { + ep["request_body_credential_rewrite"] = true.into(); + } + if e.allow_uninspected_credentials { + ep["allow_uninspected_credentials"] = true.into(); + } + if e.provider_credentialed { + ep["provider_credentialed"] = true.into(); + } + if !e.token_grant_owner.is_empty() { + // Preserve the gateway's endpoint identity so admission + // can select its grants without reconstructing ownership + // from overlapping host and path selectors. + ep["token_grant_owner"] = e.token_grant_owner.clone().into(); + } + // Every inspected protocol must bind grant reporting to the + // policy selected before credential acquisition. + ep["policy_hash"] = policy_hash.into(); + if is_mcp_protocol(&e.protocol) { + // Derive endpoint identity from the policy endpoint while + // it is still available. Request handling carries this + // opaque value through exact path selection and never + // recomputes identity from a concrete request host. + ep["endpoint_id"] = openshell_core::endpoint_status::endpoint_id(e).into(); + } + if !e.credential_signing.is_empty() { + ep["credential_signing"] = e.credential_signing.clone().into(); + } + if !e.signing_service.is_empty() { + ep["signing_service"] = e.signing_service.clone().into(); + } + if !e.signing_region.is_empty() { + ep["signing_region"] = e.signing_region.clone().into(); + } + if let Some(binding) = &e.credential_binding { + ep["credential_binding"] = serde_json::json!({ + "provider": binding.provider.clone(), + }); + } + if !e.persisted_queries.is_empty() { + ep["persisted_queries"] = e.persisted_queries.clone().into(); + } + if !e.graphql_persisted_queries.is_empty() { + let persisted: serde_json::Map = e + .graphql_persisted_queries + .iter() + .map(|(key, op)| { + ( + key.clone(), + serde_json::json!({ + "operation_type": op.operation_type, + "operation_name": op.operation_name, + "fields": op.fields, + }), + ) + }) + .collect(); + ep["graphql_persisted_queries"] = persisted.into(); + } + if e.graphql_max_body_bytes > 0 { + ep["graphql_max_body_bytes"] = e.graphql_max_body_bytes.into(); + } + if e.json_rpc_max_body_bytes > 0 { + ep["json_rpc_max_body_bytes"] = e.json_rpc_max_body_bytes.into(); + } + if let Some(mcp) = &e.mcp { + if e.protocol.eq_ignore_ascii_case("mcp") { + ep["mcp_versions"] = mcp.versions.clone().into(); + } + if let Some(strict_tool_names) = mcp.strict_tool_names { + ep["mcp_strict_tool_names"] = strict_tool_names.into(); + } + if let Some(allow_all_known_mcp_methods) = mcp.allow_all_known_mcp_methods { + ep["mcp_allow_all_known_mcp_methods"] = allow_all_known_mcp_methods.into(); + } + } + ep +} + +/// Converts a policy's `network_middlewares` into the JSON shape the policy +/// data carries, keyed by config name in sorted order. +/// +/// Shared by the Rego data builder and the Cedar middleware state, so both +/// engines select middleware from identical configs. +pub fn network_middlewares_json( + policy: &openshell_core::proto::SandboxPolicy, +) -> serde_json::Map { + let mut middleware_entries: Vec<_> = policy.network_middlewares.iter().collect(); + middleware_entries.sort_by_key(|(name, _)| name.as_str()); + middleware_entries + .into_iter() + .map(|(name, mw)| { + let mut value = serde_json::json!({ + "middleware": mw.middleware, + "order": mw.order, + }); + if !mw.name.is_empty() { + value["name"] = mw.name.clone().into(); + } + if let Some(config) = &mw.config { + value["config"] = openshell_core::proto_struct::struct_to_json_value(config); + } + if !mw.on_error.is_empty() { + value["on_error"] = mw.on_error.clone().into(); + } + if let Some(selector) = &mw.endpoints { + let mut endpoints = serde_json::json!({}); + if !selector.include.is_empty() { + endpoints["include"] = selector.include.clone().into(); + } + if !selector.exclude.is_empty() { + endpoints["exclude"] = selector.exclude.clone().into(); + } + value["endpoints"] = endpoints; + } + (name.clone(), value) + }) + .collect() +} + +/// Canonicalize recognized protocol spellings before L7 validation or Rego use. +/// +/// Rust parses protocol names case-insensitively, while Rego compares stable +/// wire keys. Publishing one canonical spelling keeps rule validation, typed +/// routing, and authorization on the same protocol branch. +pub fn normalize_endpoint_protocols(data: &mut serde_json::Value) { + let Some(policies) = data + .get_mut("network_policies") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + + for policy in policies.values_mut() { + let Some(endpoints) = policy + .get_mut("endpoints") + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + + for endpoint in endpoints { + let Some(endpoint) = endpoint.as_object_mut() else { + continue; + }; + let Some(protocol) = endpoint.get("protocol").and_then(serde_json::Value::as_str) + else { + continue; + }; + let canonical = if protocol.eq_ignore_ascii_case("tcp") { + Some("tcp") + } else { + PolicyL7Protocol::parse(protocol).map(|protocol| match protocol { + PolicyL7Protocol::Rest => "rest", + PolicyL7Protocol::Websocket => "websocket", + PolicyL7Protocol::Graphql => "graphql", + PolicyL7Protocol::Sql => "sql", + PolicyL7Protocol::JsonRpc => "json-rpc", + PolicyL7Protocol::Mcp => "mcp", + }) + }; + if let Some(canonical) = canonical { + endpoint.insert( + "protocol".to_string(), + serde_json::Value::String(canonical.to_string()), + ); + } + } + } +} + +pub fn normalize_l7_policy_rule_aliases(data: &mut serde_json::Value) { + let Some(policies) = data + .get_mut("network_policies") + .and_then(|v| v.as_object_mut()) + else { + return; + }; + + for (_name, policy) in policies.iter_mut() { + let Some(endpoints) = policy.get_mut("endpoints").and_then(|v| v.as_array_mut()) else { + continue; + }; + + for ep in endpoints.iter_mut() { + let Some(ep_obj) = ep.as_object_mut() else { + continue; + }; + normalize_l7_rules_aliases(ep_obj); + } + } +} + +fn normalize_l7_rules_aliases(ep: &mut serde_json::Map) { + let protocol = ep + .get("protocol") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + let mcp_allow_all_known_mcp_methods = ep + .get("mcp_allow_all_known_mcp_methods") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if let Some(rules) = ep.get_mut("rules").and_then(|v| v.as_array_mut()) { + for rule in rules { + if let Some(allow) = rule + .get_mut("allow") + .and_then(serde_json::Value::as_object_mut) + { + normalize_l7_rule_aliases(allow, &protocol, mcp_allow_all_known_mcp_methods); + } else if let Some(allow) = rule.as_object_mut() { + normalize_l7_rule_aliases(allow, &protocol, mcp_allow_all_known_mcp_methods); + } + } + } + + if let Some(denies) = ep.get_mut("deny_rules").and_then(|v| v.as_array_mut()) { + for deny in denies { + if let Some(deny_obj) = deny.as_object_mut() { + normalize_l7_rule_aliases(deny_obj, &protocol, mcp_allow_all_known_mcp_methods); + } + } + } +} + +fn normalize_l7_rule_aliases( + rule: &mut serde_json::Map, + protocol: &str, + mcp_allow_all_known_mcp_methods: bool, +) { + if protocol == "mcp" { + let mut has_tool_selector = rule + .get("params") + .and_then(serde_json::Value::as_object) + .and_then(|params| params.get("name")) + .is_some_and(|v| !v.is_null()); + if let Some(tool) = rule.remove("tool").filter(|v| !v.is_null()) { + let params = rule + .entry("params".to_string()) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + if let Some(params) = params.as_object_mut() { + params.entry("name".to_string()).or_insert(tool); + has_tool_selector = true; + } + } + + if mcp_allow_all_known_mcp_methods + && rule + .get("method") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .is_empty() + { + let method = if has_tool_selector { "tools/call" } else { "*" }; + rule.insert( + "method".to_string(), + serde_json::Value::String(method.to_string()), + ); + } + } + + // MCP tool aliases must move into params before matcher normalization so + // both authored forms produce the same endpoint configuration as protobuf. + normalize_l7_matcher_map(rule, "query"); + normalize_l7_matcher_map(rule, "params"); +} + +/// Normalize nonempty matcher leaves to the protobuf runtime representation. +/// +/// OPA data also accepts explicit `glob` and `any` objects. Keeping those intact +/// makes normalization idempotent for already lowered data and protobuf reloads. +fn normalize_l7_matcher_map(rule: &mut serde_json::Map, field: &str) { + let Some(matchers) = rule + .get_mut(field) + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + for matcher in matchers.values_mut() { + // Rego permits an empty scalar to match an empty query value, whereas + // an empty glob object never matches. Preserve this OPA-only form. + if matcher.as_str().is_some_and(|glob| !glob.is_empty()) { + *matcher = serde_json::json!({ "glob": matcher.take() }); + } + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/mod.rs b/crates/openshell-supervisor-network/src/policy_dns/mod.rs index ee1a5cc122..d57cac58ad 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/mod.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/mod.rs @@ -24,6 +24,7 @@ mod store; mod wire; pub(crate) use name::NormalizedName; +pub(crate) use resolver::ResolveError as TrustedResolveError; pub(crate) use resolver::{AddressFamily, SocketTrustedResolver, TrustedAnswer, TrustedResolver}; pub(crate) use runtime::{PolicyDnsRuntime, PolicyDnsRuntimeConfig}; pub(crate) use store::{ @@ -32,7 +33,7 @@ pub(crate) use store::{ SyntheticPools, }; -use crate::opa::OpaEngine; +use crate::policy_engine::PolicyEngine; use crate::proxy::destination::{build_validation_plan, filter_resolved_addresses}; use crate::proxy::is_host_gateway_alias; use openshell_core::host_pattern::HostSelector; @@ -84,7 +85,7 @@ pub(crate) enum PolicyDnsError { /// No socket is bound by this type. A later runtime adapter owns listener and /// namespace lifecycle and calls the bounded wire helpers in this module. pub(crate) struct PolicyDnsService { - policy: Arc, + policy: PolicyEngine, resolver: R, store: Arc, trusted_host_gateway: Option, @@ -92,13 +93,13 @@ pub(crate) struct PolicyDnsService { impl PolicyDnsService { pub(crate) fn new( - policy: Arc, + policy: impl Into, resolver: R, store: Arc, trusted_host_gateway: Option, ) -> Self { Self { - policy, + policy: policy.into(), resolver, store, trusted_host_gateway, @@ -359,20 +360,24 @@ fn eligible_endpoints( ) -> Result, PolicyDnsError> { let mut eligible = Vec::new(); for endpoint in endpoints { - let Some(pattern) = value_string(&endpoint.endpoint, "host") else { + if endpoint.endpoint.host.is_empty() { continue; - }; - let pattern = pattern.trim_end_matches('.').to_ascii_lowercase(); + } + let pattern = endpoint + .endpoint + .host + .trim_end_matches('.') + .to_ascii_lowercase(); let selector = HostSelector::new(std::slice::from_ref(&pattern), &[]) .map_err(PolicyDnsError::Policy)?; if !selector.matches(name.as_str()) { continue; } - let ports = value_ports(&endpoint.endpoint); + let ports = record_ports(&endpoint.endpoint); if ports.is_empty() { continue; } - let raw_allowed_ips = value_string_array(&endpoint.endpoint, "allowed_ips"); + let raw_allowed_ips = endpoint.endpoint.allowed_ips.clone(); let exact_declared_host = !pattern.contains('*') && pattern == name.as_str(); let destination_plan = build_validation_plan( name.as_str(), @@ -381,6 +386,7 @@ fn eligible_endpoints( None, &raw_allowed_ips, exact_declared_host, + None, ) .map_err(|error| PolicyDnsError::Policy(error.reason))?; eligible.push(EligibleEndpoint { @@ -390,7 +396,7 @@ fn eligible_endpoints( }, ports, destination_plan, - contract_fingerprint: endpoint.endpoint.to_string(), + contract_fingerprint: format!("{:?}", endpoint.endpoint), }); } Ok(eligible) @@ -427,47 +433,14 @@ fn eligible_endpoint_context(endpoints: &[EligibleEndpoint]) -> Vec(value: &'a regorus::Value, key: &str) -> Option<&'a regorus::Value> { - let regorus::Value::Object(fields) = value else { - return None; - }; - fields.get(®orus::Value::String(key.into())) -} - -fn value_string(value: ®orus::Value, key: &str) -> Option { - match value_field(value, key) { - Some(regorus::Value::String(value)) => Some(value.to_string()), - _ => None, - } -} - -fn value_string_array(value: ®orus::Value, key: &str) -> Vec { - match value_field(value, key) { - Some(regorus::Value::Array(values)) => values - .iter() - .filter_map(|value| match value { - regorus::Value::String(value) => Some(value.to_string()), - _ => None, - }) - .collect(), - _ => Vec::new(), - } -} - -fn value_ports(value: ®orus::Value) -> Vec { - let mut ports = match value_field(value, "ports") { - Some(regorus::Value::Array(values)) => values - .iter() - .filter_map(|value| match value { - regorus::Value::Number(number) => number - .as_i64() - .and_then(|port| u16::try_from(port).ok()) - .filter(|port| *port != 0), - _ => None, - }) - .collect::>(), - _ => Vec::new(), - }; +/// Returns a record's usable ports, sorted and deduplicated, without port 0. +fn record_ports(record: &crate::policy_types::DnsRecord) -> Vec { + let mut ports: Vec = record + .ports + .iter() + .copied() + .filter(|port| *port != 0) + .collect(); ports.sort_unstable(); ports.dedup(); ports @@ -658,6 +631,7 @@ fn build_mapping_publication_event(record: &ResolvedEndpointRecord) -> openshell #[cfg(test)] mod tests { use super::*; + use crate::opa::OpaEngine; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::Notify; diff --git a/crates/openshell-supervisor-network/src/policy_dns/runtime.rs b/crates/openshell-supervisor-network/src/policy_dns/runtime.rs index 6463792a1e..3ce0f5fe2e 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/runtime.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/runtime.rs @@ -3,10 +3,10 @@ //! Runtime-owned policy DNS listeners for combined Linux supervisors. -use crate::opa::OpaEngine; use crate::policy_dns::resolver::MAX_DNS_MESSAGE_BYTES; use crate::policy_dns::store::{ResolvedEndpointStore, StoreConfig, SyntheticPools}; use crate::policy_dns::{POLICY_LOCAL_ADDRESS, PolicyDnsService, SocketTrustedResolver, wire}; +use crate::policy_engine::PolicyEngine; use futures::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::net::set_tcp_nodelay_best_effort; @@ -93,7 +93,7 @@ impl PolicyDnsRuntime { /// Start policy DNS over an isolation-backend exchange source. No UDP or /// TCP listener is bound in the supervisor namespace. pub(crate) fn start_mediated( - policy: Arc, + policy: PolicyEngine, source: Arc, trusted_host_gateway: Option, config: PolicyDnsRuntimeConfig, @@ -182,7 +182,7 @@ impl PolicyDnsRuntime { } pub(crate) fn start( - policy: Arc, + policy: PolicyEngine, udp: tokio::net::UdpSocket, tcp: tokio::net::TcpListener, trusted_host_gateway: Option, diff --git a/crates/openshell-supervisor-network/src/policy_engine.rs b/crates/openshell-supervisor-network/src/policy_engine.rs new file mode 100644 index 0000000000..3c0249abf4 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_engine.rs @@ -0,0 +1,536 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The authoritative policy engine for a sandbox's network decisions. +//! +//! A sandbox's policy is either YAML, enforced by [`OpaEngine`], or Cedar, +//! enforced by [`CedarOnlyEngine`]. The choice is made once at startup from +//! the policy format and never changes. [`PolicyEngine`] is the single type +//! every network decision point (CONNECT, forward proxy, policy DNS, L7 +//! relay setup, and middleware selection) receives, so decision code cannot +//! reach the wrong engine. +//! +//! Each variant owns everything its sandbox needs. A Cedar sandbox's +//! middleware configs, services, and their generation live in its +//! [`CedarOnlyEngine`] (see `crate::network_middleware`), so a Cedar +//! sandbox constructs no OPA engine and evaluates no Rego. + +use std::fmt; +use std::sync::Arc; + +use miette::Result; +use openshell_supervisor_middleware::{ChainEntry, ChainRunner, MiddlewareRegistry}; + +use crate::cedar_only::{CedarL7TunnelEngine, CedarOnlyEngine}; +use crate::l7::websocket::WebSocketAssemblyBudget; +use crate::network_middleware::{ActiveMiddleware, MiddlewareConfigs}; +use crate::opa::{OpaEngine, RegoTunnelEvaluator}; +use crate::policy_types::{ + EgressAuthorization, NetworkInput, PolicyDnsEligibilitySnapshot, PolicyGenerationGuard, +}; + +/// The engine whose decisions are authoritative for one sandbox. +/// +/// Cloning is cheap and shares the underlying engine. +#[derive(Clone)] +pub enum PolicyEngine { + /// A YAML-authored policy, evaluated by Rego. + Opa(Arc), + /// A Cedar-authored policy, evaluated by Cedar. + Cedar(Arc), +} + +impl PolicyEngine { + /// Authorizes one egress request against one policy generation. + /// + /// # Errors + /// + /// Returns an error only for an evaluator-internal failure. A policy + /// denial is `Ok` with a deny action. + pub fn authorize_egress(&self, input: &NetworkInput) -> Result { + match self { + Self::Opa(engine) => engine.authorize_egress(input), + Self::Cedar(engine) => engine.authorize_egress(input), + } + } + + /// Returns the active policy generation, advanced by each reload. + #[must_use] + pub fn current_generation(&self) -> u64 { + match self { + Self::Opa(engine) => engine.current_generation(), + Self::Cedar(engine) => engine.current_generation(), + } + } + + /// Pins `expected_generation` for a long-lived operation. + /// + /// # Errors + /// + /// Returns an error if `expected_generation` is already stale. + pub fn generation_guard(&self, expected_generation: u64) -> Result { + match self { + Self::Opa(engine) => engine.generation_guard(expected_generation), + Self::Cedar(engine) => engine.generation_guard(expected_generation), + } + } + + /// Runs `operation` only while `expected_generation` is current. + /// + /// The check and `operation` run under the engine's reload lock, so no + /// reload can land between them. `operation` must not block. + /// + /// # Errors + /// + /// Returns an error if the engine lock is poisoned. + pub fn with_current_generation( + &self, + expected_generation: u64, + operation: impl FnOnce(u64) -> T, + ) -> Result> { + match self { + Self::Opa(engine) => engine.with_current_generation(expected_generation, operation), + Self::Cedar(engine) => engine.with_current_generation(expected_generation, operation), + } + } + + /// Pins the generation that each committed runtime configuration advances. + /// + /// The policy poll loop uses it to tell whether the configuration it + /// installed is still the active one. For YAML this is the policy + /// generation. For Cedar it is the middleware generation, which advances + /// on every committed reload, service registry change, and quarantine + /// entry or exit, while the Cedar policy generation advances only when + /// the policy itself changes or a quarantine starts or ends. + /// + /// # Errors + /// + /// Returns an error if the generation changes while it is pinned. + pub fn configuration_generation_guard(&self) -> Result { + match self { + Self::Opa(engine) => engine.generation_guard(engine.current_generation()), + Self::Cedar(engine) => { + let middleware = engine.middleware(); + middleware.generation_guard(middleware.current_generation()) + } + } + } + + /// Returns whether egress authorization requires a binary identity. + #[must_use] + pub fn binary_identity_required(&self) -> bool { + match self { + Self::Opa(engine) => engine.binary_identity_required(), + Self::Cedar(_) => true, + } + } + + /// Returns the buffering budget for WebSocket frames under inspection. + #[must_use] + pub fn websocket_assembly_budget(&self) -> WebSocketAssemblyBudget { + match self { + Self::Opa(engine) => engine.websocket_assembly_budget(), + Self::Cedar(_) => WebSocketAssemblyBudget::default(), + } + } + + /// Returns the endpoints eligible for policy-gated DNS resolution. + /// + /// # Errors + /// + /// Returns an error only for an evaluator-internal failure. + pub fn policy_dns_eligibility_snapshot(&self) -> Result { + match self { + Self::Opa(engine) => engine.policy_dns_eligibility_snapshot(), + Self::Cedar(engine) => engine.policy_dns_eligibility_snapshot(), + } + } + + /// Publishes a deny-all quarantine generation on the authoritative engine. + /// + /// # Errors + /// + /// Returns an error if an engine lock is poisoned. + pub fn enter_fail_closed(&self, reason: impl Into) -> Result { + match self { + Self::Opa(engine) => engine.enter_fail_closed(reason), + Self::Cedar(engine) => engine.enter_fail_closed(reason), + } + } + + /// Ends a quarantine on the authoritative engine, keeping its active policy. + /// + /// # Errors + /// + /// Returns an error if an engine lock is poisoned. + pub fn exit_fail_closed(&self) -> Result { + match self { + Self::Opa(engine) => engine.exit_fail_closed(), + Self::Cedar(engine) => engine.exit_fail_closed(), + } + } + + /// Returns the quarantine reason while a quarantine is active. + #[must_use] + pub fn fail_closed_reason(&self) -> Option { + match self { + Self::Opa(engine) => engine.fail_closed_reason(), + Self::Cedar(engine) => engine.fail_closed_reason(), + } + } + + /// Returns the endpoint settings the credential guard checks for a request. + /// + /// The guard refuses an uninspected connection to an endpoint that + /// carries provider credentials. + /// + /// # Errors + /// + /// Returns an error only for an evaluator-internal failure. + pub fn endpoint_credential_guards( + &self, + input: &NetworkInput, + ) -> Result> { + match self { + Self::Opa(engine) => engine.query_endpoint_credential_guards(input), + Self::Cedar(engine) => engine.credential_guards(&input.host, input.port), + } + } + + /// Returns the generation the middleware chain and services come from. + /// + /// For YAML this is the policy generation. For Cedar it is the + /// middleware generation, which moves independently of the Cedar policy + /// generation. + #[must_use] + pub fn middleware_generation(&self) -> u64 { + match self { + Self::Opa(engine) => engine.current_generation(), + Self::Cedar(engine) => engine.middleware().current_generation(), + } + } + + /// Returns the ordered middleware chain for an admitted destination and + /// the middleware generation it was selected from. + /// + /// # Errors + /// + /// Returns an error if selection fails or an engine lock is poisoned. + pub fn query_middleware_chain_with_generation( + &self, + input: &NetworkInput, + ) -> Result<(Vec, u64)> { + match self { + Self::Opa(engine) => engine.query_middleware_chain_with_generation(input), + Self::Cedar(engine) => engine.middleware().select_with_generation(&input.host), + } + } + + /// Returns the middleware services in effect now. + /// + /// # Errors + /// + /// Returns an error if an engine lock is poisoned. + pub(crate) fn middleware_runner(&self) -> Result { + match self { + Self::Opa(engine) => engine.middleware_runner(), + Self::Cedar(engine) => engine.middleware().runner(), + } + } + + /// Replaces the middleware service registry and advances the middleware + /// generation, so subsequent requests use the new service set. + /// + /// # Errors + /// + /// Returns an error if an engine lock is poisoned. + pub fn replace_middleware_registry(&self, registry: MiddlewareRegistry) -> Result<()> { + match self { + Self::Opa(engine) => engine.replace_middleware_registry(registry), + Self::Cedar(engine) => engine.middleware().replace_registry(registry), + } + } + + /// Returns where a passthrough tunnel starting now selects its middleware. + /// + /// A Cedar tunnel keeps the middleware configs and services in effect + /// now, the snapshot [`Self::tunnel_engine`] gives an inspected tunnel, + /// so a middleware-only reload does not change its chain mid-tunnel. A + /// YAML tunnel selects from the live engine (see + /// [`PassthroughMiddleware`]). + /// + /// # Errors + /// + /// Returns an error if an engine lock is poisoned. + pub fn passthrough_middleware(&self) -> Result> { + match self { + Self::Opa(_) => Ok(PassthroughMiddleware::Live(self)), + Self::Cedar(engine) => Ok(PassthroughMiddleware::Snapshot( + engine.middleware().snapshot()?, + )), + } + } + + /// Builds the per-tunnel L7 evaluator for a decision at `expected_generation`. + /// + /// For a YAML policy, the tunnel clones the compiled Rego policy and is + /// pinned under one lock. For a Cedar policy, the tunnel is pinned to + /// Cedar's generation, its L7 decisions come from Cedar, and it keeps the + /// middleware configs and services in effect now. + /// + /// # Errors + /// + /// Returns an error if `expected_generation` is stale or an engine lock + /// is poisoned. + pub fn tunnel_engine(&self, expected_generation: u64) -> Result { + match self { + Self::Opa(engine) => engine.clone_engine_for_tunnel(expected_generation), + Self::Cedar(engine) => { + let generation_guard = engine.generation_guard(expected_generation)?; + let middleware = engine.middleware().snapshot()?; + Ok(TunnelPolicyEngine { + generation_guard, + middleware_runner: middleware.runner, + websocket_assembly_budget: WebSocketAssemblyBudget::default(), + evaluator: TunnelEvaluator::Cedar { + l7: engine.l7_handle(expected_generation), + middleware: middleware.configs, + }, + }) + } + } + } + + /// Returns the engine name recorded on OCSF events (`opa` or `cedar`). + #[must_use] + pub fn label(&self) -> &'static str { + match self { + Self::Opa(_) => "opa", + Self::Cedar(_) => "cedar", + } + } +} + +impl fmt::Debug for PolicyEngine { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("PolicyEngine").field(&self.label()).finish() + } +} + +impl From> for PolicyEngine { + fn from(engine: Arc) -> Self { + Self::Opa(engine) + } +} + +impl From> for PolicyEngine { + fn from(engine: Arc) -> Self { + Self::Cedar(engine) + } +} + +/// Where a passthrough tunnel selects its middleware chain from. +/// +/// Built by [`PolicyEngine::passthrough_middleware`] when the tunnel starts +/// and consulted for each request the tunnel relays. +pub enum PassthroughMiddleware<'a> { + /// A YAML sandbox selects from the live engine. Its middleware + /// generation is its policy generation, so a middleware change stales + /// the tunnel's guard and the tunnel closes at the next request boundary. + Live(&'a PolicyEngine), + /// A Cedar sandbox keeps the configs and services in effect when the + /// tunnel started, as an inspected tunnel does, because its middleware + /// generation moves independently of the Cedar generation its guard + /// is pinned to. + Snapshot(ActiveMiddleware), +} + +impl PassthroughMiddleware<'_> { + /// Returns the chain for `input` and the services to run it with. + /// + /// Returns `None` when the tunnel must close instead: its policy + /// generation is stale, or, for a live selection, the middleware + /// changed between the selection and the check. + /// + /// # Errors + /// + /// Returns an error if selection fails or an engine lock is poisoned. + pub(crate) fn select( + &self, + input: &NetworkInput, + generation_guard: &PolicyGenerationGuard, + ) -> Result, ChainRunner)>> { + match self { + Self::Live(engine) => { + let (chain, generation) = engine.query_middleware_chain_with_generation(input)?; + if generation != engine.middleware_generation() || generation_guard.is_stale() { + return Ok(None); + } + Ok(Some((chain, engine.middleware_runner()?))) + } + Self::Snapshot(active) => { + if generation_guard.is_stale() { + return Ok(None); + } + Ok(Some(( + active.configs.select(&input.host)?, + active.runner.clone(), + ))) + } + } + } +} + +impl fmt::Debug for PassthroughMiddleware<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let kind = match self { + Self::Live(_) => "live", + Self::Snapshot(_) => "snapshot", + }; + f.debug_tuple("PassthroughMiddleware").field(&kind).finish() + } +} + +/// Per-tunnel L7 policy evaluator bound to the policy generation captured +/// when the tunnel was established. +/// +/// Built by [`PolicyEngine::tunnel_engine`]. It keeps the middleware chain +/// configs and services in effect when the tunnel started. +pub struct TunnelPolicyEngine { + generation_guard: PolicyGenerationGuard, + middleware_runner: ChainRunner, + websocket_assembly_budget: WebSocketAssemblyBudget, + evaluator: TunnelEvaluator, +} + +/// The engine-specific half of a [`TunnelPolicyEngine`]. +enum TunnelEvaluator { + /// A YAML sandbox's tunnel evaluates its own clone of the Rego policy. + Rego(Box), + /// A Cedar sandbox's tunnel evaluates Cedar and selects middleware in + /// Rust from the configs in effect when it started. + Cedar { + l7: CedarL7TunnelEngine, + middleware: MiddlewareConfigs, + }, +} + +impl TunnelPolicyEngine { + /// Builds a YAML sandbox's tunnel around a clone of its Rego policy. + pub(crate) fn rego( + generation_guard: PolicyGenerationGuard, + middleware_runner: ChainRunner, + websocket_assembly_budget: WebSocketAssemblyBudget, + evaluator: RegoTunnelEvaluator, + ) -> Self { + Self { + generation_guard, + middleware_runner, + websocket_assembly_budget, + evaluator: TunnelEvaluator::Rego(Box::new(evaluator)), + } + } + + pub fn captured_generation(&self) -> u64 { + self.generation_guard.captured_generation() + } + + pub fn current_generation(&self) -> u64 { + self.generation_guard.current_generation() + } + + pub fn is_stale(&self) -> bool { + self.generation_guard.is_stale() + } + + pub fn generation_guard(&self) -> &PolicyGenerationGuard { + &self.generation_guard + } + + pub(crate) fn middleware_runner(&self) -> &ChainRunner { + &self.middleware_runner + } + + pub(crate) fn websocket_assembly_budget(&self) -> WebSocketAssemblyBudget { + self.websocket_assembly_budget.clone() + } + + /// Test-only: a YAML tunnel's Rego evaluator. + /// + /// # Panics + /// + /// Panics for a Cedar tunnel, which has no Rego engine. + #[cfg(test)] + pub(crate) fn rego_evaluator_for_tests(&self) -> &RegoTunnelEvaluator { + match &self.evaluator { + TunnelEvaluator::Rego(rego) => rego, + TunnelEvaluator::Cedar { .. } => panic!("a Cedar tunnel has no Rego engine"), + } + } + + /// Query the ordered middleware chain for a destination within this tunnel. + /// + /// # Errors + /// + /// Returns an error if selection fails. + pub fn query_middleware_chain(&self, input: &NetworkInput) -> Result> { + match &self.evaluator { + TunnelEvaluator::Rego(rego) => rego.query_middleware_chain(input), + TunnelEvaluator::Cedar { middleware, .. } => middleware.select(&input.host), + } + } + + /// Evaluates one L7 request and returns `(allowed, deny_reason)`. + /// + /// For a Cedar-sourced sandbox the decision comes from Cedar; otherwise + /// from this tunnel's Rego policy. + /// + /// # Errors + /// + /// Returns an error if the tunnel's policy generation is stale or the + /// evaluation fails. Callers treat an error as a denial. + pub fn evaluate_request( + &self, + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + ) -> Result<(bool, String)> { + match &self.evaluator { + TunnelEvaluator::Cedar { l7, .. } => l7.evaluate_request(ctx, request), + TunnelEvaluator::Rego(rego) => { + if self.is_stale() { + return Err(miette::miette!( + "L7 tunnel policy generation is stale [captured_generation:{} current_generation:{}]", + self.captured_generation(), + self.current_generation(), + )); + } + rego.evaluate_request(ctx, request) + } + } + } + + /// Returns the token-grant owners whose own endpoint admits one request. + /// + /// For a Cedar-sourced sandbox the owners come from Cedar; see + /// [`CedarL7TunnelEngine::admitted_token_grant_owners`]. + /// + /// # Errors + /// + /// Returns an error if the tunnel's policy generation is stale or the + /// evaluation fails. Callers inject no token on error. + pub(crate) fn admitted_token_grant_owners( + &self, + ctx: &crate::l7::relay::L7EvalContext, + request: &crate::l7::L7RequestInfo, + ) -> Result> { + if self.is_stale() { + return Err(miette::miette!( + "policy changed before token grant selection" + )); + } + match &self.evaluator { + TunnelEvaluator::Cedar { l7, .. } => l7.admitted_token_grant_owners(ctx, request), + TunnelEvaluator::Rego(rego) => rego.admitted_token_grant_owners(ctx, request), + } + } +} diff --git a/crates/openshell-supervisor-network/src/policy_types.rs b/crates/openshell-supervisor-network/src/policy_types.rs new file mode 100644 index 0000000000..4ab063a2e5 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_types.rs @@ -0,0 +1,449 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Engine-neutral network policy inputs, results, and generation guards. +//! +//! Both [`crate::opa::OpaEngine`] (YAML policies) and +//! [`crate::cedar_only::CedarOnlyEngine`] (Cedar policies) speak these types, +//! so decision points never depend on which engine a sandbox uses. Endpoint +//! configs, policy DNS records, and middleware configs are typed Rust data: +//! the Cedar engine builds them from the policy proto and its analysis, and +//! the OPA engine converts its Rego query results into them at one boundary +//! (`crate::opa`). Nothing downstream of that boundary depends on the policy +//! language. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use miette::Result; +use openshell_policy::L7Protocol; +use serde::Deserialize; +use tokio::sync::watch; + +/// The `protocol` of a policy endpoint. +/// +/// Built from the authored or normalized protocol label. The label is kept +/// as a closed set so unrecognized spellings are rejected where they matter +/// (the L7 config parser) instead of silently selecting a wire profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(from = "String")] +pub enum EndpointProtocol { + /// No protocol: a connect-only endpoint. + #[default] + None, + /// Explicit native TCP, which is never inspected. + Tcp, + /// An inspected L7 protocol. + L7(L7Protocol), + /// A label no engine recognizes. + Unknown, +} + +impl EndpointProtocol { + /// Returns the inspected L7 protocol, if this is one. + pub fn l7(self) -> Option { + match self { + Self::L7(protocol) => Some(protocol), + _ => None, + } + } +} + +impl From<&str> for EndpointProtocol { + fn from(label: &str) -> Self { + if label.is_empty() { + Self::None + } else if label.eq_ignore_ascii_case("tcp") { + Self::Tcp + } else { + L7Protocol::parse(label).map_or(Self::Unknown, Self::L7) + } + } +} + +impl From for EndpointProtocol { + fn from(label: String) -> Self { + Self::from(label.as_str()) + } +} + +/// The `tls` setting of a policy endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(from = "String")] +pub enum TlsSetting { + /// Unset: TLS is detected and terminated automatically. + #[default] + Auto, + /// Explicit opt-out of TLS termination. + Skip, + /// A value the runtime does not accept. + Unknown, +} + +impl From<&str> for TlsSetting { + fn from(label: &str) -> Self { + match label { + "" => Self::Auto, + "skip" => Self::Skip, + _ => Self::Unknown, + } + } +} + +impl From for TlsSetting { + fn from(label: String) -> Self { + Self::from(label.as_str()) + } +} + +impl TlsSetting { + /// Reads a proto `NetworkTlsMode` value. + pub fn from_proto(value: i32) -> Self { + openshell_policy::network_tls_mode_to_str(value).map_or(Self::Unknown, Self::from) + } +} + +/// The `enforcement` setting of a policy endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(from = "String")] +pub enum EnforcementSetting { + /// Unset: the runtime default (audit) applies. + #[default] + Default, + /// Deny violations. + Enforce, + /// Log violations and forward the request. + Audit, + /// A value the runtime does not accept. + Unknown, +} + +impl From<&str> for EnforcementSetting { + fn from(label: &str) -> Self { + match label { + "" => Self::Default, + "enforce" => Self::Enforce, + "audit" => Self::Audit, + _ => Self::Unknown, + } + } +} + +impl From for EnforcementSetting { + fn from(label: String) -> Self { + Self::from(label.as_str()) + } +} + +/// The `access` preset of a policy endpoint. +/// +/// Presets are expanded into rules by the engine; the runtime only rejects +/// an unknown value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(from = "String")] +pub enum AccessSetting { + /// No preset. + #[default] + None, + ReadOnly, + ReadWrite, + Full, + /// A value the runtime does not accept. + Unknown, +} + +impl From<&str> for AccessSetting { + fn from(label: &str) -> Self { + match label { + "" => Self::None, + "read-only" => Self::ReadOnly, + "read-write" => Self::ReadWrite, + "full" => Self::Full, + _ => Self::Unknown, + } + } +} + +impl From for AccessSetting { + fn from(label: String) -> Self { + Self::from(label.as_str()) + } +} + +/// The GraphQL operation selectors of one L7 rule. +/// +/// The other selectors (method, path, query, params, command) are evaluated +/// by the policy engine alone, so they are not carried here. +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(default)] +pub struct GraphqlRuleSelectors { + pub operation_type: String, + pub operation_name: String, + pub fields: Vec, +} + +impl GraphqlRuleSelectors { + /// Returns whether any GraphQL selector is set. + pub fn is_set(&self) -> bool { + !self.operation_type.is_empty() + || !self.operation_name.is_empty() + || !self.fields.is_empty() + } +} + +/// One `rules` or `deny_rules` entry of a policy endpoint. +/// +/// An allow rule wraps its selectors in `allow`; a deny rule carries them +/// directly. Both forms are accepted for either list. +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(default)] +pub struct L7Rule { + pub allow: Option, + #[serde(flatten)] + pub selectors: GraphqlRuleSelectors, +} + +impl L7Rule { + /// Returns the rule's GraphQL selectors, unwrapping `allow` when present. + pub fn graphql_selectors(&self) -> &GraphqlRuleSelectors { + self.allow.as_ref().unwrap_or(&self.selectors) + } +} + +/// A registered GraphQL operation of `graphql_persisted_queries`. +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(default)] +pub struct GraphqlPersistedQuery { + pub operation_type: String, + pub operation_name: String, + pub fields: Vec, +} + +/// One policy endpoint's connection and L7 settings, as the runtime reads them. +/// +/// This is the engine-neutral shape of a policy endpoint that matched a +/// connection: the L7 config parser (`crate::l7::parse_l7_config`), the TLS +/// mode and credential guard readers, and the SSRF allowlist read it. The +/// OPA engine produces it from its Rego data; the Cedar engine builds it from +/// provider endpoints, endpoint settings, and its `HttpRequest` analysis. +/// Fields absent from the policy take their defaults. +#[allow( + clippy::struct_excessive_bools, + reason = "Endpoint config mirrors independent policy schema toggles." +)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(default)] +pub struct EndpointConfig { + pub host: String, + pub ports: Vec, + /// Endpoint path glob; empty for every path. + pub path: String, + pub protocol: EndpointProtocol, + pub tls: TlsSetting, + pub enforcement: EnforcementSetting, + pub access: AccessSetting, + pub rules: Vec, + pub deny_rules: Vec, + /// CIDR ranges or addresses the endpoint may resolve to. + pub allowed_ips: Vec, + pub allow_encoded_slash: bool, + pub websocket_credential_rewrite: bool, + pub request_body_credential_rewrite: bool, + pub allow_uninspected_credentials: bool, + pub provider_credentialed: bool, + /// Set by the Cedar engine for a `websocket-graphql` endpoint; a YAML + /// endpoint opts in through its GraphQL rules instead. + pub websocket_graphql_policy: bool, + /// Opaque MCP endpoint identity; empty for other endpoints. + pub endpoint_id: String, + /// Hash of the canonical policy that supplied the endpoint. Both engines + /// set it for every endpoint so grant reporting binds a request to the + /// policy it was selected under. + pub policy_hash: String, + /// Credential signing mode label; empty for none. + pub credential_signing: String, + pub signing_service: String, + pub signing_region: String, + /// Persisted-query mode label; empty for the default. + pub persisted_queries: String, + pub graphql_persisted_queries: BTreeMap, + /// Zero for the runtime default. + pub graphql_max_body_bytes: u64, + /// Zero for the runtime default. + pub json_rpc_max_body_bytes: u64, + /// Canonical MCP protocol revision labels; the parser requires a + /// non-empty, strictly ordered list for an MCP endpoint. + pub mcp_versions: Vec, + /// `None` takes the MCP default (strict). + pub mcp_strict_tool_names: Option, +} + +/// One policy endpoint as policy DNS reads it: its host selector, ports, +/// transport, and resolution allowlist. +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(default)] +pub struct DnsRecord { + pub host: String, + pub ports: Vec, + pub protocol: EndpointProtocol, + pub allowed_ips: Vec, +} + +/// One `network_middlewares` entry of a policy revision. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct MiddlewareConfig { + /// The entry's key under `network_middlewares`, which names the chain + /// stage. + pub policy_key: String, + /// The implementation to run, such as `openshell/regex`. + pub middleware: String, + pub order: i32, + pub config: prost_types::Struct, + /// Failure handling label; empty for the default. + pub on_error: String, + /// The hosts the stage applies to; `None` selects no host. + pub endpoints: Option, +} + +/// The host selector of a [`MiddlewareConfig`]. +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(default)] +pub struct MiddlewareEndpointSelector { + pub include: Vec, + pub exclude: Vec, +} + +/// Network action returned by a network policy decision. +/// +/// - `Allow`: endpoint + binary explicitly matched in a network policy +/// - `Deny`: no matching policy +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NetworkAction { + Allow { matched_policy: Option }, + Deny { reason: String }, +} + +/// Endpoint identity and metadata captured with one policy generation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct MatchedEndpoint { + pub policy_name: String, + pub endpoint_index: usize, + pub endpoint: DnsRecord, +} + +/// Policy-DNS eligible endpoint metadata captured from one policy generation. +/// +/// This is policy data only. It deliberately contains no process identity or +/// network authorization decision. +#[derive(Debug, Clone)] +pub struct PolicyDnsEligibilitySnapshot { + pub endpoints: Vec, + pub generation: u64, + /// The generation is a fail-closed quarantine, so every name is refused. + pub fail_closed: bool, +} + +/// Atomic policy result used to authorize and materialize one egress request. +#[derive(Debug, Clone)] +pub struct EgressAuthorization { + pub action: NetworkAction, + pub endpoint_configs: Vec, + pub matched_endpoints: Vec, + pub exact_declared_endpoint_host: bool, + pub generation: u64, + /// For a Cedar sandbox's allowed connection, how its resolved addresses + /// are checked; `None` for YAML, whose endpoint configs carry + /// `allowed_ips` instead. + pub cedar_destination: Option, +} + +/// Input for a network access policy evaluation. +pub struct NetworkInput { + pub host: String, + pub port: u16, + pub binary_path: PathBuf, + pub binary_sha256: String, + /// Ancestor binary paths from process tree walk (parent, grandparent, ...). + pub ancestors: Vec, + /// Absolute paths extracted from `/proc//cmdline` of the socket-owning + /// process and its ancestors. Captures script paths (e.g. `/usr/local/bin/claude`) + /// that don't appear in `/proc//exe` because the interpreter (node) is the exe. + pub cmdline_paths: Vec, +} + +/// Generation guard captured when an HTTP tunnel or request path starts. +#[derive(Clone, Debug)] +pub struct PolicyGenerationGuard { + captured_generation: u64, + current_generation: Arc, + generation_rx: watch::Receiver, +} + +/// Builds a [`PolicyGenerationGuard`] for any state holding a plain +/// `Arc` generation counter and matching `watch::Sender`. +/// +/// `OpaEngine`, `CedarOnlyEngine`, and the Cedar middleware state all use +/// this shape, so the guard itself (and callers that only read it, e.g. +/// DNS-staleness checks) don't need to know which one produced it. +/// +/// # Errors +/// +/// Returns an error if `current_generation_value` differs from +/// `expected_generation`. +pub(crate) fn generation_guard_for( + expected_generation: u64, + current_generation_value: u64, + current_generation: &Arc, + generation_tx: &watch::Sender, +) -> Result { + if current_generation_value != expected_generation { + return Err(miette::miette!( + "policy changed before HTTP relay started [expected_generation:{expected_generation} current_generation:{current_generation_value}]" + )); + } + Ok(PolicyGenerationGuard { + captured_generation: current_generation_value, + current_generation: Arc::clone(current_generation), + generation_rx: generation_tx.subscribe(), + }) +} + +impl PolicyGenerationGuard { + pub fn captured_generation(&self) -> u64 { + self.captured_generation + } + + pub fn current_generation(&self) -> u64 { + self.current_generation.load(Ordering::Acquire) + } + + pub fn is_stale(&self) -> bool { + self.current_generation() != self.captured_generation + } + + pub fn ensure_current(&self) -> Result<()> { + if self.is_stale() { + return Err(miette::miette!( + "policy generation is stale [captured_generation:{} current_generation:{}]", + self.captured_generation(), + self.current_generation(), + )); + } + Ok(()) + } + + /// Wait until the policy generation changes. + /// + /// Relay boundaries use this to close even an idle or raw stream as soon + /// as a new generation (including fail-closed quarantine) is published. + pub async fn wait_until_stale(&self) { + let mut receiver = self.generation_rx.clone(); + while !self.is_stale() { + if receiver.changed().await.is_err() { + return; + } + } + } +} diff --git a/crates/openshell-supervisor-network/src/policy_validation.rs b/crates/openshell-supervisor-network/src/policy_validation.rs new file mode 100644 index 0000000000..9c2942f9e4 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_validation.rs @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Engine-neutral validation of a gateway-delivered sandbox policy. +//! +//! A policy is validated with [`openshell_policy`] before either engine +//! activates it, whether it is YAML or Cedar. Validation errors are rendered +//! from fixed, implementation-owned categories so authored names, paths, and +//! values never cross the supervisor's load boundary. + +use miette::Result; +use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; +use openshell_policy::PolicyViolation; + +/// Maximum number of fixed categories in one policy-load error. +pub(crate) const POLICY_VALIDATION_DIAGNOSTIC_MAX_ITEMS: usize = 8; +/// Maximum byte length of one policy-load error, including its omission marker. +pub(crate) const POLICY_VALIDATION_DIAGNOSTIC_MAX_BYTES: usize = 512; + +/// Validates `policy` and returns its canonical form. +/// +/// Applies the same checks to every section a gateway delivers, for YAML and +/// Cedar policies alike: process identity, Landlock, filesystem paths, +/// network endpoints, middleware (names, implementations, failure modes, +/// selectors, and built-in configs), endpoint settings, and the Cedar text. +/// Protobuf cannot distinguish an omitted repeated MCP version field from an +/// empty one, so canonicalization selects the pinned default for both. +/// +/// # Errors +/// +/// Returns a bounded, redacted diagnostic if the policy is invalid or its +/// network endpoint selectors are ambiguous. +pub fn validate_sandbox_policy(policy: &ProtoSandboxPolicy) -> Result { + let policy = openshell_policy::validate_and_canonicalize_sandbox_policy(policy.clone()) + .map_err(|error| { + miette::miette!(render_bounded_validation_diagnostics( + "policy validation failed", + error + .violations() + .iter() + .map(redacted_policy_violation_category), + )) + })?; + + let ambiguities = openshell_policy::find_endpoint_ambiguities(&policy); + if !ambiguities.is_empty() { + return Err(miette::miette!(render_repeated_validation_diagnostic( + "network endpoint ambiguity validation failed", + ambiguities.len(), + "ambiguous network endpoint selectors", + ))); + } + Ok(policy) +} + +/// Select a fixed category without formatting authored fields or nested reasons. +/// Keep this match exhaustive so new validator variants require an explicit +/// decision before their diagnostics can cross the supervisor load boundary. +pub(crate) fn redacted_policy_violation_category(violation: &PolicyViolation) -> &'static str { + match violation { + PolicyViolation::InvalidProcessIdentity { .. } => "invalid process identity", + PolicyViolation::InvalidLandlockCompatibility { .. } => "invalid Landlock compatibility", + PolicyViolation::PathTraversal { .. } + | PolicyViolation::RelativePath { .. } + | PolicyViolation::OverlyBroadPath { .. } + | PolicyViolation::FieldTooLong { .. } => "invalid filesystem path", + PolicyViolation::TooManyPaths { .. } => "filesystem path limit exceeded", + PolicyViolation::TldWildcard { .. } + | PolicyViolation::TcpEndpointIpLiteral { .. } + | PolicyViolation::InvalidTcpEndpointHost { .. } + | PolicyViolation::InvalidHostWildcard { .. } => "invalid network endpoint host", + PolicyViolation::MissingEndpointHost { .. } + | PolicyViolation::MissingTcpEndpointHost { .. } => "missing network endpoint host", + PolicyViolation::MissingEndpointPort { .. } + | PolicyViolation::InvalidEndpointPort { .. } => "invalid network endpoint port", + PolicyViolation::MissingSigningService { .. } => { + "incomplete credential signing configuration" + } + PolicyViolation::UnknownCredentialSigning { .. } => { + "invalid credential signing configuration" + } + PolicyViolation::CredentialSigningWithBodyRewrite { .. } => { + "conflicting credential rewrite configuration" + } + PolicyViolation::InvalidL7Endpoint { .. } => "invalid L7 endpoint configuration", + PolicyViolation::InvalidMiddlewareConfig { .. } => "invalid middleware configuration", + PolicyViolation::TooManyMiddlewareConfigs { .. } => { + "middleware configuration limit exceeded" + } + PolicyViolation::DuplicateMiddlewareOrder { .. } => "duplicate middleware order", + PolicyViolation::TooManyMiddlewareSelectorPatterns { .. } => { + "middleware selector limit exceeded" + } + PolicyViolation::MiddlewareTlsSkipConflict { .. } => { + "middleware conflicts with TLS inspection" + } + PolicyViolation::MissingMcpVersions { .. } => "missing MCP protocol version", + PolicyViolation::McpOptionsOnNonMcpEndpoint { .. } => "MCP options require MCP protocol", + PolicyViolation::UnsupportedMcpVersion { .. } => "unsupported MCP protocol version", + PolicyViolation::DuplicateMcpVersion { .. } => "duplicate MCP protocol version", + PolicyViolation::CedarMutuallyExclusiveWithNetworkPolicies => { + "cedar_policy_source combined with network_policies" + } + PolicyViolation::InvalidCedarPolicy { .. } => "invalid Cedar policy", + PolicyViolation::EndpointSettingsRequireCedar => "endpoint settings require Cedar", + PolicyViolation::InvalidEndpointSetting { .. } => "invalid endpoint setting", + } +} + +/// Render only fixed, implementation-owned headings and categories. Reserve +/// space for the omission marker before adding each complete item; never copy +/// or truncate a validator's payload-bearing Display, Debug, or source chain. +pub(crate) fn render_bounded_validation_diagnostics( + heading: &'static str, + diagnostics: impl Iterator, +) -> String { + const OMITTED_SUFFIX: &str = "; additional violations omitted"; + let mut rendered = heading.to_string(); + for (index, category) in diagnostics.enumerate() { + let separator = if index == 0 { ": " } else { "; " }; + if index == POLICY_VALIDATION_DIAGNOSTIC_MAX_ITEMS + || rendered.len() + separator.len() + category.len() + OMITTED_SUFFIX.len() + > POLICY_VALIDATION_DIAGNOSTIC_MAX_BYTES + { + rendered.push_str(OMITTED_SUFFIX); + break; + } + rendered.push_str(separator); + rendered.push_str(category); + } + rendered +} + +/// String-only validators have no safe fields to expose; retain their stage +/// and bounded item count without inspecting their authored error text. +pub(crate) fn render_repeated_validation_diagnostic( + heading: &'static str, + count: usize, + category: &'static str, +) -> String { + render_bounded_validation_diagnostics(heading, std::iter::repeat_n(category, count)) +} diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index e3e2515d50..4a8f35c052 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -11,10 +11,11 @@ pub(crate) use relay::relay_inspected_http_stream_for_test; use crate::identity::{BinaryIdentityCache, SuppliedIdentityError}; use crate::l7::tls::ProxyTlsState; -use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; +use crate::opa::{NetworkAction, PolicyGenerationGuard}; #[cfg(any(target_os = "linux", test))] use crate::policy_dns::PolicyEndpointId; use crate::policy_dns::{MappingLookup, MappingLookupError, ResolvedEndpointStore}; +use crate::policy_engine::PolicyEngine; use crate::policy_local::{POLICY_LOCAL_HOST, PolicyLocalContext}; use crate::upstream_proxy::{self, UpstreamProxyConfig}; use futures::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; @@ -238,7 +239,7 @@ impl ProxyHandle { pub(crate) async fn start_with_bind_addr( policy: &ProxyPolicy, bind_addr: Option, - opa_engine: Arc, + network_engine: PolicyEngine, identity_cache: Arc, entrypoint_pid: Arc, tls_state: Option>, @@ -417,7 +418,7 @@ impl ProxyHandle { Ok(connection) => { let tx = preauthorized_tx.clone(); let dns_store = policy_dns_store.clone(); - let opa = opa_engine.clone(); + let network = network_engine.clone(); let cache = identity_cache.clone(); let backend_gateway = *backend_host_gateway; let trusted_gateway = *trusted_host_gateway; @@ -427,7 +428,7 @@ impl ProxyHandle { if let Some(connection) = preauthorize_transparent_open( connection, dns_store.as_ref(), - &opa, + &network, &cache, backend_gateway, trusted_gateway, @@ -475,7 +476,7 @@ impl ProxyHandle { .map(|transparent| transparent.destination); consecutive_resource_errors = 0; consecutive_unknown_errors = 0; - let opa = opa_engine.clone(); + let network = network_engine.clone(); let cache = identity_cache.clone(); let spid = entrypoint_pid.clone(); let tls = tls_state.clone(); @@ -501,7 +502,7 @@ impl ProxyHandle { socket_addrs, transparent_destination, dns_store, - opa, + network, cache, spid, tls, @@ -572,7 +573,7 @@ impl ProxyHandle { async fn preauthorize_transparent_open( connection: PendingTcpOpen, policy_dns_store: Option<&Arc>, - opa_engine: &OpaEngine, + network_engine: &PolicyEngine, identity_cache: &BinaryIdentityCache, backend_host_gateway: Option, trusted_host_gateway: Option, @@ -673,7 +674,7 @@ async fn preauthorize_transparent_open( )); } let TransparentTarget { host, intent } = - match resolve_transparent_target(destination, policy_dns_store, opa_engine) { + match resolve_transparent_target(destination, policy_dns_store, network_engine) { Ok(target) => target, Err(error) => { warn!(%destination, %error, "Denied staged transparent connection"); @@ -693,7 +694,7 @@ async fn preauthorize_transparent_open( let connect_span = egress::connect_span(&connect_intent); let supplied_authorization = connect_span.in_scope(|| { authorize_supplied_identity_with_denial( - opa_engine, + network_engine, identity_cache, connect_intent, &binary_identity, @@ -839,6 +840,160 @@ async fn preauthorize_transparent_open( )) } +/// What a staged transparent open to a policy DNS destination did. +#[cfg(test)] +pub(crate) struct StagedOpenOutcome { + /// The decision sent back to the boundary. + pub(crate) decision: TcpOpenDecision, + /// The addresses the admitted connection may dial; empty when refused. + pub(crate) dial_addresses: Vec, + /// Whether the decision's matched endpoints correlate with the policy DNS + /// mapping, which the transparent TCP listener also requires. + pub(crate) correlated: bool, +} + +/// Stages a transparent open from `binary` to `destination` through +/// `engine`, as the boundary does, for tests that compare policy engines. +#[cfg(test)] +pub(crate) async fn stage_transparent_open_for_test( + engine: &PolicyEngine, + store: &Arc, + destination: SocketAddr, + binary: &str, +) -> StagedOpenOutcome { + let (stream, _peer) = tokio::io::duplex(64); + let (decision, completion) = tokio::sync::oneshot::channel(); + let open = PendingTcpOpen { + stream: Box::new(stream), + binary_identity: Ok(ContractBinaryIdentity { + executable: ContractExecutableIdentity { + path: PathBuf::from(binary), + digest: Some("00".repeat(32).parse().expect("valid test digest")), + }, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }), + destination, + socket: openshell_isolation_interface::contract::NetworkSocketMetadata { + socket_cookie: 7, + nonblocking: false, + process_generation: 1, + }, + policy_generation: engine.current_generation(), + timing: MediationTiming::default(), + decision, + }; + let accepted = preauthorize_transparent_open( + open, + Some(store), + engine, + &BinaryIdentityCache::new(), + None, + None, + false, + None, + ) + .await; + let decision = completion.await.expect("the open is decided"); + let authorization = accepted + .and_then(|(_, _, _, transparent)| transparent) + .and_then(|transparent| transparent.authorization); + let Some((egress, connector)) = authorization else { + return StagedOpenOutcome { + decision, + dial_addresses: Vec::new(), + correlated: false, + }; + }; + let correlated = store + .lookup( + destination.ip(), + destination.port(), + egress.policy_generation, + std::time::Instant::now(), + ) + .ok() + .and_then(|mapping| correlated_endpoint(&egress.endpoint.matched_endpoints, &mapping)) + .is_some(); + StagedOpenOutcome { + decision, + dial_addresses: connector.addrs().to_vec(), + correlated, + } +} + +/// What the explicit proxy does with one connection whose host resolved to +/// `addresses`, for tests that compare policy engines. +/// +/// Authorizes the connection, selects its destination mode as CONNECT and +/// forward HTTP do (no host gateway), and checks the resolved addresses +/// all-or-nothing with that mode's own check, without resolving anything. +/// Returns `"deny"` for a policy denial, `"rejected"` when a destination +/// check rejects the addresses, and `"admitted"` otherwise. +#[cfg(test)] +pub(crate) fn connect_destination_outcome_for_test( + engine: &PolicyEngine, + input: &crate::opa::NetworkInput, + addresses: &[IpAddr], +) -> String { + let Ok(authorization) = engine.authorize_egress(input) else { + return "deny".to_string(); + }; + if matches!(authorization.action, NetworkAction::Deny { .. }) { + return "deny".to_string(); + } + let mut decision = EgressDecision { + intent: EgressIntent::connect(input.host.clone(), input.port), + action: authorization.action.clone(), + policy_generation: authorization.generation, + engine: "test", + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::from_authorization(&authorization), + binary: Some(input.binary_path.clone()), + binary_pid: None, + ancestors: input.ancestors.clone(), + cmdline_paths: Vec::new(), + }; + if hydrate_destination_plan(&mut decision, None, None).is_err() { + return "rejected".to_string(); + } + let host = input.host.as_str(); + let port = input.port; + let addrs: Vec = addresses + .iter() + .map(|ip| SocketAddr::new(*ip, port)) + .collect(); + let plan = decision + .endpoint + .destination + .as_ref() + .expect("destination plan hydrated"); + let checked = match &plan.address_authorization { + destination::AddressAuthorization::DefaultPublicOnly => { + reject_internal_resolved_addrs(host, &addrs) + } + destination::AddressAuthorization::ExplicitAllowedIps(networks) => { + validate_allowed_ips_for_resolved_addrs(host, port, &addrs, networks) + } + destination::AddressAuthorization::ImplicitIpLiteral(ip) => { + validate_allowed_ips_for_resolved_addrs(host, port, &addrs, &[ipnet::IpNet::from(*ip)]) + } + destination::AddressAuthorization::ExactDeclaredHost => { + validate_declared_endpoint_resolved_addrs(host, port, &addrs) + } + destination::AddressAuthorization::Cedar(cedar) => { + destination::validate_cedar_resolved_addrs(cedar, host, port, &addrs) + } + other => panic!("no host gateway is configured, but the plan is {other:?}"), + }; + if checked.is_ok() { + "admitted" + } else { + "rejected" + } + .to_string() +} + fn emit_staged_transparent_denial( destination: SocketAddr, mapped_host: Option<&str>, @@ -917,7 +1072,7 @@ struct TransparentTarget { fn resolve_transparent_target( destination: SocketAddr, policy_dns_store: Option<&Arc>, - opa_engine: &OpaEngine, + network_engine: &PolicyEngine, ) -> Result { let Some(store) = policy_dns_store else { return Ok(TransparentTarget { @@ -928,7 +1083,7 @@ fn resolve_transparent_target( match store.lookup_intent( destination.ip(), destination.port(), - opa_engine.current_generation(), + network_engine.current_generation(), std::time::Instant::now(), ) { Ok(mapping) => Ok(TransparentTarget { @@ -959,8 +1114,41 @@ fn pinned_transparent_plan( decision.policy_generation, std::time::Instant::now(), )?; - build_pinned_validation_plan(mapping.pinned_addresses()) - .map_err(|_| MappingLookupError::InvalidMapping) + let addresses = + cedar_admitted_addresses(decision, destination.port(), mapping.pinned_addresses()); + build_pinned_validation_plan(addresses).map_err(|_| MappingLookupError::InvalidMapping) +} + +/// Keeps the pinned policy DNS addresses a Cedar sandbox's decision admits. +/// +/// Policy DNS pins addresses per endpoint record, without the requesting +/// process; a Cedar connection also needs every pinned address it dials to +/// pass its own destination rules, which can name the process and forbid +/// addresses. YAML decisions keep every pinned address. +fn cedar_admitted_addresses( + decision: &EgressDecision, + port: u16, + addresses: Vec, +) -> Vec { + let Some(cedar) = decision.endpoint.cedar_destination.as_ref() else { + return addresses; + }; + let host = &decision.intent.destination.host; + if BLOCKED_CONTROL_PLANE_PORTS.contains(&port) + && destination::cedar_blocks_control_plane(cedar, host) + { + return Vec::new(); + } + addresses + .into_iter() + .filter(|ip| { + let rejection = destination::cedar_address_rejection(cedar, host, *ip); + if let Some(reason) = &rejection { + debug!(host, %ip, reason, "Cedar destination rules dropped a pinned address"); + } + rejection.is_none() + }) + .collect() } fn valid_policy_local_request(method: &str, target: &str, request_headers: &str) -> bool { @@ -995,7 +1183,7 @@ impl TransparentTcpHandle { pub(crate) fn start( listeners: Vec, store: Arc, - opa_engine: Arc, + network_engine: PolicyEngine, identity_cache: Arc, entrypoint_pid: Arc, agent_proposals: openshell_core::proposals::AgentProposals, @@ -1011,7 +1199,7 @@ impl TransparentTcpHandle { let mut joins = Vec::with_capacity(listeners.len()); for listener in listeners { let store = store.clone(); - let engine = opa_engine.clone(); + let network = network_engine.clone(); let cache = identity_cache.clone(); let pid = entrypoint_pid.clone(); let proposals = agent_proposals.clone(); @@ -1037,7 +1225,7 @@ impl TransparentTcpHandle { }; set_tcp_nodelay_best_effort(&stream); let store = store.clone(); - let engine = engine.clone(); + let network = network.clone(); let cache = cache.clone(); let pid = pid.clone(); let proposals = proposals.clone(); @@ -1048,7 +1236,7 @@ impl TransparentTcpHandle { if let Err(error) = handle_transparent_tcp_connection( stream, store, - engine, + network, cache, pid, proposals, @@ -1080,12 +1268,31 @@ impl Drop for TransparentTcpHandle { } } +/// Returns the first endpoint an egress decision matched that the policy DNS +/// mapping the connection dialed also contains. +/// +/// `None` means the decision and the mapping name no common endpoint, so the +/// transparent TCP listener refuses the connection. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn correlated_endpoint( + matched_endpoints: &[crate::opa::MatchedEndpoint], + mapping: &MappingLookup, +) -> Option { + matched_endpoints + .iter() + .map(|endpoint| PolicyEndpointId { + policy_name: endpoint.policy_name.clone(), + endpoint_index: endpoint.endpoint_index, + }) + .find(|candidate| mapping.endpoint_ids().any(|mapped| mapped == candidate)) +} + #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] async fn handle_transparent_tcp_connection( mut client: TcpStream, store: Arc, - opa_engine: Arc, + network_engine: PolicyEngine, identity_cache: Arc, entrypoint_pid: Arc, agent_proposals: openshell_core::proposals::AgentProposals, @@ -1095,7 +1302,7 @@ async fn handle_transparent_tcp_connection( ) -> Result<()> { let workload_addr = client.peer_addr().into_diagnostic()?; let original = original_destination(&client).into_diagnostic()?; - let current_generation = opa_engine.current_generation(); + let current_generation = network_engine.current_generation(); let mapping = match store.lookup_intent( original.ip(), original.port(), @@ -1114,7 +1321,7 @@ async fn handle_transparent_tcp_connection( let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, original); let intent = EgressIntent::transparent_tcp(host.clone(), port); let connect_span = egress::connect_span(&intent); - let engine = opa_engine.clone(); + let engine = network_engine.clone(); let cache = identity_cache.clone(); let pid = entrypoint_pid.clone(); let parent = connect_span.clone(); @@ -1159,7 +1366,7 @@ async fn handle_transparent_tcp_connection( // connector. This prevents combining an old DNS answer with a newer // policy decision (or vice versa). let Ok(generation_guard) = - relay::pin_policy_generation(&opa_engine, decision.policy_generation) + relay::pin_policy_generation(&network_engine, decision.policy_generation) else { emit_transparent_mapping_denial( workload_addr, @@ -1184,16 +1391,8 @@ async fn handle_transparent_tcp_connection( } }; - let endpoint_id = decision - .endpoint - .matched_endpoints - .iter() - .map(|endpoint| PolicyEndpointId { - policy_name: endpoint.policy_name.clone(), - endpoint_index: endpoint.endpoint_index, - }) - .find(|candidate| mapping.endpoint_ids().any(|mapped| mapped == candidate)); - let Some(endpoint_id) = endpoint_id else { + let Some(endpoint_id) = correlated_endpoint(&decision.endpoint.matched_endpoints, &mapping) + else { let reason = "authorized endpoint did not match DNS correlation"; emit_transparent_policy_denial(&decision, workload_addr, &host, port); emit_denial( @@ -1219,6 +1418,37 @@ async fn handle_transparent_tcp_connection( .map_err(|error| { miette::miette!("transparent TCP pinned destination is invalid: {error}") })?; + let admitted = cedar_admitted_addresses( + &decision, + port, + connector.addrs().iter().map(SocketAddr::ip).collect(), + ); + if admitted.is_empty() { + let reason = "no pinned address passed the Cedar destination rules"; + emit_transparent_policy_denial(&decision, workload_addr, &host, port); + emit_denial( + &denial_tx, + &host, + port, + decision + .binary + .as_ref() + .map_or("-", |path| path.to_str().unwrap_or("-")), + &decision, + reason, + "transparent-tcp", + ); + emit_activity(&activity_tx, true, "transparent_tcp_destination"); + return Ok(()); + } + let connector = destination::UpstreamConnector::new( + &host, + port, + admitted + .into_iter() + .map(|ip| SocketAddr::new(ip, port)) + .collect(), + ); let mut ctx = relay::http_context( &decision, None, @@ -1233,7 +1463,7 @@ async fn handle_transparent_tcp_connection( endpoint_observation: None, }, ); - let middleware_gate = middleware_uninspectable_gate(&opa_engine, &ctx)?; + let middleware_gate = middleware_uninspectable_gate(&network_engine, &ctx)?; if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::Deny { crate::l7::middleware::emit_middleware_uninspectable(&ctx, "transparent tcp", true); return Ok(()); @@ -1310,6 +1540,7 @@ async fn handle_transparent_tcp_connection( binary: &binary, pid: &pid, policy_name, + engine: decision.engine, } )); emit_activity(&activity_tx, false, "transparent_tcp"); @@ -1332,6 +1563,8 @@ struct TransparentTcpAllowAudit<'a> { binary: &'a str, pid: &'a str, policy_name: &'a str, + /// Engine that made the decision (`opa` or `cedar`). + engine: &'a str, } #[cfg(any(target_os = "linux", test))] @@ -1368,7 +1601,7 @@ fn build_transparent_tcp_allow_ocsf_event( )) .src_endpoint_addr(audit.workload.ip(), audit.workload.port()) .actor_process(Process::from_bypass(audit.binary, audit.pid, "")) - .firewall_rule(audit.policy_name, "opa") + .firewall_rule(audit.policy_name, audit.engine) .unmapped("matched_policy", audit.policy_name) .unmapped("normalized_domain", audit.normalized_domain) .unmapped("logical_destination", logical_destination) @@ -1482,7 +1715,7 @@ fn emit_transparent_policy_denial( .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint_addr(workload.ip(), workload.port()) .actor_process(Process::from_bypass(&binary, &pid, "-")) - .firewall_rule("-", "opa") + .firewall_rule("-", decision.engine) .message(format!("Transparent TCP denied {host}:{port}")) .status_detail(status_detail) .build() @@ -1705,11 +1938,11 @@ fn unsupported_l7_tunnel_protocol_detail( /// any matching entry is `fail_closed`; an all-`fail_open` chain passes it /// through with a bypass detection finding. fn middleware_uninspectable_gate( - opa_engine: &OpaEngine, + network_engine: &PolicyEngine, ctx: &crate::l7::relay::L7EvalContext, ) -> Result { let input = crate::l7::middleware::middleware_network_input(ctx); - let (chain, _generation) = opa_engine.query_middleware_chain_with_generation(&input)?; + let (chain, _generation) = network_engine.query_middleware_chain_with_generation(&input)?; Ok(crate::l7::middleware::uninspectable_traffic_gate(&chain)) } @@ -1893,6 +2126,7 @@ fn build_connect_allow_ocsf_event( cmdline: &str, policy: &str, l7_inspection: bool, + engine: &str, ) -> openshell_ocsf::OcsfEvent { let connect_msg = if l7_inspection { "CONNECT_L7" @@ -1908,7 +2142,7 @@ fn build_connect_allow_ocsf_event( .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) - .firewall_rule(policy, "opa") + .firewall_rule(policy, engine) .message(format!("{connect_msg} allowed {host}:{port}")) .build() } @@ -1925,6 +2159,7 @@ fn build_forward_allow_ocsf_event( ancestors: &str, cmdline: &str, policy: &str, + engine: &str, ) -> openshell_ocsf::OcsfEvent { HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::for_http_method(method)) @@ -1939,7 +2174,7 @@ fn build_forward_allow_ocsf_event( .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) - .firewall_rule(policy, "opa") + .firewall_rule(policy, engine) .message(format!("FORWARD allowed {method} {host}:{port}{path}")) .build() } @@ -2046,6 +2281,7 @@ fn build_forward_policy_deny_ocsf_event( ancestors: &str, cmdline: &str, reason: &str, + engine: &str, ) -> openshell_ocsf::OcsfEvent { HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) @@ -2060,7 +2296,7 @@ fn build_forward_policy_deny_ocsf_event( .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) - .firewall_rule("-", "opa") + .firewall_rule("-", engine) .message(format!("FORWARD denied {method} {host}:{port}{path}")) .status_detail(reason) .build() @@ -2074,6 +2310,7 @@ fn destination_denial_detail(kind: DestinationDenialKind) -> &'static str { DestinationDenialKind::AllowedIps => "allowed_ips check failed", DestinationDenialKind::DeclaredEndpoint => "declared endpoint check failed", DestinationDenialKind::InternalAddress => "internal address", + DestinationDenialKind::CedarAddress => "destination address check failed", } } @@ -2325,7 +2562,7 @@ fn has_valid_proxy_authorization(request: &str, expected: &str) -> bool { #[allow(clippy::too_many_arguments)] async fn handle_tcp_connection( client: TcpStream, - opa_engine: Arc, + opa_engine: Arc, identity_cache: Arc, entrypoint_pid: Arc, tls_state: Option>, @@ -2348,7 +2585,7 @@ async fn handle_tcp_connection( socket_addrs, None, None, - opa_engine, + PolicyEngine::from(opa_engine), identity_cache, entrypoint_pid, tls_state, @@ -2413,7 +2650,7 @@ async fn handle_mediated_connection( socket_addrs: Option<(SocketAddr, SocketAddr)>, transparent_open: Option, policy_dns_store: Option>, - opa_engine: Arc, + network_engine: PolicyEngine, identity_cache: Arc, entrypoint_pid: Arc, tls_state: Option>, @@ -2451,7 +2688,7 @@ async fn handle_mediated_connection( let host = resolve_transparent_target( destination, policy_dns_store.as_ref(), - &opa_engine, + &network_engine, )? .host; let (decision, connector) = transparent @@ -2596,7 +2833,7 @@ async fn handle_mediated_connection( &mut client, supplied_identity.as_ref(), socket_addrs, - opa_engine, + network_engine, identity_cache, entrypoint_pid, policy_local_ctx, @@ -2629,22 +2866,28 @@ async fn handle_mediated_connection( decision } else if let Some(identity) = supplied_identity.as_ref() { connect_span.in_scope(|| { - authorize_supplied_identity(&opa_engine, &identity_cache, intent, identity) + authorize_supplied_identity(&network_engine, &identity_cache, intent, identity) }) - } else if !opa_engine.binary_identity_required() { - connect_span.in_scope(|| evaluate_endpoint_only_opa(&opa_engine, intent)) + } else if !network_engine.binary_identity_required() { + connect_span.in_scope(|| evaluate_endpoint_only_opa(&network_engine, intent)) } else { let (workload_addr, proxy_addr) = socket_addrs.ok_or_else(|| { miette::miette!("legacy proxy connection is missing socket addresses") })?; let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); - let opa_clone = opa_engine.clone(); + let network_clone = network_engine.clone(); let cache_clone = identity_cache.clone(); let pid_clone = entrypoint_pid.clone(); let parent = connect_span.clone(); tokio::task::spawn_blocking(move || { parent.in_scope(|| { - authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) + authorize_egress_intent( + connection, + &network_clone, + &cache_clone, + &pid_clone, + intent, + ) }) }) .await @@ -2709,7 +2952,7 @@ async fn handle_mediated_connection( Process::from_bypass(&binary_str, &pid_str, &ancestors_str) .with_cmd_line(&cmdline_str), ) - .firewall_rule("-", "opa") + .firewall_rule("-", decision.engine) .message(format!("CONNECT denied {host_lc}:{port}")) .status_detail(&deny_reason) .build(); @@ -2739,7 +2982,7 @@ async fn handle_mediated_connection( } let connect_generation_guard = - match relay::pin_policy_generation(&opa_engine, decision.policy_generation) { + match relay::pin_policy_generation(&network_engine, decision.policy_generation) { Ok(guard) => guard, Err(error) => { reject_stale_connect_policy( @@ -2762,7 +3005,8 @@ async fn handle_mediated_connection( // connect and before `200 Connection Established`. hydrate_tls_mode(&mut decision); let effective_tls_skip = decision.endpoint.tls_mode == crate::l7::TlsMode::Skip; - let credential_guard = query_endpoint_credential_guard(&opa_engine, &decision, &host_lc, port)?; + let credential_guard = + query_endpoint_credential_guard(&network_engine, &decision, &host_lc, port)?; // Route materialization is safe before destination dialing and lets an // unambiguous MCP authority report local fail-closed outcomes that occur // before the HTTP request path becomes available. @@ -3008,6 +3252,7 @@ async fn handle_mediated_connection( &cmdline_str, policy_str, should_inspect_l7, + decision.engine, )); emit_connect_activity_if_l4_only(&activity_tx, l7_route); @@ -3034,7 +3279,7 @@ async fn handle_mediated_connection( if effective_tls_skip { // Policy validation rejects fail-closed middleware overlapping // `tls: skip` endpoints; this runtime gate is defense in depth. - match middleware_uninspectable_gate(&opa_engine, &ctx)? { + match middleware_uninspectable_gate(&network_engine, &ctx)? { crate::l7::middleware::UninspectableTrafficGate::Deny => { crate::l7::middleware::emit_middleware_uninspectable(&ctx, "tls-skip tunnel", true); respond( @@ -3064,7 +3309,7 @@ async fn handle_mediated_connection( port = port, "tls: skip — bypassing TLS auto-detection, raw tunnel" ); - let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &opa_engine, &decision) + let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &network_engine, &decision) else { return Ok(()); }; @@ -3119,7 +3364,7 @@ async fn handle_mediated_connection( }; let tls_result = async { let Some(relay_context) = - relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) + relay::prepare_http_relay(l7_route, &network_engine, &decision, &ctx) else { return Ok(()); }; @@ -3193,7 +3438,8 @@ async fn handle_mediated_connection( // Plaintext HTTP detected. ctx.request_default_port = Some(80); let is_l7_relay = l7_route.is_some_and(|route| !route.configs.is_empty()); - let Some(relay_context) = relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) + let Some(relay_context) = + relay::prepare_http_relay(l7_route, &network_engine, &decision, &ctx) else { return Ok(()); }; @@ -3221,7 +3467,7 @@ async fn handle_mediated_connection( } } } else { - let middleware_gate = middleware_uninspectable_gate(&opa_engine, &ctx)?; + let middleware_gate = middleware_uninspectable_gate(&network_engine, &ctx)?; let requirement = inspection_requirement(should_inspect_l7, middleware_gate); if let Some(protocol_detail) = unsupported_l7_tunnel_protocol_detail(tunnel_protocol, requirement) @@ -3280,7 +3526,7 @@ async fn handle_mediated_connection( port = port, "Non-TLS non-HTTP traffic detected, raw tunnel" ); - let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &opa_engine, &decision) + let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &network_engine, &decision) else { return Ok(()); }; @@ -3507,7 +3753,7 @@ fn resolve_process_identity( /// Authorize a proxied TCP connection inside an egress decision span. fn authorize_egress_intent( connection: crate::procfs::WorkloadProxyTcpConnection, - engine: &OpaEngine, + engine: &PolicyEngine, identity_cache: &BinaryIdentityCache, entrypoint_pid: &AtomicU32, intent: EgressIntent, @@ -3531,7 +3777,7 @@ fn authorize_egress_intent( #[cfg(target_os = "linux")] fn authorize_egress_intent_inner( connection: crate::procfs::WorkloadProxyTcpConnection, - engine: &OpaEngine, + engine: &PolicyEngine, identity_cache: &BinaryIdentityCache, entrypoint_pid: &AtomicU32, intent: EgressIntent, @@ -3550,6 +3796,7 @@ fn authorize_egress_intent_inner( intent: intent.clone(), action: NetworkAction::Deny { reason }, policy_generation: engine.current_generation(), + engine: engine.label(), identity, endpoint: EndpointDecision::default(), binary, @@ -3607,6 +3854,7 @@ fn authorize_egress_intent_inner( intent: intent.clone(), action: authorization.action.clone(), policy_generation: authorization.generation, + engine: engine.label(), identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(bin_path), @@ -3630,7 +3878,7 @@ fn proc_net_anchor_pid(entrypoint_pid: u32) -> Option { (entrypoint_pid != 0).then_some(entrypoint_pid) } -fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> EgressDecision { +fn evaluate_endpoint_only_opa(engine: &PolicyEngine, intent: EgressIntent) -> EgressDecision { egress::traced_authorization( intent, |intent| evaluate_endpoint_only_opa_inner(engine, intent), @@ -3638,7 +3886,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres ) } -fn evaluate_endpoint_only_opa_inner(engine: &OpaEngine, intent: EgressIntent) -> EgressDecision { +fn evaluate_endpoint_only_opa_inner(engine: &PolicyEngine, intent: EgressIntent) -> EgressDecision { let input = crate::opa::NetworkInput { host: intent.destination.host.clone(), port: intent.destination.port, @@ -3653,6 +3901,7 @@ fn evaluate_endpoint_only_opa_inner(engine: &OpaEngine, intent: EgressIntent) -> intent, action: authorization.action.clone(), policy_generation: authorization.generation, + engine: engine.label(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), @@ -3668,6 +3917,7 @@ fn evaluate_endpoint_only_opa_inner(engine: &OpaEngine, intent: EgressIntent) -> reason: format!("policy evaluation error: {e}"), }, policy_generation: engine.current_generation(), + engine: engine.label(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), @@ -3684,7 +3934,7 @@ fn evaluate_endpoint_only_opa_inner(engine: &OpaEngine, intent: EgressIntent) -> /// connection by an isolation backend. This is the RFC 0012 path; legacy /// listeners continue to resolve through procfs in `authorize_egress_intent`. fn authorize_supplied_identity( - engine: &OpaEngine, + engine: &PolicyEngine, identity_cache: &BinaryIdentityCache, intent: EgressIntent, identity: &Result, @@ -3704,7 +3954,7 @@ struct SuppliedIdentityAuthorization { } fn authorize_supplied_identity_with_denial( - engine: &OpaEngine, + engine: &PolicyEngine, identity_cache: &BinaryIdentityCache, intent: EgressIntent, identity: &Result, @@ -3719,7 +3969,7 @@ fn authorize_supplied_identity_with_denial( } fn authorize_supplied_identity_with_denial_inner( - engine: &OpaEngine, + engine: &PolicyEngine, identity_cache: &BinaryIdentityCache, intent: EgressIntent, identity: &Result, @@ -3731,6 +3981,7 @@ fn authorize_supplied_identity_with_denial_inner( intent: intent.clone(), action: NetworkAction::Deny { reason }, policy_generation: engine.current_generation(), + engine: engine.label(), identity: ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::LookupFailed), endpoint: EndpointDecision::default(), binary, @@ -3790,6 +4041,7 @@ fn authorize_supplied_identity_with_denial_inner( intent, action: authorization.action.clone(), policy_generation: authorization.generation, + engine: engine.label(), identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(identity.executable.path.clone()), @@ -3814,7 +4066,7 @@ fn authorize_supplied_identity_with_denial_inner( #[cfg(not(target_os = "linux"))] fn authorize_egress_intent_inner( _connection: crate::procfs::WorkloadProxyTcpConnection, - engine: &OpaEngine, + engine: &PolicyEngine, _identity_cache: &BinaryIdentityCache, _entrypoint_pid: &AtomicU32, intent: EgressIntent, @@ -3825,6 +4077,7 @@ fn authorize_egress_intent_inner( reason: "identity binding unavailable on this platform".into(), }, policy_generation: engine.current_generation(), + engine: engine.label(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::UnsupportedPlatform, ), @@ -3912,6 +4165,7 @@ fn hydrate_destination_plan( trusted_host_gateway, &raw_allowed_ips, exact_declared_host, + decision.endpoint.cedar_destination.as_ref(), )?; decision.endpoint.destination = Some(plan); Ok(()) @@ -4002,7 +4256,7 @@ fn query_tls_mode(decision: &EgressDecision, _host: &str, _port: u16) -> crate:: } fn query_endpoint_credential_guard( - engine: &OpaEngine, + engine: &PolicyEngine, decision: &EgressDecision, host: &str, port: u16, @@ -4023,7 +4277,7 @@ fn query_endpoint_credential_guard( ancestors: decision.ancestors.clone(), cmdline_paths: decision.cmdline_paths.clone(), }; - let values = engine.query_endpoint_credential_guards(&input)?; + let values = engine.endpoint_credential_guards(&input)?; let credentialed: Vec<_> = values .iter() .map(crate::l7::parse_endpoint_credential_guard) @@ -4720,27 +4974,10 @@ fn query_allowed_ips(decision: &EgressDecision) -> Vec { .endpoint .policy_configs .first() - .map(|config| endpoint_config_string_array(config, "allowed_ips")) + .map(|config| config.allowed_ips.clone()) .unwrap_or_default() } -fn endpoint_config_string_array(config: ®orus::Value, key: &str) -> Vec { - let regorus::Value::Object(fields) = config else { - return Vec::new(); - }; - let key = regorus::Value::String(key.into()); - let Some(regorus::Value::Array(values)) = fields.get(&key) else { - return Vec::new(); - }; - values - .iter() - .filter_map(|value| match value { - regorus::Value::String(value) => Some(value.to_string()), - _ => None, - }) - .collect() -} - /// Extract the hostname from an absolute-form URI used in plain HTTP proxy requests. /// /// For example, `"http://example.com/path"` yields `"example.com"` and @@ -5337,7 +5574,7 @@ async fn handle_forward_proxy( client: &mut ProxyClient, supplied_identity: Option<&Result>, socket_addrs: Option<(SocketAddr, SocketAddr)>, - opa_engine: Arc, + network_engine: PolicyEngine, identity_cache: Arc, entrypoint_pid: Arc, policy_local_ctx: Option>, @@ -5464,22 +5701,28 @@ async fn handle_forward_proxy( let connect_span = egress::connect_span(&intent); let mut decision = if let Some(identity) = supplied_identity { connect_span.in_scope(|| { - authorize_supplied_identity(&opa_engine, &identity_cache, intent, identity) + authorize_supplied_identity(&network_engine, &identity_cache, intent, identity) }) - } else if !opa_engine.binary_identity_required() { - connect_span.in_scope(|| evaluate_endpoint_only_opa(&opa_engine, intent)) + } else if !network_engine.binary_identity_required() { + connect_span.in_scope(|| evaluate_endpoint_only_opa(&network_engine, intent)) } else { let (workload_addr, proxy_addr) = socket_addrs.ok_or_else(|| { miette::miette!("legacy proxy connection is missing socket addresses") })?; let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); - let opa_clone = opa_engine.clone(); + let network_clone = network_engine.clone(); let cache_clone = identity_cache.clone(); let pid_clone = entrypoint_pid.clone(); let parent = connect_span.clone(); tokio::task::spawn_blocking(move || { parent.in_scope(|| { - authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) + authorize_egress_intent( + connection, + &network_clone, + &cache_clone, + &pid_clone, + intent, + ) }) }) .await @@ -5536,6 +5779,7 @@ async fn handle_forward_proxy( &ancestors_str, &cmdline_str, reason, + decision.engine, )); emit_denial_simple( denial_tx, @@ -5568,13 +5812,13 @@ async fn handle_forward_proxy( binary_pid = %pid_str, matched_policy = %policy_str, policy_generation = decision.policy_generation, - current_generation = opa_engine.current_generation(), + current_generation = network_engine.current_generation(), action = ?decision.action, "Forward proxy L4 policy decision" ); let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); let forward_generation_guard = match relay::pin_policy_generation( - &opa_engine, + &network_engine, decision.policy_generation, ) { Ok(guard) => guard, @@ -5583,7 +5827,7 @@ async fn handle_forward_proxy( host = %host_lc, port, policy_generation = decision.policy_generation, - current_generation = opa_engine.current_generation(), + current_generation = network_engine.current_generation(), error = %e, "Forward proxy rejected request because policy generation changed after L4 decision" ); @@ -5602,6 +5846,15 @@ async fn handle_forward_proxy( return Ok(()); } }; + // On a YAML sandbox the middleware chain comes from the policy + // generation, so it must match the pinned decision generation. On a + // Cedar sandbox it comes from the middleware state, whose generation + // moves independently of Cedar's, so pin its own generation here; Cedar + // staleness stays enforced by `forward_generation_guard`. + let middleware_expected_generation = match &network_engine { + PolicyEngine::Opa(_) => forward_generation_guard.captured_generation(), + PolicyEngine::Cedar(_) => network_engine.middleware_generation(), + }; let mut websocket_extensions = crate::l7::rest::WebSocketExtensionMode::Preserve; let mut forward_tunnel_engine: Option = None; // L7 endpoint config and evaluated request info, carried past the L7 @@ -5702,7 +5955,7 @@ async fn handle_forward_proxy( policy_generation = decision.policy_generation, l4_guard_generation = forward_generation_guard.captured_generation(), l7_policy_generation = route.l7_policy_generation, - current_generation = opa_engine.current_generation(), + current_generation = network_engine.current_generation(), "Forward proxy rejected request because L7 route lookup used a different policy generation" ); emit_l7_tunnel_close_after_policy_change( @@ -5727,14 +5980,17 @@ async fn handle_forward_proxy( .await?; return Ok(()); } - let tunnel_engine = match relay::pin_l7_evaluator(&opa_engine, route.l7_policy_generation) { + let tunnel_engine = match relay::pin_l7_evaluator( + &network_engine, + route.l7_policy_generation, + ) { Ok(engine) => engine, Err(e) => { warn!( host = %host_lc, port, l7_policy_generation = route.l7_policy_generation, - current_generation = opa_engine.current_generation(), + current_generation = network_engine.current_generation(), error = %e, "Forward proxy rejected request because L7 tunnel engine could not be cloned" ); @@ -6259,15 +6515,13 @@ async fn handle_forward_proxy( cmdline_paths: decision.cmdline_paths.clone(), }; let (chain, generation) = - opa_engine.query_middleware_chain_with_generation(&middleware_input)?; - if generation != forward_generation_guard.captured_generation() { + network_engine.query_middleware_chain_with_generation(&middleware_input)?; + if generation != middleware_expected_generation { emit_l7_tunnel_close_after_policy_change( &host_lc, port, miette::miette!( - "policy changed before forward middleware evaluation [expected_generation:{} current_generation:{}]", - forward_generation_guard.captured_generation(), - generation, + "policy changed before forward middleware evaluation [expected_generation:{middleware_expected_generation} current_generation:{generation}]", ), ); respond( @@ -6287,7 +6541,7 @@ async fn handle_forward_proxy( let response_selection = if chain.is_empty() { None } else { - let middleware_runner = opa_engine.middleware_runner()?; + let middleware_runner = network_engine.middleware_runner()?; let request = crate::l7::rest::request_from_buffered_http( method, middleware_path, @@ -6341,7 +6595,7 @@ async fn handle_forward_proxy( &upstream_target, forward_request_bytes.clone(), )?; - let middleware_runner = opa_engine.middleware_runner()?; + let middleware_runner = network_engine.middleware_runner()?; let preflight = crate::l7::relay::websocket_middleware_preflight( &request, chain, @@ -6766,6 +7020,7 @@ async fn handle_forward_proxy( &ancestors_str, &cmdline_str, policy_str, + network_engine.label(), )); emit_forward_success_activity(activity_tx, l7_activity_pending); @@ -6826,7 +7081,7 @@ async fn handle_forward_proxy( } }; upgrade_options.generation_guard = Some(&forward_generation_guard); - upgrade_options.assembly_budget = Some(opa_engine.websocket_assembly_budget()); + upgrade_options.assembly_budget = Some(network_engine.websocket_assembly_budget()); upgrade_options.websocket.permessage_deflate = websocket_permessage_deflate; upgrade_options.middleware_session = middleware_session.take(); upgrade_options.selected_subprotocol = websocket_subprotocol; @@ -7108,6 +7363,7 @@ mod tests { } use super::*; + use crate::opa::OpaEngine; use crate::test_support::{executable_path, policy_with_native_executable_paths}; use openshell_core::proposals::AgentProposals; use std::collections::HashMap as TestHashMap; @@ -7155,6 +7411,7 @@ process: ), ) .expect("load policy"); + let engine = PolicyEngine::from(Arc::new(engine)); let identity = Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { path: executable_path("/usr/bin/python3"), @@ -7212,6 +7469,7 @@ process: ), ) .expect("load policy"); + let engine = PolicyEngine::from(Arc::new(engine)); let identity = |digest_byte: &str| { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { @@ -7267,6 +7525,7 @@ process: ), ) .expect("load policy"); + let engine = PolicyEngine::from(Arc::new(engine)); let identity = |ancestor_digest: &str| { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { @@ -7317,9 +7576,11 @@ process: run_as_group: sandbox "#; let rego = include_str!("../data/sandbox-policy.rego"); - let engine = + let opa = Arc::new( OpaEngine::from_strings(rego, &policy_with_native_executable_paths(POLICY_DATA)) - .expect("load policy"); + .expect("load policy"), + ); + let engine = PolicyEngine::from(Arc::clone(&opa)); let identity = |digest_byte: &str| { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { @@ -7337,8 +7598,7 @@ process: authorize_supplied_identity(&engine, &identity_cache, intent(), &identity("11")); assert!(matches!(original.action, NetworkAction::Allow { .. })); - engine - .reload(rego, &policy_with_native_executable_paths(POLICY_DATA)) + opa.reload(rego, &policy_with_native_executable_paths(POLICY_DATA)) .expect("reload policy"); let replacement = authorize_supplied_identity(&engine, &identity_cache, intent(), &identity("22")); @@ -7378,6 +7638,7 @@ process: ), ) .expect("load policy"); + let engine = PolicyEngine::from(Arc::new(engine)); let identity = Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { path: executable_path("/sandbox/bin/client"), @@ -7435,6 +7696,7 @@ process: .unwrap(); let identity_cache = BinaryIdentityCache::new(); let (denial_tx, mut denial_rx) = mpsc::unbounded_channel(); + let engine = PolicyEngine::from(Arc::new(engine)); let identity = || { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { @@ -7664,6 +7926,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } ) .unwrap(), ); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let (server, mut workload) = tokio::io::duplex(32768); let credentials = ProviderCredentialState::from_environment( 1, @@ -7697,7 +7960,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } connect_span: None, }), None, - engine, + network_engine.clone(), Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(1)), None, @@ -7783,6 +8046,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } "network_policies: {}", ) .unwrap(); + let engine = PolicyEngine::from(Arc::new(engine)); for valid_identity in [true, false] { let (mut open, completion) = staged_curl_open( openshell_core::google_cloud::METADATA_LOOPBACK_ADDR @@ -7825,6 +8089,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), ) .unwrap(); + let engine = PolicyEngine::from(Arc::new(engine)); let store = policy_dns_test_store(); let record = publish_mapping( &store, @@ -7865,6 +8130,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), ) .unwrap(); + let engine = PolicyEngine::from(Arc::new(engine)); let store = policy_dns_test_store(); let record = publish_observation(&store, "unknown.example", engine.current_generation()); let (open, completion) = staged_curl_open( @@ -7908,6 +8174,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), ) .unwrap(); + let engine = PolicyEngine::from(Arc::new(engine)); let identity_cache = BinaryIdentityCache::new(); let (denial_tx, mut denial_rx) = mpsc::unbounded_channel(); let preauthorize = |store: Arc, destination: SocketAddr| { @@ -7969,11 +8236,14 @@ process: { run_as_user: sandbox, run_as_group: sandbox } #[test] fn pinned_plan_requires_the_mapping_of_the_deciding_generation() { - let engine = native_identity_engine( - include_str!("../data/sandbox-policy.rego"), - &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), - ) - .unwrap(); + let opa = Arc::new( + OpaEngine::from_strings( + include_str!("../data/sandbox-policy.rego"), + &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), + ) + .unwrap(), + ); + let engine = PolicyEngine::from(Arc::clone(&opa)); let store = policy_dns_test_store(); let mapped = publish_mapping( &store, @@ -8011,12 +8281,11 @@ process: { run_as_user: sandbox, run_as_group: sandbox } // A reload between DNS correlation and authorization yields a decision // from a newer generation than the DNS answer. It must not fall back // to resolving the name again. - engine - .reload( - include_str!("../data/sandbox-policy.rego"), - &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), - ) - .unwrap(); + opa.reload( + include_str!("../data/sandbox-policy.rego"), + &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), + ) + .unwrap(); let reloaded = decide("db.example", 5432); assert!(matches!(reloaded.action, NetworkAction::Allow { .. })); assert!(matches!( @@ -8069,6 +8338,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } ) .unwrap(), ); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let cache = Arc::new(BinaryIdentityCache::new()); let identity = ContractBinaryIdentity { executable: ContractExecutableIdentity { @@ -8093,10 +8363,18 @@ process: { run_as_user: sandbox, run_as_group: sandbox } timing: MediationTiming::default(), decision, }; - let (stream, supplied_identity, socket_addrs, transparent) = - preauthorize_transparent_open(pending, None, &engine, &cache, None, None, true, None) - .await - .expect("sandbox-local open is admitted"); + let (stream, supplied_identity, socket_addrs, transparent) = preauthorize_transparent_open( + pending, + None, + &network_engine, + &cache, + None, + None, + true, + None, + ) + .await + .expect("sandbox-local open is admitted"); assert_eq!(completion.await.unwrap(), TcpOpenDecision::RelayReady); let proposals = AgentProposals::new(true); @@ -8117,7 +8395,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } socket_addrs, transparent, None, - engine, + network_engine.clone(), cache, Arc::new(AtomicU32::new(0)), None, @@ -8201,6 +8479,7 @@ network_policies: ) .unwrap(), ); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let cache = Arc::new(BinaryIdentityCache::new()); let (stream, mut workload) = tokio::io::duplex(4096); let (decision, completion) = tokio::sync::oneshot::channel(); @@ -8224,10 +8503,18 @@ network_policies: timing: MediationTiming::default(), decision, }; - let (stream, supplied_identity, socket_addrs, transparent) = - preauthorize_transparent_open(pending, None, &engine, &cache, None, None, false, None) - .await - .expect("open is admitted"); + let (stream, supplied_identity, socket_addrs, transparent) = preauthorize_transparent_open( + pending, + None, + &network_engine, + &cache, + None, + None, + false, + None, + ) + .await + .expect("open is admitted"); assert_eq!(completion.await.unwrap(), TcpOpenDecision::RelayReady); // The workload writes before the proxy reads anything, so its request sits // right behind the synthesized CONNECT header in the proxy's first read. @@ -8245,7 +8532,7 @@ network_policies: socket_addrs, transparent, None, - engine, + network_engine.clone(), cache, Arc::new(AtomicU32::new(0)), None, @@ -8342,6 +8629,7 @@ process: ), ) .unwrap(); + let engine = PolicyEngine::from(Arc::new(engine)); let identity_cache = BinaryIdentityCache::new(); let (stream, _peer) = tokio::io::duplex(64); let (decision, completion) = tokio::sync::oneshot::channel(); @@ -8413,6 +8701,7 @@ process: ), ) .unwrap(); + let engine = PolicyEngine::from(Arc::new(engine)); let identity_cache = BinaryIdentityCache::new(); for index in 0..4096 { identity_cache @@ -8851,10 +9140,11 @@ network_policies: {} .expect("engine"), ); let (_ready_tx, ready_rx) = tokio::sync::watch::channel(true); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let mut handle = ProxyHandle::start_with_bind_addr( &ProxyPolicy { http_addr: None }, Some(([127, 0, 0, 1], 3128).into()), - engine, + network_engine, Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(1)), None, @@ -8991,6 +9281,7 @@ network_policies: OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) .expect("load MCP policy"), ); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( openshell_supervisor_middleware_builtins::services(), Vec::new(), @@ -9059,7 +9350,7 @@ network_policies: &mut proxy_connection, None, socket_addrs, - engine, + network_engine.clone(), Arc::clone(&identity_cache), Arc::new(AtomicU32::new(std::process::id())), None, @@ -9111,6 +9402,140 @@ network_policies: } } + /// A Cedar sandbox's middleware state has its own generation counter, + /// which moves independently of Cedar's (installing the middleware + /// registry advances it). A plain-HTTP forward request Cedar allows must + /// still reach the upstream, not fail the middleware generation check. + #[tokio::test] + async fn cedar_forward_request_survives_middleware_generation_advance() { + let Some(upstream_ip) = non_loopback_test_ipv4() else { + eprintln!("skipping: no routable non-loopback IPv4 test address"); + return; + }; + let upstream_listener = TcpListener::bind((upstream_ip, 0)) + .await + .expect("bind upstream listener"); + let upstream_port = upstream_listener.local_addr().unwrap().port(); + + // The same native executable path main's forward tests use, so the + // policy literal and the supplied identity agree on Windows too. + let curl = executable_path("/usr/bin/curl"); + let curl_literal = curl.to_string_lossy().replace('\\', "\\\\"); + let cedar = Arc::new( + crate::cedar_only::CedarOnlyEngine::from_policy_str(&format!( + r#" +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"{upstream_ip}:{upstream_port}" +) +when {{ + context.binary_path == "{curl_literal}" +}}; +"# + )) + .expect("Cedar policy parses"), + ); + let network_engine = PolicyEngine::from(Arc::clone(&cedar)); + let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await + .expect("connect built-in middleware"); + network_engine + .replace_middleware_registry(registry) + .expect("install built-in middleware"); + assert_ne!( + network_engine.middleware_generation(), + cedar.current_generation(), + "precondition: the middleware and Cedar generations must differ" + ); + + let upstream = tokio::spawn(async move { + let (mut socket, _) = upstream_listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = socket.read(&mut chunk).await.unwrap(); + assert_ne!(count, 0, "request closed before its headers completed"); + request.extend_from_slice(&chunk[..count]); + } + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .unwrap(); + String::from_utf8(request).expect("UTF-8 request") + }); + let proxy_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind proxy listener"); + let proxy_address = proxy_listener.local_addr().unwrap(); + let target = format!("http://{upstream_ip}:{upstream_port}/status"); + let request = format!( + "GET {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nConnection: close\r\n\r\n" + ); + let client = tokio::spawn(async move { + let mut socket = TcpStream::connect(proxy_address).await.unwrap(); + let mut response = Vec::new(); + socket.read_to_end(&mut response).await.unwrap(); + response + }); + let (proxy_connection, _) = proxy_listener.accept().await.unwrap(); + let socket_addrs = proxy_connection + .peer_addr() + .ok() + .zip(proxy_connection.local_addr().ok()); + let mut proxy_connection: ProxyClient = + tokio::io::BufReader::new(Box::new(proxy_connection)); + let identity = Ok(ContractBinaryIdentity { + executable: ContractExecutableIdentity { + path: curl.clone(), + digest: Some("00".repeat(32).parse().expect("digest")), + }, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }); + + Box::pin(tokio::time::timeout( + std::time::Duration::from_secs(30), + Box::pin(handle_forward_proxy( + "GET", + &target, + request.as_bytes(), + request.len(), + &mut proxy_connection, + Some(&identity), + socket_addrs, + network_engine, + Arc::new(BinaryIdentityCache::new()), + Arc::new(AtomicU32::new(std::process::id())), + None, + AgentProposals::default(), + Arc::new(None), + Arc::new(None), + None, + None, + None, + None, + None, + )), + )) + .await + .expect("forwarding should complete") + .expect("handle forward request"); + drop(proxy_connection); + + let response = client.await.expect("join client"); + let response = String::from_utf8_lossy(&response); + assert!( + response.starts_with("HTTP/1.1 200 OK"), + "a Cedar-allowed forward request must reach the upstream; got: {response:?}" + ); + let forwarded = upstream.await.expect("join upstream"); + assert!(forwarded.starts_with("GET /status HTTP/1.1\r\n")); + } + #[tokio::test] async fn forward_mcp_websocket_upgrade_is_denied_before_connecting_upstream() { if !cfg!(target_os = "linux") { @@ -9150,6 +9575,7 @@ network_policies: OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) .expect("load MCP policy"), ); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let proxy_listener = TcpListener::bind("127.0.0.1:0") .await @@ -9190,7 +9616,7 @@ network_policies: &mut proxy_connection, None, socket_addrs, - engine, + network_engine.clone(), Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(std::process::id())), None, @@ -9269,6 +9695,7 @@ network_policies: OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) .expect("load GraphQL policy"), ); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let proxy_listener = TcpListener::bind("127.0.0.1:0") .await @@ -9308,7 +9735,7 @@ network_policies: &mut proxy_connection, None, socket_addrs, - engine, + network_engine.clone(), Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(std::process::id())), None, @@ -9386,6 +9813,7 @@ network_policies: OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) .expect("load policy"), ); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( vec![openshell_supervisor_middleware::in_process_endpoint( Arc::new(DenyWebSocketPreflight), @@ -9435,7 +9863,7 @@ network_policies: &mut proxy_connection, None, socket_addrs, - engine, + network_engine.clone(), Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(std::process::id())), None, @@ -9509,6 +9937,7 @@ network_policies: OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) .expect("load policy"), ); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( openshell_supervisor_middleware_builtins::services(), Vec::new(), @@ -9577,7 +10006,7 @@ network_policies: &mut proxy_connection, None, socket_addrs, - engine, + network_engine.clone(), Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(std::process::id())), None, @@ -9880,6 +10309,7 @@ network_policies: "/usr/bin/bash", "curl http://api.example.com/v1/models", reason, + "opa", ); let json = event.to_json().unwrap(); @@ -9945,6 +10375,7 @@ network_policies: binary: "/sandbox/.venv/bin/python3", pid: "42", policy_name: "redis", + engine: "opa", }); let json = event.to_json().unwrap(); @@ -9997,6 +10428,7 @@ network_policies: binary: "/usr/bin/redis-cli", pid: "43", policy_name: "redis", + engine: "opa", }); let json = event.to_json().unwrap(); @@ -10075,6 +10507,7 @@ network_policies: "/usr/bin/bash", "curl", "allow_api", + "opa", ) .to_json() .unwrap(); @@ -10089,6 +10522,7 @@ network_policies: "/usr/bin/bash", "curl", "policy denied", + "opa", ) .to_json() .unwrap(); @@ -10115,6 +10549,7 @@ network_policies: "/usr/bin/bash", "curl", "allow_api", + "opa", ) .to_json() .unwrap(); @@ -10164,6 +10599,7 @@ network_policies: "#; let engine = OpaEngine::from_strings_with_binary_identity_required(policy, data, false) .expect("relaxed engine"); + let engine = PolicyEngine::from(Arc::new(engine)); let decision = evaluate_endpoint_only_opa( &engine, @@ -11211,6 +11647,7 @@ network_policies: intent: EgressIntent::forward_http(host.to_string(), port), action: authorization.action.clone(), policy_generation: authorization.generation, + engine: "opa", identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(PathBuf::from("/usr/bin/node")), @@ -14835,6 +15272,7 @@ network_policies: intent: EgressIntent::connect("203.0.113.10".to_string(), 443), action: authorization.action.clone(), policy_generation: authorization.generation, + engine: "opa", identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(input.binary_path), diff --git a/crates/openshell-supervisor-network/src/proxy/destination.rs b/crates/openshell-supervisor-network/src/proxy/destination.rs index ae1c5f9179..1d3d2a55ff 100644 --- a/crates/openshell-supervisor-network/src/proxy/destination.rs +++ b/crates/openshell-supervisor-network/src/proxy/destination.rs @@ -9,11 +9,13 @@ //! Shared external destination validation and upstream dial boundary. use super::{ - BLOCKED_CONTROL_PLANE_PORTS, DestinationCheckError, implicit_allowed_ips_for_ip_host, - is_cloud_metadata_ip, is_host_gateway_alias, is_link_local_ip, parse_allowed_ips, - resolve_and_check_allowed_ips, resolve_and_check_declared_endpoint, - resolve_and_check_trusted_gateway, resolve_and_reject_internal, + AddressBinding, BLOCKED_CONTROL_PLANE_PORTS, DestinationCheckError, + implicit_allowed_ips_for_ip_host, is_cloud_metadata_ip, is_host_gateway_alias, + is_link_local_ip, normalize_host_lookup_key, parse_allowed_ips, resolve_and_check_allowed_ips, + resolve_and_check_declared_endpoint, resolve_and_check_trusted_gateway, + resolve_and_reject_internal, resolve_socket_addrs, }; +use crate::cedar_only::CedarDestination; use ipnet::IpNet; use openshell_core::net::{connect_tcp_nodelay_best_effort, is_always_blocked_ip, is_internal_ip}; use std::net::{IpAddr, SocketAddr}; @@ -38,6 +40,9 @@ pub(crate) enum AddressAuthorization { /// unopened connector. #[allow(dead_code, reason = "used when the policy DNS adapter lands")] PinnedResolved(Vec), + /// A Cedar sandbox's connection: each resolved address must pass + /// [`cedar_address_rejection`]. + Cedar(Box), } /// Fully materialized input to shared destination validation. @@ -66,6 +71,8 @@ pub(crate) enum DestinationDenialKind { AllowedIps, DeclaredEndpoint, InternalAddress, + /// A Cedar sandbox's destination address rules rejected the address. + CedarAddress, } #[derive(Debug)] @@ -90,6 +97,11 @@ impl DestinationDenial { } /// Select one current destination-validation mode without changing precedence. +/// +/// A Cedar sandbox passes `cedar`, which replaces the modes below the host +/// gateway aliases. Those aliases keep their pinned address, which Cedar must +/// also allow. As in YAML, provider `allowed_ips` do not apply to an alias's +/// pinned address. pub(crate) fn build_validation_plan( host: &str, normalized_host: &str, @@ -97,7 +109,26 @@ pub(crate) fn build_validation_plan( trusted_host_gateway: Option, raw_allowed_ips: &[String], exact_declared_endpoint_host: bool, + cedar: Option<&CedarDestination>, ) -> Result { + let gateway_ip = if is_host_gateway_alias(normalized_host) { + backend_host_gateway.or(trusted_host_gateway) + } else { + None + }; + if let (Some(ip), Some(cedar)) = (gateway_ip, cedar) { + let allowed = cedar + .allows(ip) + .map_err(|error| DestinationDenial::new(DestinationDenialKind::CedarAddress, error))?; + if !allowed { + return Err(DestinationDenial::new( + DestinationDenialKind::CedarAddress, + format!( + "Cedar policy denies {host} at host gateway address {ip}, connection rejected" + ), + )); + } + } let address_authorization = if is_host_gateway_alias(normalized_host) && let Some(expected_ip) = backend_host_gateway { @@ -106,6 +137,8 @@ pub(crate) fn build_validation_plan( && let Some(expected_ip) = trusted_host_gateway { AddressAuthorization::TrustedGatewayAlias { expected_ip } + } else if let Some(cedar) = cedar { + AddressAuthorization::Cedar(Box::new(cedar.clone())) } else if !raw_allowed_ips.is_empty() { AddressAuthorization::ExplicitAllowedIps(parse_allowed_ips(raw_allowed_ips).map_err( |reason| DestinationDenial::new(DestinationDenialKind::InvalidAllowedIps, reason), @@ -126,6 +159,141 @@ pub(crate) fn build_validation_plan( }) } +/// Returns why a Cedar sandbox rejects resolved address `ip` for a +/// connection to `host`, or `None` when it is admitted. +/// +/// Reproduces the YAML modes on the connection's allowing permits, never +/// admitting an address that YAML would reject whichever matching endpoint +/// it consulted first: +/// +/// - Loopback, link-local (including cloud metadata), and unspecified +/// addresses are always rejected. +/// - Every allowing permit with a `destination_ip` condition must admit the +/// address, as a YAML endpoint's `allowed_ips` restricts every address. +/// - A private address also needs a permit that names the host exactly, or +/// only permits with `destination_ip` conditions; a permit without one is +/// public-only unless it names the host, as a YAML endpoint without +/// `allowed_ips` is. +/// - Every matching provider endpoint with `allowed_ips` must admit the +/// address, as those ranges restrict a YAML provider endpoint's +/// connections. They only narrow: providers grant no access, so they never +/// count toward admitting a private address. +/// - Cedar, with every policy, must allow the connection to the address. +pub(crate) fn cedar_address_rejection( + cedar: &CedarDestination, + host: &str, + ip: IpAddr, +) -> Option { + if is_always_blocked_ip(ip) { + return Some(format!( + "{host} resolves to always-blocked address {ip}, connection rejected" + )); + } + let conditions = cedar.address_conditions(); + if !conditions + .iter() + .all(|ranges| ranges.iter().any(|range| range.contains(&ip))) + { + return Some(format!( + "{host} resolves to {ip} which is not in the policy's destination_ip ranges, \ + connection rejected" + )); + } + if let Some(reason) = cedar_provider_rejection(cedar, host, ip) { + return Some(reason); + } + let private_admitted = + cedar.names_endpoint() || (!conditions.is_empty() && !cedar.unconstrained_permit()); + if is_internal_ip(ip) && !private_admitted { + return Some(format!( + "{host} resolves to internal address {ip}, connection rejected" + )); + } + match cedar.allows(ip) { + Ok(true) => None, + Ok(false) => Some(format!( + "Cedar policy denies {host} at address {ip}, connection rejected" + )), + Err(error) => Some(format!( + "Cedar policy evaluation failed for {host} at address {ip}: {error}" + )), + } +} + +/// Returns why the `allowed_ips` of a provider endpoint matching a Cedar +/// sandbox's connection to `host` reject resolved address `ip`, or `None` +/// when every such endpoint admits it. +fn cedar_provider_rejection(cedar: &CedarDestination, host: &str, ip: IpAddr) -> Option { + cedar + .provider_ranges() + .iter() + .find_map(|ranges| match ranges { + Err(reason) => Some(format!("{host}: {reason}, connection rejected")), + Ok(ranges) if !ranges.iter().any(|range| range.contains(&ip)) => Some(format!( + "{host} resolves to {ip} which is not in its provider endpoint's allowed_ips, \ + connection rejected" + )), + Ok(_) => None, + }) +} + +/// Returns whether a Cedar sandbox blocks control-plane ports for a +/// connection to `host`. +/// +/// YAML blocks them in every mode that can admit a private address: an +/// endpoint with `allowed_ips`, an IP literal host, or an exactly declared +/// host. Only a public-only connection may use them. A provider endpoint's +/// `allowed_ips` block them too, as they do for a YAML provider endpoint. +pub(crate) fn cedar_blocks_control_plane(cedar: &CedarDestination, host: &str) -> bool { + !cedar.address_conditions().is_empty() + || !cedar.provider_ranges().is_empty() + || cedar.names_endpoint() + || normalize_host_lookup_key(host).parse::().is_ok() +} + +/// Returns whether sandbox `/etc/hosts` entries may resolve a Cedar +/// sandbox's connection. +/// +/// They may only when every resolved address is then checked against fixed +/// ranges: an allowing permit's `destination_ip` condition or a matching +/// provider endpoint's `allowed_ips`, the modes in which YAML consults them +/// for an endpoint with `allowed_ips`. Otherwise the hostname alone +/// authorizes the connection, so the supervisor resolver is authoritative, +/// as it is for a YAML exact or wildcard host: accepting the workload's +/// hosts file there would let it choose the address the name authorizes. +fn cedar_address_binding(cedar: &CedarDestination) -> AddressBinding { + if cedar.address_conditions().is_empty() && cedar.provider_ranges().is_empty() { + AddressBinding::NameAuthorized + } else { + AddressBinding::IpConstrained + } +} + +/// Checks every resolved address of a Cedar sandbox's connection, rejecting +/// the connection if any is rejected, as CONNECT and forward HTTP do. +pub(crate) fn validate_cedar_resolved_addrs( + cedar: &CedarDestination, + host: &str, + port: u16, + addrs: &[SocketAddr], +) -> Result<(), String> { + if addrs.is_empty() { + return Err(format!( + "DNS resolution returned no addresses for {}", + normalize_host_lookup_key(host) + )); + } + if BLOCKED_CONTROL_PLANE_PORTS.contains(&port) && cedar_blocks_control_plane(cedar, host) { + return Err(format!( + "port {port} is a blocked control-plane port, connection rejected" + )); + } + addrs + .iter() + .find_map(|addr| cedar_address_rejection(cedar, host, addr.ip())) + .map_or(Ok(()), Err) +} + /// Build the destination mode used by policy DNS after it has validated and /// pinned a non-empty answer set for an endpoint. #[allow(dead_code, reason = "used by the policy DNS adapter")] @@ -168,6 +336,10 @@ pub(crate) fn filter_resolved_addresses( AddressAuthorization::ExactDeclaredHost => (DestinationDenialKind::DeclaredEndpoint, true), AddressAuthorization::DefaultPublicOnly => (DestinationDenialKind::InternalAddress, false), AddressAuthorization::PinnedResolved(_) => (DestinationDenialKind::AllowedIps, false), + AddressAuthorization::Cedar(cedar) => ( + DestinationDenialKind::CedarAddress, + cedar_blocks_control_plane(cedar, host), + ), }; if control_plane_blocked && BLOCKED_CONTROL_PLANE_PORTS.contains(&port) { @@ -248,6 +420,7 @@ pub(crate) fn filter_resolved_addresses( AddressAuthorization::PinnedResolved(pinned) if !pinned.contains(&ip) => Some(format!( "{host} resolves to unpinned address {ip}, connection rejected" )), + AddressAuthorization::Cedar(cedar) => cedar_address_rejection(cedar, host, ip), AddressAuthorization::DefaultPublicOnly | AddressAuthorization::ExactDeclaredHost | AddressAuthorization::PinnedResolved(_) => None, @@ -265,7 +438,7 @@ pub(crate) fn filter_resolved_addresses( first_rejection.unwrap_or_else(|| { format!( "DNS resolution returned no addresses for {}", - super::normalize_host_lookup_key(host) + normalize_host_lookup_key(host) ) }), )); @@ -377,6 +550,20 @@ pub(crate) async fn validate_destination( .copied() .map(|address| SocketAddr::new(address, port)) .collect(), + AddressAuthorization::Cedar(cedar) => { + let addrs = resolve_socket_addrs( + host, + port, + sandbox_entrypoint_pid, + cedar_address_binding(cedar), + ) + .await + .map_err(|reason| DestinationDenial::new(DestinationDenialKind::Resolution, reason))?; + validate_cedar_resolved_addrs(cedar, host, port, &addrs).map_err(|reason| { + DestinationDenial::new(DestinationDenialKind::CedarAddress, reason) + })?; + addrs + } }; Ok(UpstreamConnector::new(host, port, addrs)) @@ -444,6 +631,7 @@ mod tests { None, &["not-an-ip".to_string()], false, + None, ) .expect_err("invalid allowed_ips must be denied"); @@ -575,6 +763,350 @@ mod tests { assert!(denial.reason.contains("blocked control-plane port")); } + /// The destination rules of `binary`'s allowed Cedar connection. + fn cedar_destination(policy: &str, host: &str, port: u16) -> CedarDestination { + let engine = crate::cedar_only::CedarOnlyEngine::from_policy_str(policy) + .expect("Cedar policy loads"); + engine + .authorize_egress(&crate::opa::NetworkInput { + host: host.to_string(), + port, + binary_path: "/usr/bin/curl".into(), + binary_sha256: String::new(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }) + .expect("request evaluates") + .cedar_destination + .expect("the connection is allowed") + } + + fn addrs(ips: &[&str], port: u16) -> Vec { + ips.iter() + .map(|ip| SocketAddr::new(ip.parse().unwrap(), port)) + .collect() + } + + const HOSTLESS_8080: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.port == 8080 + && context has destination_ip + && context.destination_ip.isInRange(ip("10.0.0.0/8")) +}; +"#; + + #[test] + fn cedar_destination_replaces_the_yaml_modes() { + let cedar = cedar_destination(HOSTLESS_8080, "svc.example", 8080); + let plan = build_validation_plan( + "svc.example", + "svc.example", + None, + None, + &["192.168.0.0/16".to_string()], + true, + Some(&cedar), + ) + .unwrap(); + assert_eq!( + plan.address_authorization, + AddressAuthorization::Cedar(Box::new(cedar)) + ); + } + + /// Sandbox hosts entries may take part only where YAML consults them: when + /// fixed address ranges check every resolved address. A permit that + /// authorizes by name alone, exact or glob, resolves through the + /// supervisor resolver, as a YAML declared or wildcard host does. + #[test] + fn cedar_address_binding_follows_the_yaml_modes() { + let constrained = cedar_destination(HOSTLESS_8080, "svc.example", 8080); + assert_eq!( + cedar_address_binding(&constrained), + AddressBinding::IpConstrained + ); + + let exact = cedar_destination( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example:443");"#, + "db.example", + 443, + ); + assert_eq!( + cedar_address_binding(&exact), + AddressBinding::NameAuthorized + ); + + let glob = cedar_destination( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) + when { resource.host like("*.example.com", ".") && resource.port == 443 };"#, + "api.example.com", + 443, + ); + assert_eq!(cedar_address_binding(&glob), AddressBinding::NameAuthorized); + } + + #[test] + fn cedar_must_allow_the_host_gateway_address() { + let trusted_ip = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); + let alias = "host.openshell.internal"; + let cedar = cedar_destination(HOSTLESS_8080, alias, 8080); + let denial = build_validation_plan( + alias, + alias, + None, + Some(trusted_ip), + &[], + false, + Some(&cedar), + ) + .expect_err("the gateway address is outside the permit's range"); + assert_eq!(denial.kind, DestinationDenialKind::CedarAddress); + + let named = cedar_destination( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"host.openshell.internal:8080");"#, + alias, + 8080, + ); + let plan = build_validation_plan( + alias, + alias, + None, + Some(trusted_ip), + &[], + false, + Some(&named), + ) + .unwrap(); + assert_eq!( + plan.address_authorization, + AddressAuthorization::TrustedGatewayAlias { + expected_ip: trusted_ip + } + ); + } + + #[test] + fn cedar_forbids_reading_the_address_reject_resolved_addresses() { + let policy = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example:443"); +forbid (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { context has destination_ip && context.destination_ip.isInRange(ip("10.9.0.0/16")) }; +"#; + let cedar = cedar_destination(policy, "db.example", 443); + assert!( + validate_cedar_resolved_addrs(&cedar, "db.example", 443, &addrs(&["10.8.1.1"], 443)) + .is_ok() + ); + let error = validate_cedar_resolved_addrs( + &cedar, + "db.example", + 443, + &addrs(&["10.8.1.1", "10.9.1.1"], 443), + ) + .unwrap_err(); + assert!(error.contains("Cedar policy denies"), "{error}"); + + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::Cedar(Box::new(cedar)), + }; + let kept = filter_resolved_addresses( + &plan, + "db.example", + 443, + &["10.9.1.1".parse().unwrap(), "10.8.1.1".parse().unwrap()], + ) + .unwrap(); + assert_eq!(kept, ["10.8.1.1".parse::().unwrap()]); + } + + #[test] + fn cedar_blocks_control_plane_ports_where_yaml_does() { + let glob = cedar_destination( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", resource) + when { resource.host like("*.example.com", ".") && resource.port == 6443 };"#, + "api.example.com", + 6443, + ); + assert!( + validate_cedar_resolved_addrs( + &glob, + "api.example.com", + 6443, + &addrs(&["8.8.8.8"], 6443) + ) + .is_ok(), + "a public-only connection may use a control-plane port, as in YAML" + ); + let exact = cedar_destination( + r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.example.com:6443");"#, + "api.example.com", + 6443, + ); + let error = validate_cedar_resolved_addrs( + &exact, + "api.example.com", + 6443, + &addrs(&["8.8.8.8"], 6443), + ) + .unwrap_err(); + assert!(error.contains("control-plane port"), "{error}"); + } + + #[test] + fn cedar_admits_private_addresses_only_through_ranges_or_exact_hosts() { + let policy = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.corp.example", ".") && resource.port == 443 }; +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { + resource.port == 443 + && context has destination_ip + && context.destination_ip.isInRange(ip("10.0.0.0/8")) +}; +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"named.corp.example:443"); +"#; + let private = addrs(&["10.1.1.1"], 443); + // A glob alone is public-only, so a range cannot widen it. + let glob = cedar_destination(policy, "svc.corp.example", 443); + assert!(validate_cedar_resolved_addrs(&glob, "svc.corp.example", 443, &private).is_err()); + // Only ranged permits allow this host: their range decides. + let ranged = cedar_destination(policy, "other.example", 443); + assert!(validate_cedar_resolved_addrs(&ranged, "other.example", 443, &private).is_ok()); + // An exactly named host may resolve privately, within the range. + let named = cedar_destination(policy, "named.corp.example", 443); + assert!(validate_cedar_resolved_addrs(&named, "named.corp.example", 443, &private).is_ok()); + assert!( + validate_cedar_resolved_addrs( + &named, + "named.corp.example", + 443, + &addrs(&["192.168.1.1"], 443) + ) + .is_err(), + "every ranged permit that allows the connection must admit the address" + ); + } + + /// The destination rules of curl's allowed Cedar connection to + /// `host:port` on a sandbox with one provider endpoint for `host:port` + /// whose `allowed_ips` are `ranges`. + fn cedar_destination_with_provider( + policy: &str, + host: &str, + port: u16, + ranges: &[&str], + ) -> CedarDestination { + let mut proto = openshell_core::proto::SandboxPolicy { + cedar_policy_source: policy.to_string(), + ..Default::default() + }; + proto.provider_credential_rules.insert( + "_provider_corp".to_string(), + openshell_core::proto::NetworkPolicyRule { + name: "_provider_corp".to_string(), + endpoints: vec![openshell_core::proto::NetworkEndpoint { + host: host.to_string(), + port: u32::from(port), + allowed_ips: ranges.iter().map(ToString::to_string).collect(), + provider_credentialed: true, + ..Default::default() + }], + ..Default::default() + }, + ); + crate::cedar_only::CedarOnlyEngine::from_proto(&proto) + .expect("Cedar policy loads") + .authorize_egress(&crate::opa::NetworkInput { + host: host.to_string(), + port, + binary_path: "/usr/bin/curl".into(), + binary_sha256: String::new(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }) + .expect("request evaluates") + .cedar_destination + .expect("the connection is allowed") + } + + const EXACT_CORP: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.corp.example:443"); +"#; + + /// Regression test: a credentialed provider endpoint's `allowed_ips` + /// keep restricting the addresses its connections reach. + #[test] + fn provider_ranges_narrow_the_addresses_cedar_admits() { + let host = "api.corp.example"; + let unranged = cedar_destination(EXACT_CORP, host, 443); + assert!( + validate_cedar_resolved_addrs(&unranged, host, 443, &addrs(&["10.6.0.1"], 443)).is_ok(), + "Cedar alone admits a private address for an exactly named host" + ); + + let ranged = cedar_destination_with_provider(EXACT_CORP, host, 443, &["10.5.0.0/16"]); + assert!( + validate_cedar_resolved_addrs(&ranged, host, 443, &addrs(&["10.5.0.1"], 443)).is_ok() + ); + for outside in [&["10.6.0.1"][..], &["8.8.8.8"], &["10.5.0.1", "10.6.0.1"]] { + let error = validate_cedar_resolved_addrs(&ranged, host, 443, &addrs(outside, 443)) + .unwrap_err(); + assert!( + error.contains("provider endpoint's allowed_ips"), + "{outside:?}: {error}" + ); + } + + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::Cedar(Box::new(ranged)), + }; + let kept = filter_resolved_addresses( + &plan, + host, + 443, + &["10.6.0.1".parse().unwrap(), "10.5.0.1".parse().unwrap()], + ) + .unwrap(); + assert_eq!(kept, ["10.5.0.1".parse::().unwrap()]); + } + + /// Provider ranges never admit a private address Cedar's own rules + /// reject, and block control-plane ports as YAML `allowed_ips` do. + #[test] + fn provider_ranges_never_widen_address_admission() { + let host = "api.corp.example"; + let glob = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.corp.example", ".") && (resource.port == 443 || resource.port == 6443) }; +"#; + let ranged = cedar_destination_with_provider(glob, host, 443, &["10.5.0.0/16"]); + let error = validate_cedar_resolved_addrs(&ranged, host, 443, &addrs(&["10.5.0.1"], 443)) + .unwrap_err(); + assert!(error.contains("internal address"), "{error}"); + + let control_plane = cedar_destination_with_provider(glob, host, 6443, &["8.8.8.0/24"]); + let error = + validate_cedar_resolved_addrs(&control_plane, host, 6443, &addrs(&["8.8.8.8"], 6443)) + .unwrap_err(); + assert!(error.contains("control-plane port"), "{error}"); + } + + #[test] + fn invalid_provider_ranges_reject_every_address() { + let host = "api.corp.example"; + let invalid = cedar_destination_with_provider(EXACT_CORP, host, 443, &["not-an-ip"]); + let error = validate_cedar_resolved_addrs(&invalid, host, 443, &addrs(&["8.8.8.8"], 443)) + .unwrap_err(); + assert!(error.contains("invalid CIDR/IP"), "{error}"); + } + #[test] fn validation_mode_precedence_is_explicit_and_stable() { let backend_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); @@ -586,6 +1118,7 @@ mod tests { Some(trusted_ip), &["10.0.0.0/8".to_string()], true, + None, ) .unwrap(); assert_eq!( @@ -600,6 +1133,7 @@ mod tests { Some(trusted_ip), &["10.0.0.0/8".to_string()], true, + None, ) .unwrap(); assert_eq!( @@ -616,6 +1150,7 @@ mod tests { None, &["10.0.0.0/8".to_string()], true, + None, ) .unwrap(); assert_eq!( @@ -624,23 +1159,37 @@ mod tests { ); let implicit = - build_validation_plan("10.2.3.4", "10.2.3.4", None, None, &[], true).unwrap(); + build_validation_plan("10.2.3.4", "10.2.3.4", None, None, &[], true, None).unwrap(); assert_eq!( implicit.address_authorization, AddressAuthorization::ImplicitIpLiteral("10.2.3.4".parse().unwrap()) ); - let declared = - build_validation_plan("private.example", "private.example", None, None, &[], true) - .unwrap(); + let declared = build_validation_plan( + "private.example", + "private.example", + None, + None, + &[], + true, + None, + ) + .unwrap(); assert_eq!( declared.address_authorization, AddressAuthorization::ExactDeclaredHost ); - let default = - build_validation_plan("*.example.com", "*.example.com", None, None, &[], false) - .unwrap(); + let default = build_validation_plan( + "*.example.com", + "*.example.com", + None, + None, + &[], + false, + None, + ) + .unwrap(); assert_eq!( default.address_authorization, AddressAuthorization::DefaultPublicOnly diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index 77b89a9b7e..e863924e86 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -37,13 +37,15 @@ pub(super) struct EndpointDecision { pub(super) l7_route: Option, /// Destination authorization selected from the captured endpoint metadata. pub(super) destination: Option, - /// Raw endpoint configs returned with the authoritative egress decision. - pub(super) policy_configs: Vec, + /// Endpoint configs returned with the authoritative egress decision. + pub(super) policy_configs: Vec, /// Full endpoint identities and metadata captured in the same generation. #[allow(dead_code, reason = "consumed when the policy DNS adapter lands")] pub(super) matched_endpoints: Vec, /// Whether policy matched the requested hostname exactly (not by glob). pub(super) exact_declared_host: bool, + /// For a Cedar sandbox, how resolved addresses are checked. + pub(super) cedar_destination: Option, } impl Default for EndpointDecision { @@ -55,6 +57,7 @@ impl Default for EndpointDecision { policy_configs: Vec::new(), matched_endpoints: Vec::new(), exact_declared_host: false, + cedar_destination: None, } } } @@ -65,6 +68,7 @@ impl EndpointDecision { policy_configs: authorization.endpoint_configs.clone(), matched_endpoints: authorization.matched_endpoints.clone(), exact_declared_host: authorization.exact_declared_endpoint_host, + cedar_destination: authorization.cedar_destination.clone(), ..Self::default() } } @@ -156,6 +160,8 @@ pub(super) struct EgressDecision { pub(super) action: NetworkAction, /// Policy generation used for the complete authorization snapshot. pub(super) policy_generation: u64, + /// Engine that made the decision (`opa` or `cedar`), for OCSF events. + pub(super) engine: &'static str, /// Whether process identity evidence was available to policy evaluation. pub(super) identity: ProcessIdentityEvidence, /// Endpoint behavior hydrated for destination validation and relays. @@ -248,6 +254,7 @@ mod tests { intent, action, policy_generation: 1, + engine: "opa", identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::default(), binary: None, diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 363ef8868d..d405498536 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -5,7 +5,8 @@ use super::{EgressDecision, L7RouteSnapshot, emit_l7_tunnel_close_after_policy_change}; use crate::l7::relay::L7EvalContext; -use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard, TunnelPolicyEngine}; +use crate::policy_engine::{PassthroughMiddleware, PolicyEngine, TunnelPolicyEngine}; +use crate::policy_types::{NetworkAction, PolicyGenerationGuard}; use miette::{IntoDiagnostic, Result}; use openshell_core::activity::ActivitySender; use openshell_core::endpoint_status::EndpointObservationSender; @@ -13,25 +14,29 @@ use openshell_core::secrets::SecretResolver; use std::sync::Arc; use tokio::io::{AsyncRead, AsyncWrite}; -enum PreparedHttpPolicy { +enum PreparedHttpPolicy<'a> { Inspect { configs: Vec, evaluator: Box, }, Passthrough { generation_guard: PolicyGenerationGuard, + /// The middleware the tunnel selects from; see + /// [`PolicyEngine::passthrough_middleware`]. + middleware: PassthroughMiddleware<'a>, }, } /// Everything an HTTP relay needs after authorization is complete. /// -/// The relay deliberately owns a generation-pinned policy primitive instead -/// of retaining access to the mutable OPA engine. Policy reloads therefore -/// fail closed through the guard or tunnel evaluator already attached here. +/// The relay deliberately owns generation-pinned policy primitives instead +/// of retaining access to the mutable policy engine for decisions. Policy +/// reloads therefore fail closed through the guard or tunnel evaluator +/// already attached here, and a passthrough tunnel selects middleware from +/// what it was given when it started. pub(super) struct RelayContext<'a> { request: &'a L7EvalContext, - policy: PreparedHttpPolicy, - middleware_engine: &'a OpaEngine, + policy: PreparedHttpPolicy<'a>, } /// Non-blocking observation channels attached to an authorized HTTP relay. @@ -101,18 +106,21 @@ pub(super) fn http_context( /// Pin a generation for a relay or the forward HTTP single-request path. pub(super) fn pin_policy_generation( - opa_engine: &OpaEngine, + network_engine: &PolicyEngine, expected_generation: u64, ) -> Result { - opa_engine.generation_guard(expected_generation) + network_engine.generation_guard(expected_generation) } -/// Clone an L7 evaluator for a relay or the forward HTTP single-request path. +/// Build the L7 evaluator for a relay or the forward HTTP single-request path. +/// +/// `network_engine` pins the tunnel to the generation of the decision that +/// allowed it and supplies its middleware. See [`PolicyEngine::tunnel_engine`]. pub(super) fn pin_l7_evaluator( - opa_engine: &OpaEngine, + network_engine: &PolicyEngine, expected_generation: u64, ) -> Result { - opa_engine.clone_engine_for_tunnel(expected_generation) + network_engine.tunnel_engine(expected_generation) } pub(super) fn validate_route_generation( @@ -139,7 +147,7 @@ pub(super) fn validate_route_generation( /// attempting to write an HTTP response into it. pub(super) fn prepare_http_relay<'a>( route: Option<&L7RouteSnapshot>, - opa_engine: &'a OpaEngine, + network_engine: &'a PolicyEngine, decision: &EgressDecision, request: &'a L7EvalContext, ) -> Option> { @@ -153,7 +161,7 @@ pub(super) fn prepare_http_relay<'a>( } let policy = if let Some(route) = route.filter(|route| !route.configs.is_empty()) { - let evaluator = match pin_l7_evaluator(opa_engine, decision.policy_generation) { + let evaluator = match pin_l7_evaluator(network_engine, decision.policy_generation) { Ok(evaluator) => evaluator, Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -174,8 +182,20 @@ pub(super) fn prepare_http_relay<'a>( evaluator: Box::new(evaluator), } } else { - let generation_guard = match pin_policy_generation(opa_engine, decision.policy_generation) { - Ok(guard) => guard, + let generation_guard = + match pin_policy_generation(network_engine, decision.policy_generation) { + Ok(guard) => guard, + Err(error) => { + emit_l7_tunnel_close_after_policy_change( + &decision.intent.destination.host, + decision.intent.destination.port, + error, + ); + return None; + } + }; + let middleware = match network_engine.passthrough_middleware() { + Ok(middleware) => middleware, Err(error) => { emit_l7_tunnel_close_after_policy_change( &decision.intent.destination.host, @@ -185,14 +205,13 @@ pub(super) fn prepare_http_relay<'a>( return None; } }; - PreparedHttpPolicy::Passthrough { generation_guard } + PreparedHttpPolicy::Passthrough { + generation_guard, + middleware, + } }; - Some(RelayContext { - request, - policy, - middleware_engine: opa_engine, - }) + Some(RelayContext { request, policy }) } /// Pin the generation used by a raw relay so policy activation or quarantine @@ -200,7 +219,7 @@ pub(super) fn prepare_http_relay<'a>( /// a stale decision. pub(super) fn prepare_raw_relay( route: Option<&L7RouteSnapshot>, - opa_engine: &OpaEngine, + network_engine: &PolicyEngine, decision: &EgressDecision, ) -> Option { if let Err(error) = validate_route_generation(route, decision.policy_generation) { @@ -212,7 +231,7 @@ pub(super) fn prepare_raw_relay( return None; } - match pin_policy_generation(opa_engine, decision.policy_generation) { + match pin_policy_generation(network_engine, decision.policy_generation) { Ok(guard) => Some(guard), Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -272,14 +291,17 @@ where } } } - PreparedHttpPolicy::Passthrough { generation_guard } => { + PreparedHttpPolicy::Passthrough { + generation_guard, + middleware, + } => { tokio::select! { result = crate::l7::relay::relay_passthrough_with_credentials( client, upstream, context.request, &generation_guard, - Some(context.middleware_engine), + Some(&middleware), ) => result, () = generation_guard.wait_until_stale() => { emit_stale_relay_close(context.request, &generation_guard); @@ -297,7 +319,9 @@ pub async fn relay_inspected_http_stream_for_test( upstream: &mut U, configs: Vec, evaluator: TunnelPolicyEngine, - middleware_engine: &OpaEngine, + // An inspected tunnel selects middleware from `evaluator`'s snapshot; + // kept so fixtures build the engine and the evaluator the same way. + _middleware_engine: &PolicyEngine, request: &L7EvalContext, ) -> Result<()> where @@ -313,7 +337,6 @@ where configs, evaluator: Box::new(evaluator), }, - middleware_engine, }, ) .await @@ -357,6 +380,7 @@ fn emit_stale_relay_close(request: &L7EvalContext, guard: &PolicyGenerationGuard mod tests { use super::super::{EgressIntent, EndpointDecision, ProcessIdentityEvidence}; use super::*; + use crate::opa::OpaEngine; const POLICY_REGO: &str = include_str!("../../data/sandbox-policy.rego"); const EMPTY_POLICY_DATA: &str = "network_policies: {}\n"; @@ -368,6 +392,7 @@ mod tests { matched_policy: Some("test".to_string()), }, policy_generation, + engine: "opa", identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::default(), binary: None, @@ -407,10 +432,11 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let (mut upstream, mut server) = tokio::io::duplex(1024); - let engine = OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); + let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap()); + let network_engine = PolicyEngine::from(Arc::clone(&engine)); let decision = decision(engine.current_generation()); let request = request_context(); - let context = prepare_http_relay(None, &engine, &decision, &request).unwrap(); + let context = prepare_http_relay(None, &network_engine, &decision, &request).unwrap(); let persistent = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; // Larger than either transport buffer: closing must drain the body. let body = vec![b'x'; 64 * 1024]; @@ -496,13 +522,17 @@ mod tests { #[test] fn relay_without_route_pins_l4_decision_generation() { - let engine = OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); + let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap()); + let policy = PolicyEngine::from(Arc::clone(&engine)); let decision = decision(engine.current_generation()); let request = request_context(); - let context = prepare_http_relay(None, &engine, &decision, &request) + let context = prepare_http_relay(None, &policy, &decision, &request) .expect("current L4 generation should prepare a relay"); - let PreparedHttpPolicy::Passthrough { generation_guard } = context.policy else { + let PreparedHttpPolicy::Passthrough { + generation_guard, .. + } = context.policy + else { panic!("route-less relay should use a generation guard"); }; @@ -514,7 +544,8 @@ mod tests { #[test] fn empty_hydrated_route_cannot_replace_stale_l4_generation() { - let engine = OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); + let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap()); + let policy = PolicyEngine::from(Arc::clone(&engine)); let decision = decision(u64::MAX); let route = L7RouteSnapshot { configs: vec![], @@ -523,14 +554,15 @@ mod tests { let request = request_context(); assert!( - prepare_http_relay(Some(&route), &engine, &decision, &request).is_none(), + prepare_http_relay(Some(&route), &policy, &decision, &request).is_none(), "a current L7 lookup must not freshen a stale L4 allow" ); } #[test] fn inspected_route_cannot_replace_stale_l4_generation() { - let engine = OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); + let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap()); + let policy = PolicyEngine::from(Arc::clone(&engine)); let decision = decision(u64::MAX); let route = L7RouteSnapshot { configs: vec![super::super::L7ConfigSnapshot { @@ -561,14 +593,15 @@ mod tests { let request = request_context(); assert!( - prepare_http_relay(Some(&route), &engine, &decision, &request).is_none(), + prepare_http_relay(Some(&route), &policy, &decision, &request).is_none(), "an inspected route must use the generation that authorized CONNECT" ); } #[test] fn raw_route_cannot_replace_stale_l4_generation() { - let engine = OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); + let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap()); + let policy = PolicyEngine::from(Arc::clone(&engine)); let decision = decision(u64::MAX); let route = L7RouteSnapshot { configs: vec![], @@ -576,20 +609,21 @@ mod tests { }; assert!( - prepare_raw_relay(Some(&route), &engine, &decision).is_none(), + prepare_raw_relay(Some(&route), &policy, &decision).is_none(), "a raw relay must not freshen a stale L4 allow" ); } #[test] fn stale_generation_fails_before_relay_context_is_created() { - let engine = OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); + let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap()); + let policy = PolicyEngine::from(Arc::clone(&engine)); let decision = decision(engine.current_generation()); let request = request_context(); engine.reload(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); assert!( - prepare_http_relay(None, &engine, &decision, &request).is_none(), + prepare_http_relay(None, &policy, &decision, &request).is_none(), "policy reload must prevent a stale relay from starting" ); } diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 010cb466c5..38189f780d 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -14,6 +14,7 @@ fn allowed_decision(intent: EgressIntent) -> EgressDecision { matched_policy: Some("proxy_compatibility".to_string()), }, policy_generation: 0, + engine: "opa", identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::default(), binary: Some(PathBuf::from("/usr/bin/curl")), @@ -234,6 +235,7 @@ fn representative_adapter_allows_preserve_ocsf_fields() { "curl --proxy", "proxy_compatibility", true, + "opa", )) .unwrap(); assert_eq!(connect["class_name"], "Network Activity"); @@ -260,6 +262,7 @@ fn representative_adapter_allows_preserve_ocsf_fields() { "/usr/bin/sh", "curl --proxy", "proxy_compatibility", + "opa", )) .unwrap(); assert_eq!(forward["class_name"], "HTTP Activity"); @@ -327,7 +330,7 @@ network_policies: // combined authorization query must deny rather than preserve the old // multi-query behavior that could fall back to an L4-only allow. let decision = evaluate_endpoint_only_opa( - &engine, + &PolicyEngine::from(Arc::new(engine)), EgressIntent::connect("sub\0.example.com".to_string(), 443), ); @@ -392,7 +395,7 @@ fn identity_required_mode_is_explicitly_unsupported_off_linux() { "127.0.0.1:41000".parse().unwrap(), "127.0.0.1:3000".parse().unwrap(), ), - &engine, + &PolicyEngine::from(Arc::new(engine)), &BinaryIdentityCache::new(), &AtomicU32::new(1), EgressIntent::connect("target.example".to_string(), 443), diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 51418d62e4..03385abfb4 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -34,7 +34,6 @@ use crate::l7::tls::{ CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, write_ca_files, }; -use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::ProxyHandle; use openshell_core::endpoint_status::EndpointObservationSender; @@ -195,14 +194,13 @@ pub struct Networking { /// /// # Errors /// -/// Returns an error if proxy mode is requested but the proxy configuration, -/// OPA engine, or identity cache is missing, or if the proxy server fails to -/// start. +/// Returns an error if proxy mode is requested but the proxy configuration +/// or policy engine is missing, or if the proxy server fails to start. #[allow(clippy::too_many_arguments)] pub async fn run_networking( policy: &SandboxPolicy, proxy_bind_ip: Option, - opa_engine: Option<&Arc>, + network_engine: Option<&crate::policy_engine::PolicyEngine>, retained_proto: Option<&ProtoSandboxPolicy>, entrypoint_pid: Arc, process_enabled: bool, @@ -222,6 +220,11 @@ pub async fn run_networking( network_mediation_source: Option>, proxy_listener: Option, ) -> Result { + // The authoritative engine: Cedar for a Cedar-authored policy, otherwise + // OPA. It also supplies middleware and per-tunnel evaluators; see + // `crate::policy_engine`. + let network_engine = network_engine.cloned(); + // Build the policy-local route context. The orchestrator's policy poll // loop also holds an `Arc` clone (via `Networking::policy_local_ctx`) so // it can publish updated policy snapshots after a successful reload. @@ -239,7 +242,7 @@ pub async fn run_networking( // listener immediately (so the OS backlog queues early SYN packets) but // defers `accept()` until symlink resolution completes. This eliminates // the race where an in-flight request observes a generation transition - // during the OPA engine reload. + // during the policy engine's symlink resolution. let (engine_ready_tx, engine_ready_rx) = tokio::sync::watch::channel(false); #[cfg(target_os = "linux")] let transparent_engine_ready_rx = engine_ready_rx.clone(); @@ -250,7 +253,7 @@ pub async fn run_networking( // namespace becomes accessible via /proc//root/. The task starts // before run_process spawns the child, so first wait for the orchestrator // to publish a non-zero PID, then poll for proc-root readiness. - if let (Some(engine), Some(proto)) = (opa_engine, retained_proto) { + if let (Some(engine), Some(proto)) = (network_engine.as_ref(), retained_proto) { if process_enabled { let resolve_engine = engine.clone(); let resolve_proto = proto.clone(); @@ -291,21 +294,7 @@ pub async fn run_networking( attempt = attempt, "Container filesystem accessible, resolving policy binary symlinks" ); - match resolve_engine.reload_from_proto_with_pid(&resolve_proto, pid) { - Ok(_) => { - info!( - pid = pid, - "Policy binary symlink resolution complete \ - (check logs above for per-binary results)" - ); - } - Err(e) => { - warn!( - "Failed to rebuild OPA engine with symlink resolution \ - (non-fatal, falling back to literal path matching): {e}" - ); - } - } + resolve_policy_binary_symlinks(&resolve_engine, &resolve_proto, pid); let _ = engine_ready_tx.send(true); return; } @@ -333,9 +322,11 @@ pub async fn run_networking( let _ = engine_ready_tx.send(true); } - // Identity cache for SHA256 TOFU when OPA is active. Only consumed by - // the proxy, so it's owned here. - let identity_cache = opa_engine.map(|_| Arc::new(BinaryIdentityCache::new())); + // Identity cache for SHA256 TOFU when a policy engine is active. Only + // consumed by the proxy, so it's owned here. + let identity_cache = network_engine + .as_ref() + .map(|_| Arc::new(BinaryIdentityCache::new())); // Load a provisioned CA when the boundary lifetime outlives this control // process; otherwise generate an ephemeral CA. @@ -455,9 +446,9 @@ pub async fn run_networking( }; let mediated_policy_dns = if let Some(source) = network_mediation_source.clone() { - let engine = opa_engine - .cloned() - .ok_or_else(|| miette::miette!("Mediated DNS requires an OPA engine"))?; + let engine = network_engine.clone().ok_or_else(|| { + miette::miette!("Mediated DNS requires a network policy engine (OPA or Cedar)") + })?; Some(crate::policy_dns::PolicyDnsRuntime::start_mediated( engine, source, @@ -474,12 +465,10 @@ pub async fn run_networking( miette::miette!("Network mode is set to proxy but no proxy configuration was provided") })?; - let engine = opa_engine.cloned().ok_or_else(|| { - miette::miette!("Proxy mode requires an OPA engine (--rego-policy and --rego-data)") - })?; - let cache = identity_cache.clone().ok_or_else(|| { - miette::miette!("Proxy mode requires an identity cache (OPA engine must be configured)") + miette::miette!( + "Proxy mode requires an identity cache (a policy engine must be configured)" + ) })?; // If the orchestrator gave us a proxy bind IP (the host-side veth IP @@ -497,10 +486,14 @@ pub async fn run_networking( }) }); + let active_network_engine = network_engine.clone().ok_or_else(|| { + miette::miette!("Proxy mode requires a network policy engine (OPA or Cedar)") + })?; + let proxy_handle = ProxyHandle::start_with_bind_addr( proxy_policy, bind_addr, - engine, + active_network_engine, cache, entrypoint_pid.clone(), tls_state, @@ -531,15 +524,15 @@ pub async fn run_networking( #[cfg(target_os = "linux")] let (policy_dns, transparent_tcp) = if let Some(runtime) = transparent_runtime { - let engine = opa_engine - .cloned() - .ok_or_else(|| miette::miette!("transparent TCP requires an OPA policy engine"))?; + let active_network_engine = network_engine.clone().ok_or_else(|| { + miette::miette!("transparent TCP requires a network policy engine (OPA or Cedar)") + })?; let cache = identity_cache .clone() .ok_or_else(|| miette::miette!("transparent TCP requires a process identity cache"))?; let trusted_gateway = crate::proxy::detect_trusted_host_gateway(); let dns = crate::policy_dns::PolicyDnsRuntime::start( - engine.clone(), + active_network_engine.clone(), runtime.dns_udp, runtime.dns_tcp, trusted_gateway, @@ -549,7 +542,7 @@ pub async fn run_networking( let transparent = crate::proxy::TransparentTcpHandle::start( runtime.listeners, dns.store.clone(), - engine, + active_network_engine, cache, entrypoint_pid, agent_proposals, @@ -575,6 +568,55 @@ pub async fn run_networking( }) } +/// Resolves the policy's binary symlinks once the entrypoint process is known. +/// +/// A YAML sandbox rebuilds its OPA engine from `proto` with symlink +/// resolution. A Cedar sandbox resolves the binary paths its policies test in +/// `context.binary_aliases`; it builds no OPA engine. Failures are non-fatal: +/// binary paths are then matched literally. +fn resolve_policy_binary_symlinks( + engine: &crate::policy_engine::PolicyEngine, + proto: &ProtoSandboxPolicy, + pid: u32, +) { + match engine { + crate::policy_engine::PolicyEngine::Opa(opa) => { + match opa.reload_from_proto_with_pid(proto, pid) { + Ok(_) => { + info!( + pid = pid, + "Policy binary symlink resolution complete \ + (check logs above for per-binary results)" + ); + } + Err(e) => { + warn!( + "Failed to rebuild OPA engine with symlink resolution \ + (non-fatal, falling back to literal path matching): {e}" + ); + } + } + } + crate::policy_engine::PolicyEngine::Cedar(cedar) => { + match cedar.resolve_binary_symlinks(pid) { + Ok(()) => { + info!( + pid = pid, + "Policy binary symlink resolution complete \ + (check logs above for per-binary results)" + ); + } + Err(e) => { + warn!( + "Failed to resolve Cedar policy binary symlinks \ + (non-fatal, binary_aliases stays empty): {e}" + ); + } + } + } + } +} + #[cfg(all(test, target_os = "linux"))] mod transparent_runtime_tests { use super::*; diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index baa918bd47..3a3fbd731e 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -378,7 +378,10 @@ use openshell_core::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPol use openshell_core::proposals::AgentProposals; use openshell_core::proto::ProviderReadinessReason; use openshell_core::provider_credentials::ProviderCredentialState; -use openshell_supervisor_network::opa::{OpaEngine, PolicyGenerationGuard}; +use openshell_supervisor_network::cedar_only::CedarOnlyEngine; +use openshell_supervisor_network::opa::OpaEngine; +use openshell_supervisor_network::policy_engine::PolicyEngine; +use openshell_supervisor_network::policy_types::PolicyGenerationGuard; use openshell_supervisor_network::proxy::ProxyHandle; use openshell_supervisor_process::skills; use provider_readiness::{EnvironmentIdentity, Tracker as ProviderReadinessTracker}; @@ -573,7 +576,12 @@ pub async fn run_network_proxy( } let extension_credentials = openshell_extension_core::ExtensionCredentialStore::new(); - let (mut policy, opa_engine, _, _, _, initial_agent_proposals_enabled, _, _) = load_policy( + let LoadedPolicy { + mut policy, + network_engine, + agent_proposals_enabled: initial_agent_proposals_enabled, + .. + } = load_policy( None, None, None, @@ -601,7 +609,7 @@ pub async fn run_network_proxy( let mut networking = openshell_supervisor_network::run::run_networking( &policy, None, - opa_engine.as_ref(), + network_engine.as_ref(), None, Arc::new(AtomicU32::new(0)), false, @@ -865,16 +873,16 @@ async fn run_sandbox_with_backend( // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); - let ( + let LoadedPolicy { policy, - opa_engine, - retained_proto, + network_engine, + proto: retained_proto, middleware_registry_status, - loaded_policy_origin, - initial_agent_proposals_enabled, - initial_extension_authentication_enabled, + origin: loaded_policy_origin, + agent_proposals_enabled: initial_agent_proposals_enabled, + extension_authentication_enabled: initial_extension_authentication_enabled, captured_provider_environment, - ) = load_policy_with_gateway( + } = load_policy_with_gateway( sandbox_id.clone(), sandbox.clone(), openshell_endpoint.clone(), @@ -1088,7 +1096,7 @@ async fn run_sandbox_with_backend( openshell_supervisor_network::run::run_networking( &policy, None, - opa_engine.as_ref(), + network_engine.as_ref(), retained_proto.as_ref(), entrypoint_pid.clone(), // The sandbox supplies already-resolved identities across the @@ -1222,7 +1230,7 @@ async fn run_sandbox_with_backend( sandbox_id.as_deref(), sandbox.as_deref(), openshell_endpoint.as_deref(), - opa_engine.as_ref(), + network_engine.as_ref(), ) { let poll_id = id.to_string(); let poll_sandbox = sandbox.to_string(); @@ -1245,7 +1253,7 @@ async fn run_sandbox_with_backend( endpoint: poll_endpoint, sandbox_id: poll_id, sandbox: poll_sandbox, - opa_engine: poll_engine, + engine: poll_engine, loaded_policy_origin, vm_identity: vm_policy_identity, entrypoint_pid: poll_pid, @@ -2271,6 +2279,21 @@ impl CapturedProviderEnvironment { } } +/// Policy state produced by [`load_policy`] at supervisor startup. +struct LoadedPolicy { + policy: SandboxPolicy, + /// The authoritative network policy engine: OPA for a YAML policy, + /// Cedar for a Cedar-authored one. A Cedar sandbox builds no OPA engine. + network_engine: Option, + /// The gateway-delivered policy, retained for reload comparison. + proto: Option, + middleware_registry_status: MiddlewareRegistryStatus, + origin: LoadedPolicyOrigin, + agent_proposals_enabled: bool, + extension_authentication_enabled: bool, + captured_provider_environment: Option, +} + async fn load_policy( sandbox_id: Option, sandbox: Option, @@ -2279,16 +2302,7 @@ async fn load_policy( policy_data: Option, extension_credentials: &openshell_extension_core::ExtensionCredentialStore, local_policy_identity: LocalPolicyIdentity, -) -> Result<( - SandboxPolicy, - Option>, - Option, - MiddlewareRegistryStatus, - LoadedPolicyOrigin, - bool, - bool, - Option, -)> { +) -> Result { load_policy_with_gateway( sandbox_id, sandbox, @@ -2321,16 +2335,7 @@ async fn load_policy_with_gateway( vm_identity: Option, image_discovery: Option, gateway: &impl StartupGateway, -) -> Result<( - SandboxPolicy, - Option>, - Option, - MiddlewareRegistryStatus, - LoadedPolicyOrigin, - bool, - bool, - Option, -)> { +) -> Result { use openshell_core::proto::ConfigurationAdmissionState; // File mode: load OPA engine from rego rules + YAML data (dev override) if let (Some(policy_file), Some(data_file)) = (&policy_rules, &policy_data) { @@ -2387,16 +2392,16 @@ async fn load_policy_with_gateway( }; enrich_sandbox_baseline_paths(&mut policy); // File mode has no operator-registered middleware to connect. - return Ok(( + return Ok(LoadedPolicy { policy, - Some(Arc::new(engine)), - None, - MiddlewareRegistryStatus::Synchronized, - LoadedPolicyOrigin::LocalOverride, - false, - false, - None, - )); + network_engine: Some(PolicyEngine::from(Arc::new(engine))), + proto: None, + middleware_registry_status: MiddlewareRegistryStatus::Synchronized, + origin: LoadedPolicyOrigin::LocalOverride, + agent_proposals_enabled: false, + extension_authentication_enabled: false, + captured_provider_environment: None, + }); } // gRPC mode: fetch typed proto policy, construct OPA engine from baked rules + proto data @@ -2537,7 +2542,11 @@ async fn load_policy_with_gateway( // a sandbox-sourced policy is written back. The gateway refuses // every sandbox policy write while a global policy is active, so a // global policy keeps the added paths in this process only. - let enriched = enrich_proto_baseline_paths(&mut proto_policy); + // A Cedar-authored policy's Landlock grants come from the Cedar + // text, not `filesystem`, so its baseline paths are added after + // extraction in `prepare_startup_configuration` instead. + let enriched = proto_policy.cedar_policy_source.is_empty() + && enrich_proto_baseline_paths(&mut proto_policy); let sync_policy = proto_sync_payload_for_enriched_policy(&proto_policy, enriched) .filter(|_| snapshot.policy_source == openshell_core::proto::PolicySource::Sandbox); if let Some(sync_policy) = sync_policy { @@ -2633,7 +2642,7 @@ async fn load_policy_with_gateway( .await; continue; } - debug!("Creating OPA engine from proto policy data"); + debug!("Creating policy engine from proto policy data"); let (engine, policy, captured_provider_credentials) = match prepare_startup_configuration(&snapshot, &proto_policy, &provider) { Ok(prepared) => prepared, @@ -2659,7 +2668,6 @@ async fn load_policy_with_gateway( continue; } }; - let engine = Arc::new(engine); // Install the in-process catalog before any external connection can // fail. A newly started sandbox must always be able to resolve built-in @@ -2722,7 +2730,7 @@ async fn load_policy_with_gateway( } else { MiddlewareRegistryStatus::Synchronized }; - let opa_engine = Some(engine); + let network_engine = Some(engine); // The gateway compares the entire tuple again. A concurrent repair or // provider rotation invalidates this candidate before any workload @@ -2756,22 +2764,22 @@ async fn load_policy_with_gateway( .build() ); } - return Ok(( + return Ok(LoadedPolicy { policy, - opa_engine, - Some(proto_policy), + network_engine, + proto: Some(proto_policy), middleware_registry_status, - LoadedPolicyOrigin::Gateway { + origin: LoadedPolicyOrigin::Gateway { revision: loaded_policy_revision, has_last_valid_policy, }, - agent_proposals_enabled_from_settings(&snapshot.settings), - snapshot.extension_authentication_enabled, - Some(CapturedProviderEnvironment::new( + agent_proposals_enabled: agent_proposals_enabled_from_settings(&snapshot.settings), + extension_authentication_enabled: snapshot.extension_authentication_enabled, + captured_provider_environment: Some(CapturedProviderEnvironment::new( captured_provider_credentials, &provider, )), - )); + }); } } @@ -2820,7 +2828,7 @@ fn prepare_startup_configuration( snapshot: &openshell_core::grpc_client::SettingsPollResult, policy: &openshell_core::proto::SandboxPolicy, provider: &openshell_core::grpc_client::ProviderEnvironmentResult, -) -> Result<(OpaEngine, SandboxPolicy, ProviderCredentialState)> { +) -> Result<(PolicyEngine, SandboxPolicy, ProviderCredentialState)> { if !snapshot.configuration_admitted { return Err(miette::miette!( "Effective configuration admission rejected" @@ -2836,8 +2844,43 @@ fn prepare_startup_configuration( "Provider environment revision changed during configuration preparation" )); } - let engine = OpaEngine::from_proto(policy)?; - let process_policy = SandboxPolicy::try_from(policy.clone())?; + // A non-empty `cedar_policy_source` makes Cedar the sole authoritative + // network, L7, middleware, and filesystem engine; no OPA engine is built + // (see `openshell_supervisor_network::policy_engine`). Either engine + // validates the policy as it builds, as a reload validates while staging. + let cedar_engine = if policy.cedar_policy_source.is_empty() { + None + } else { + Some(Arc::new(CedarOnlyEngine::from_proto(policy)?)) + }; + let engine = match &cedar_engine { + Some(cedar) => PolicyEngine::from(Arc::clone(cedar)), + None => PolicyEngine::from(Arc::new(OpaEngine::from_proto(policy)?)), + }; + let mut process_policy = SandboxPolicy::try_from(policy.clone())?; + if let Some(cedar) = &cedar_engine { + let grants = cedar.filesystem_grants()?; + process_policy.filesystem = openshell_core::policy::FilesystemPolicy { + read_only: grants + .read_only + .into_iter() + .map(std::path::PathBuf::from) + .collect(), + read_write: grants + .read_write + .into_iter() + .map(std::path::PathBuf::from) + .collect(), + include_workdir: false, + }; + // Same baseline a YAML policy gets, under the same condition: YAML + // adds it only when the policy has network rules + // (`enrich_proto_baseline_paths`), so Cedar adds it only when a + // policy can permit NetworkConnect. + if cedar.has_network_permits()? { + enrich_sandbox_baseline_paths(&mut process_policy); + } + } let credentials = prepare_provider_environment(provider)?; Ok((engine, process_policy, credentials)) } @@ -3069,14 +3112,14 @@ struct MiddlewareReloadContext<'a> { #[cfg(test)] async fn reload_gateway_policy_runtime( - engine: &OpaEngine, + engine: &Arc, policy: Option<&openshell_core::proto::SandboxPolicy>, entrypoint_pid: u32, middleware: MiddlewareReloadContext<'_>, transparent_tcp: TransparentTcpReloadState, ) -> std::result::Result { reload_gateway_configuration_runtime( - engine, + &PolicyEngine::from(Arc::clone(engine)), policy, entrypoint_pid, middleware, @@ -3087,8 +3130,12 @@ async fn reload_gateway_policy_runtime( .await } +#[allow( + clippy::too_many_arguments, + reason = "Each input is a distinct part of one atomic runtime reload" +)] async fn reload_gateway_configuration_runtime( - engine: &OpaEngine, + engine: &PolicyEngine, policy: Option<&openshell_core::proto::SandboxPolicy>, entrypoint_pid: u32, middleware: MiddlewareReloadContext<'_>, @@ -3121,40 +3168,91 @@ async fn reload_gateway_configuration_runtime( )); } } - match policy { - Some(policy) if middleware.registry_changed => { - let registry = (middleware.connector)( - middleware.desired_services.to_vec(), - middleware.authentication.clone(), - ) - .await - .map_err(GatewayRuntimeReloadError::MiddlewareRegistry)?; + // The authoritative engine is chosen once at startup from the policy + // format, so a reload cannot switch formats. Accepting one would leave + // the previous engine enforcing a stale policy while reporting success. + let Some(policy) = policy else { + return Err(GatewayRuntimeReloadError::PolicyValidation( + miette::miette!("runtime reload requires a policy payload but none was returned"), + )); + }; + match (engine, policy.cedar_policy_source.is_empty()) { + (PolicyEngine::Cedar(_), true) => { + return Err(GatewayRuntimeReloadError::PolicyValidation( + miette::miette!( + "candidate policy is YAML, but this sandbox enforces a Cedar policy; \ + a sandbox cannot switch policy formats; previous policy remains active" + ), + )); + } + (PolicyEngine::Opa(_), false) => { + return Err(GatewayRuntimeReloadError::PolicyValidation( + miette::miette!( + "candidate policy is Cedar, but this sandbox enforces a YAML policy; \ + a sandbox cannot switch policy formats; previous policy remains active" + ), + )); + } + _ => {} + } + match engine { + PolicyEngine::Opa(engine) => { + // Policy-only change: the installed registry already matches the + // delivered service set, so swap the engine alone. This must not + // require middleware reachability. + let registry = if middleware.registry_changed { + Some(connect_reload_middleware(&middleware).await?) + } else { + None + }; engine .reload_configuration_from_proto_with_pid( policy, entrypoint_pid, - Some(registry), + registry, commit_credentials, ) .map_err(GatewayRuntimeReloadError::PolicyValidation) } - // Policy-only change: the installed registry already matches the - // delivered service set, so swap the engine alone. This must not - // require middleware reachability. - Some(policy) => engine - .reload_configuration_from_proto_with_pid( - policy, - entrypoint_pid, - None, - commit_credentials, - ) - .map_err(GatewayRuntimeReloadError::PolicyValidation), - None => Err(GatewayRuntimeReloadError::PolicyValidation( - miette::miette!("runtime reload requires a policy payload but none was returned"), - )), + PolicyEngine::Cedar(cedar) => { + // Staging validates the candidate as startup does (the same + // validation a YAML policy's sections get, including middleware + // and endpoint settings, then Cedar's own load), so a rejected + // candidate leaves the policy, middleware, and credentials on the + // previous revision and is reported before any service connects. + let staged = cedar + .stage(policy, entrypoint_pid) + .map_err(GatewayRuntimeReloadError::PolicyValidation)?; + let registry = if middleware.registry_changed { + Some(connect_reload_middleware(&middleware).await?) + } else { + None + }; + // Existing guards become stale before credentials change, and the + // Cedar policy, middleware, and credentials publish together or + // not at all. + cedar + .commit_configuration(staged, registry, commit_credentials) + .map_err(GatewayRuntimeReloadError::PolicyValidation) + } } } +/// Connects the middleware services a reload delivers. +async fn connect_reload_middleware( + middleware: &MiddlewareReloadContext<'_>, +) -> std::result::Result< + openshell_supervisor_middleware::MiddlewareRegistry, + GatewayRuntimeReloadError, +> { + (middleware.connector)( + middleware.desired_services.to_vec(), + middleware.authentication.clone(), + ) + .await + .map_err(GatewayRuntimeReloadError::MiddlewareRegistry) +} + fn policy_contains_explicit_tcp(policy: &openshell_core::proto::SandboxPolicy) -> bool { policy.network_policies.values().any(|rule| { rule.endpoints @@ -3770,8 +3868,10 @@ struct PolicyPollLoopContext { sandbox_id: String, /// Canonical sandbox reference used by name-scoped configuration APIs. sandbox: String, - opa_engine: Arc, - /// Source of the policy currently loaded into OPA. This distinguishes an + /// The sandbox's authoritative policy engine, chosen once at startup + /// from the policy format (see `openshell_supervisor_network::policy_engine`). + engine: PolicyEngine, + /// Source of the policy currently loaded into the engine. This distinguishes an /// explicit local-file override from an unbound gateway revision so the /// former is never replaced by policy polling. loaded_policy_origin: LoadedPolicyOrigin, @@ -3848,13 +3948,13 @@ async fn connect_middleware_registry( } } -async fn install_builtin_middleware_registry(opa_engine: &OpaEngine) -> Result<()> { +async fn install_builtin_middleware_registry(engine: &PolicyEngine) -> Result<()> { let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( openshell_supervisor_middleware_builtins::services(), Vec::new(), ) .await?; - opa_engine.replace_middleware_registry(registry) + engine.replace_middleware_registry(registry) } /// Wait the configured poll interval, but never past the point at which an @@ -3901,7 +4001,7 @@ struct MiddlewareRegistryReconciliation<'a> { } async fn reconcile_middleware_registry( - opa_engine: &OpaEngine, + engine: &PolicyEngine, middleware_connector: &MiddlewareConnector, reconciliation: MiddlewareRegistryReconciliation<'_>, ) { @@ -3914,7 +4014,7 @@ async fn reconcile_middleware_registry( reconciliation.authentication.clone(), ) .await - .and_then(|registry| opa_engine.replace_middleware_registry(registry)) + .and_then(|registry| engine.replace_middleware_registry(registry)) { Ok(()) => { retain_extension_credentials( @@ -3993,7 +4093,7 @@ enum GatewayRuntimeFailureDisposition { } fn apply_gateway_runtime_reload_failure( - engine: &OpaEngine, + engine: &PolicyEngine, failure: GatewayRuntimeReloadError, configured_mode: PolicyValidationFailureMode, has_last_valid_policy: bool, @@ -4049,8 +4149,11 @@ fn emit_transparent_tcp_expansion_rejection( ); } +/// Quarantines or retains the policy on `engine`, the sandbox's +/// authoritative engine, so a rejected candidate never leaves network +/// decisions on a policy the operator chose not to keep. fn apply_policy_validation_failure( - engine: &OpaEngine, + engine: &PolicyEngine, configured_mode: PolicyValidationFailureMode, has_last_valid_policy: bool, version: u32, @@ -4276,10 +4379,7 @@ async fn run_policy_poll_loop_with_client( let mut pending_result = None; // Bind startup evidence before awaiting the gateway. A generation changed // during that request no longer proves which policy startup installed. - let initial_generation = ctx - .opa_engine - .generation_guard(ctx.opa_engine.current_generation()) - .ok(); + let initial_generation = ctx.engine.configuration_generation_guard().ok(); // Initialize revision from the first poll and acknowledge the initial // policy revision the supervisor actually loaded. A mismatched result is @@ -4415,7 +4515,7 @@ async fn run_policy_poll_loop_with_client( if reloads_gateway_policy && !result.configuration_admitted { let disposition = apply_policy_validation_failure( - &ctx.opa_engine, + &ctx.engine, result.policy_validation_failure_mode, has_last_valid_policy, result.version, @@ -4490,7 +4590,7 @@ async fn run_policy_poll_loop_with_client( // as one generation below. if !reloads_gateway_policy { reconcile_middleware_registry( - &ctx.opa_engine, + &ctx.engine, &ctx.middleware_connector, MiddlewareRegistryReconciliation { desired_services: &result.supervisor_middleware_services, @@ -4530,7 +4630,7 @@ async fn run_policy_poll_loop_with_client( let mode = result.policy_validation_failure_mode; if mode != rejected.configured_mode { let disposition = apply_policy_validation_failure( - &ctx.opa_engine, + &ctx.engine, mode, has_last_valid_policy, rejected.version, @@ -4679,7 +4779,7 @@ async fn run_policy_poll_loop_with_client( if policy_runtime_changed { let pid = ctx.entrypoint_pid.load(Ordering::Acquire); let runtime_result = reload_gateway_configuration_runtime( - &ctx.opa_engine, + &ctx.engine, result.policy.as_ref(), pid, MiddlewareReloadContext { @@ -4824,7 +4924,7 @@ async fn run_policy_poll_loop_with_client( if last_failed_runtime_revision.as_ref() != Some(&failed_revision) { let failure_mode = result.policy_validation_failure_mode; match apply_gateway_runtime_reload_failure( - &ctx.opa_engine, + &ctx.engine, failure, failure_mode, has_last_valid_policy, @@ -5972,7 +6072,7 @@ network_policies: #[tokio::test] async fn vm_identity_conflict_prevents_runtime_policy_and_credential_commit() { let mut policy = proto_policy_fixture(); - let engine = OpaEngine::from_proto(&policy).unwrap(); + let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap()); let before = engine.current_generation(); policy.process = Some(openshell_core::proto::ProcessPolicy { run_as_user: "sandbox".into(), @@ -5980,7 +6080,7 @@ network_policies: }); let committed = AtomicBool::new(false); let result = reload_gateway_configuration_runtime( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), Some(&policy), 0, MiddlewareReloadContext { @@ -6212,7 +6312,7 @@ network_policies: .unwrap() .expect("repair returns one launch bundle"); assert!( - bundle.7.is_some(), + bundle.captured_provider_environment.is_some(), "launch bundle retains matching provider state" ); } @@ -6539,7 +6639,7 @@ network_policies: .unwrap() .expect("the repaired configuration returns one launch bundle"); assert_eq!( - bundle.2, + bundle.proto, Some(repaired), "startup must install the repaired policy" ); @@ -6690,7 +6790,7 @@ network_policies: ], "startup must discard the obsolete rejection and validate the repair" ); - assert_eq!(bundle.2, Some(repaired)); + assert_eq!(bundle.proto, Some(repaired)); } #[tokio::test(start_paused = true)] @@ -7004,14 +7104,14 @@ network_policies: "startup must not write a global policy back as a sandbox policy" ); assert_eq!( - bundle.2, + bundle.proto, Some(enriched), "startup installs the global policy with the baseline paths added" ); let LoadedPolicyOrigin::Gateway { revision: Some(revision), .. - } = bundle.4 + } = bundle.origin else { panic!("startup must bind the global policy to its gateway revision"); }; @@ -8223,7 +8323,7 @@ network_policies: endpoint: String::new(), sandbox_id: "sandbox-test".to_string(), sandbox: "sandbox-test-name".to_string(), - opa_engine, + engine: PolicyEngine::from(opa_engine), loaded_policy_origin, vm_identity: None, entrypoint_pid: Arc::new(AtomicU32::new(0)), @@ -8768,7 +8868,8 @@ network_policies: async fn assert_gateway_reload_rejects_and_repairs(registry_changed: bool) { let policy = proto_provenance_policy_fixture(); let engine = OpaEngine::from_proto(&policy).expect("build initial gateway policy"); - install_builtin_middleware_registry(&engine) + let engine = Arc::new(engine); + install_builtin_middleware_registry(&PolicyEngine::from(Arc::clone(&engine))) .await .expect("install initial registry"); let generation = engine.current_generation(); @@ -8809,7 +8910,7 @@ network_policies: GatewayRuntimeReloadError::PolicyValidation(_) )); let disposition = apply_gateway_runtime_reload_failure( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), failure, PolicyValidationFailureMode::RetainLastValid, true, @@ -8895,22 +8996,24 @@ network_policies: LocalPolicyIdentity::Required, ) }; - let ( - _, - engine, + let LoadedPolicy { + network_engine: engine, proto, - registry, + middleware_registry_status: registry, origin, - proposals, + agent_proposals_enabled: proposals, extension_authentication_enabled, - provider_credentials, - ) = startup().await.expect("load valid local policy"); + captured_provider_environment: provider_credentials, + .. + } = startup().await.expect("load valid local policy"); assert!(provider_credentials.is_none()); assert!(proto.is_none()); assert!(matches!(origin, LoadedPolicyOrigin::LocalOverride)); assert!(matches!(registry, MiddlewareRegistryStatus::Synchronized)); assert!(!proposals && !extension_authentication_enabled); - let engine = engine.expect("local startup installs OPA"); + let Some(PolicyEngine::Opa(engine)) = engine else { + panic!("local startup installs OPA"); + }; assert!(engine.binary_identity_required()); // Installing the built-in registry creates the first active generation. assert_eq!(engine.current_generation(), 1); @@ -8930,12 +9033,9 @@ network_policies: .allowed ); let endpoint = engine - .query_endpoint_config(&input) + .query_endpoint_config_json(&input) .expect("query startup endpoint") .expect("startup endpoint must exist"); - let endpoint: serde_json::Value = - serde_json::from_str(&endpoint.to_json_str().expect("serialize endpoint")) - .expect("endpoint JSON"); assert_eq!( endpoint["rules"][0]["allow"]["query"]["scope"], serde_json::json!({ "glob": "public-*" }), @@ -8962,8 +9062,9 @@ network_policies: #[tokio::test] async fn failed_external_startup_registry_build_preserves_installed_builtins() { - let engine = OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine"); - install_builtin_middleware_registry(&engine) + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + install_builtin_middleware_registry(&PolicyEngine::from(Arc::clone(&engine))) .await .expect("install built-in middleware registry"); let builtins_generation = engine.current_generation(); @@ -8985,7 +9086,8 @@ network_policies: #[tokio::test] async fn unavailable_middleware_reload_keeps_last_known_good_runtime_active() { let engine = OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine"); - install_builtin_middleware_registry(&engine) + let engine = Arc::new(engine); + install_builtin_middleware_registry(&PolicyEngine::from(Arc::clone(&engine))) .await .expect("install built-in middleware registry"); let active_generation = engine.current_generation(); @@ -9011,7 +9113,7 @@ network_policies: .await .expect_err("unavailable middleware must fail candidate preparation"); let disposition = apply_gateway_runtime_reload_failure( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), failure, PolicyValidationFailureMode::FailClosed, true, @@ -9027,9 +9129,318 @@ network_policies: assert!(engine.fail_closed_reason().is_none()); } + const CEDAR_RELOAD_POLICY: &str = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443"); +"#; + + fn cedar_proto_policy(source: &str) -> openshell_core::proto::SandboxPolicy { + openshell_core::proto::SandboxPolicy { + version: 1, + cedar_policy_source: source.to_string(), + ..Default::default() + } + } + + fn cedar_engine() -> Arc { + Arc::new( + CedarOnlyEngine::from_proto(&cedar_proto_policy(CEDAR_RELOAD_POLICY)) + .expect("build Cedar engine"), + ) + } + + async fn reload_with_cedar( + engine: &PolicyEngine, + policy: &openshell_core::proto::SandboxPolicy, + ) -> std::result::Result { + reload_gateway_configuration_runtime( + engine, + Some(policy), + 0, + MiddlewareReloadContext { + desired_services: &[], + authentication: &MiddlewareAuthentication::default(), + registry_changed: false, + connector: &default_middleware_connector(), + }, + TransparentTcpReloadState::default(), + None, + || {}, + ) + .await + } + + #[tokio::test] + async fn yaml_reload_of_a_cedar_sandbox_is_rejected_and_keeps_previous_policy() { + let cedar = cedar_engine(); + let engine = PolicyEngine::from(Arc::clone(&cedar)); + let middleware_generation = engine.middleware_generation(); + let cedar_generation = cedar.current_generation(); + + let failure = reload_with_cedar(&engine, &proto_policy_fixture()) + .await + .expect_err("format switch must be rejected"); + + assert!( + matches!(failure, GatewayRuntimeReloadError::PolicyValidation(_)), + "{failure:?}" + ); + assert_eq!(engine.middleware_generation(), middleware_generation); + assert_eq!(cedar.current_generation(), cedar_generation); + } + + #[tokio::test] + async fn cedar_reload_of_a_yaml_sandbox_is_rejected_and_keeps_previous_policy() { + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let opa_generation = engine.current_generation(); + + let failure = reload_with_cedar( + &PolicyEngine::from(Arc::clone(&engine)), + &cedar_proto_policy(CEDAR_RELOAD_POLICY), + ) + .await + .expect_err("format switch must be rejected"); + + assert!( + matches!(failure, GatewayRuntimeReloadError::PolicyValidation(_)), + "{failure:?}" + ); + assert_eq!(engine.current_generation(), opa_generation); + } + + #[tokio::test] + async fn invalid_cedar_reload_leaves_the_previous_configuration_active() { + let cedar = cedar_engine(); + let engine = PolicyEngine::from(Arc::clone(&cedar)); + let middleware_generation = engine.middleware_generation(); + let cedar_generation = cedar.current_generation(); + + reload_with_cedar(&engine, &cedar_proto_policy("not cedar {{{")) + .await + .expect_err("invalid Cedar policy must be rejected"); + + assert_eq!(engine.middleware_generation(), middleware_generation); + assert_eq!(cedar.current_generation(), cedar_generation); + } + + /// A Cedar policy's middleware gets the same validation a YAML policy's + /// does; a rejected candidate commits no policy, middleware, or + /// credentials. + #[tokio::test] + async fn invalid_cedar_middleware_reload_commits_nothing() { + let cedar = cedar_engine(); + let engine = PolicyEngine::from(Arc::clone(&cedar)); + let middleware_generation = engine.middleware_generation(); + let cedar_generation = cedar.current_generation(); + let mut candidate = cedar_proto_policy(CEDAR_RELOAD_POLICY); + candidate.network_middlewares.insert( + "redactor".to_string(), + openshell_core::proto::NetworkMiddlewareConfig { + middleware: "openshell/regex".to_string(), + on_error: "maybe".to_string(), + order: 10, + endpoints: Some(openshell_core::proto::MiddlewareEndpointSelector { + include: vec!["pypi.org".to_string()], + exclude: Vec::new(), + }), + ..Default::default() + }, + ); + let committed = AtomicBool::new(false); + + let failure = reload_gateway_configuration_runtime( + &engine, + Some(&candidate), + 0, + MiddlewareReloadContext { + desired_services: &[], + authentication: &MiddlewareAuthentication::default(), + registry_changed: false, + connector: &default_middleware_connector(), + }, + TransparentTcpReloadState::default(), + None, + || committed.store(true, Ordering::SeqCst), + ) + .await + .expect_err("invalid middleware must be rejected"); + + assert!( + matches!(&failure, GatewayRuntimeReloadError::PolicyValidation(error) + if error.to_string().contains("invalid middleware configuration")), + "{failure:?}" + ); + assert!(!committed.load(Ordering::SeqCst)); + assert_eq!(engine.middleware_generation(), middleware_generation); + assert_eq!(cedar.current_generation(), cedar_generation); + } + + /// A middleware-only change publishes a new configuration generation but + /// keeps Cedar's, so inspected tunnels are not closed. + #[tokio::test] + async fn cedar_middleware_only_reload_keeps_the_cedar_generation() { + let cedar = cedar_engine(); + let engine = PolicyEngine::from(Arc::clone(&cedar)); + let initial = engine + .configuration_generation_guard() + .expect("configuration guard"); + let cedar_generation = cedar.current_generation(); + let mut candidate = cedar_proto_policy(CEDAR_RELOAD_POLICY); + candidate.network_middlewares.insert( + "redactor".to_string(), + openshell_core::proto::NetworkMiddlewareConfig { + middleware: "openshell/regex".to_string(), + order: 10, + endpoints: Some(openshell_core::proto::MiddlewareEndpointSelector { + include: vec!["pypi.org".to_string()], + exclude: Vec::new(), + }), + ..Default::default() + }, + ); + + let guard = reload_with_cedar(&engine, &candidate) + .await + .expect("middleware-only reload commits"); + + assert!(initial.is_stale()); + assert!(!guard.is_stale()); + assert_eq!(cedar.current_generation(), cedar_generation); + let input = openshell_supervisor_network::policy_types::NetworkInput { + host: "pypi.org".to_string(), + port: 443, + binary_path: std::path::PathBuf::from("/usr/bin/curl"), + binary_sha256: String::new(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + let (chain, generation) = engine + .query_middleware_chain_with_generation(&input) + .expect("middleware chain"); + assert_eq!(generation, guard.captured_generation()); + assert_eq!( + chain + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + vec!["redactor"] + ); + } + + #[tokio::test] + async fn valid_cedar_reload_commits_cedar_and_ends_its_quarantine() { + let cedar = cedar_engine(); + let engine = PolicyEngine::from(Arc::clone(&cedar)); + engine + .enter_fail_closed("invalid candidate") + .expect("quarantine"); + let quarantined_generation = cedar.current_generation(); + + let widened = format!( + "{CEDAR_RELOAD_POLICY}\npermit (principal, action == Sandbox::Action::\"NetworkConnect\", \ + resource == Sandbox::NetworkEndpoint::\"files.pythonhosted.org:443\");" + ); + reload_with_cedar(&engine, &cedar_proto_policy(&widened)) + .await + .expect("valid Cedar policy reloads"); + + assert!(cedar.fail_closed_reason().is_none()); + assert!(cedar.current_generation() > quarantined_generation); + } + + /// A Cedar sandbox's startup configuration is the Cedar engine alone; no + /// OPA engine is built for it. + #[test] + fn cedar_startup_configuration_builds_no_opa_engine() { + let policy = cedar_proto_policy(CEDAR_RELOAD_POLICY); + let snapshot = settings_poll_result( + Some(policy.clone()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let (engine, _, _) = + prepare_startup_configuration(&snapshot, &policy, &startup_provider(0)) + .expect("Cedar startup configuration"); + assert!(matches!(engine, PolicyEngine::Cedar(_)), "{engine:?}"); + } + + /// As YAML adds baseline paths only to a policy with network rules, a + /// Cedar policy gets them only when it can permit `NetworkConnect`. + #[test] + #[cfg_attr( + target_os = "windows", + ignore = "the baseline runtime paths are Unix paths that do not exist on a Windows host" + )] + fn cedar_baseline_paths_follow_network_permits() { + let landlock_paths = |source: &str| { + let policy = cedar_proto_policy(source); + let snapshot = settings_poll_result( + Some(policy.clone()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let (_, process_policy, _) = + prepare_startup_configuration(&snapshot, &policy, &startup_provider(0)) + .expect("Cedar startup configuration"); + process_policy.filesystem + }; + let tmp = std::path::PathBuf::from("/tmp"); + let file_only = r#"permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/srv" + );"#; + + let filesystem = landlock_paths(file_only); + assert_eq!(filesystem.read_only, [std::path::PathBuf::from("/srv")]); + assert!(!filesystem.read_write.contains(&tmp), "{filesystem:?}"); + + let filesystem = landlock_paths(&format!("{file_only}\n{CEDAR_RELOAD_POLICY}")); + assert!(filesystem.read_write.contains(&tmp), "{filesystem:?}"); + } + + #[test] + fn policy_rejection_quarantines_the_cedar_engine_of_a_cedar_sandbox() { + let cedar = cedar_engine(); + let input = openshell_supervisor_network::opa::NetworkInput { + host: "pypi.org".to_string(), + port: 443, + binary_path: std::path::PathBuf::from("/usr/bin/curl"), + binary_sha256: String::new(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + assert!(matches!( + cedar.authorize_egress(&input).unwrap().action, + openshell_supervisor_network::opa::NetworkAction::Allow { .. } + )); + + let disposition = apply_gateway_runtime_reload_failure( + &PolicyEngine::from(Arc::clone(&cedar)), + GatewayRuntimeReloadError::PolicyValidation(miette::miette!("invalid candidate")), + PolicyValidationFailureMode::FailClosed, + true, + 7, + ) + .unwrap(); + + assert!(matches!( + disposition, + GatewayRuntimeFailureDisposition::PolicyRejected { .. } + )); + assert!(cedar.fail_closed_reason().is_some()); + assert!(matches!( + cedar.authorize_egress(&input).unwrap().action, + openshell_supervisor_network::opa::NetworkAction::Deny { .. } + )); + assert!(cedar.policy_dns_eligibility_snapshot().unwrap().fail_closed); + } + #[tokio::test] async fn tcp_policy_reload_without_startup_substrate_is_rejected_and_keeps_previous_policy() { let engine = OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine"); + let engine = Arc::new(engine); let active_generation = engine.current_generation(); let failure = reload_gateway_policy_runtime( @@ -9050,7 +9461,7 @@ network_policies: .await .expect_err("TCP expansion must require startup substrate"); let disposition = apply_gateway_runtime_reload_failure( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), failure, PolicyValidationFailureMode::FailClosed, true, @@ -9071,7 +9482,8 @@ network_policies: #[tokio::test] async fn tcp_policy_reload_on_unsupported_runtime_is_rejected() { - let engine = OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine"); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); let failure = reload_gateway_policy_runtime( &engine, @@ -9102,12 +9514,13 @@ network_policies: "network_policies: {}\n", ) .unwrap(); + let engine = Arc::new(engine); let middleware_failure = GatewayRuntimeReloadError::MiddlewareRegistry(miette::miette!( "middleware service unavailable" )); let first_failure = FailedRuntimeRevision::new(42, "sha256:candidate", &middleware_failure); let middleware_disposition = apply_gateway_runtime_reload_failure( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), middleware_failure, PolicyValidationFailureMode::FailClosed, true, @@ -9131,7 +9544,7 @@ network_policies: ); let policy_disposition = apply_gateway_runtime_reload_failure( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), policy_failure, PolicyValidationFailureMode::FailClosed, true, @@ -9557,10 +9970,11 @@ network_policies: "network_policies: {}\n", ) .unwrap(); + let engine = Arc::new(engine); let previous_generation = engine.current_generation(); let disposition = apply_policy_validation_failure( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), PolicyValidationFailureMode::FailClosed, true, 7, @@ -9585,10 +9999,11 @@ network_policies: "network_policies: {}\n", ) .unwrap(); + let engine = Arc::new(engine); let previous_generation = engine.current_generation(); let quarantined = apply_policy_validation_failure( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), PolicyValidationFailureMode::FailClosed, true, 6, @@ -9598,7 +10013,7 @@ network_policies: assert!(!quarantined.previous_policy_active); let disposition = apply_policy_validation_failure( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), PolicyValidationFailureMode::RetainLastValid, true, 7, @@ -9619,9 +10034,10 @@ network_policies: "network_policies: {}\n", ) .unwrap(); + let engine = Arc::new(engine); let disposition = apply_policy_validation_failure( - &engine, + &PolicyEngine::from(Arc::clone(&engine)), PolicyValidationFailureMode::RetainLastValid, false, 1, diff --git a/crates/openshell-supervisor/src/provider_readiness.rs b/crates/openshell-supervisor/src/provider_readiness.rs index fae5e71455..9f61a97afc 100644 --- a/crates/openshell-supervisor/src/provider_readiness.rs +++ b/crates/openshell-supervisor/src/provider_readiness.rs @@ -15,7 +15,7 @@ use openshell_core::proto::{ }; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{BoundaryExec, ProviderEnvironmentInstallation}; -use openshell_supervisor_network::opa::PolicyGenerationGuard; +use openshell_supervisor_network::policy_types::PolicyGenerationGuard; use tokio::sync::watch; /// Authority captured before consuming a provider response's credential fields. @@ -815,6 +815,44 @@ mod tests { assert!(!observed.launch_environment_installed); } + /// A Cedar sandbox's configuration guard is pinned to the middleware + /// generation; a quarantine must stale it as it stales a YAML guard. + #[test] + fn cedar_quarantine_invalidates_installed_policy_evidence() { + use openshell_supervisor_network::cedar_only::CedarOnlyEngine; + use openshell_supervisor_network::policy_engine::PolicyEngine; + + let tracker = Tracker::new(); + let credentials = ProviderCredentialState::from_child_env_snapshot(6, HashMap::new()); + let policy = openshell_core::proto::SandboxPolicy { + version: 1, + cedar_policy_source: r#"permit (principal, action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443");"# + .to_string(), + ..Default::default() + }; + let engine = PolicyEngine::from(Arc::new(CedarOnlyEngine::from_proto(&policy).unwrap())); + tracker.credentials_installed(identity(), &credentials, None); + tracker.policy_activated( + &identity(), + 12, + engine.configuration_generation_guard().unwrap(), + ); + assert!(tracker.observation(&credentials).policy_active); + + engine.enter_fail_closed("test quarantine").unwrap(); + assert!(!tracker.observation(&credentials).policy_active); + + tracker.policy_activated( + &identity(), + 12, + engine.configuration_generation_guard().unwrap(), + ); + assert!(tracker.observation(&credentials).policy_active); + engine.exit_fail_closed().unwrap(); + assert!(!tracker.observation(&credentials).policy_active); + } + #[test] fn expired_credentials_and_disconnected_boundary_never_complete() { let tracker = Tracker::new(); diff --git a/crates/openshell-tui/Cargo.toml b/crates/openshell-tui/Cargo.toml index 68d6e11c93..ec73c55971 100644 --- a/crates/openshell-tui/Cargo.toml +++ b/crates/openshell-tui/Cargo.toml @@ -13,7 +13,7 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } openshell-bootstrap = { path = "../openshell-bootstrap" } -openshell-policy = { path = "../openshell-policy" } +openshell-policy = { path = "../openshell-policy", default-features = false } openshell-providers = { path = "../openshell-providers" } base64 = { workspace = true } diff --git a/deny.toml b/deny.toml index 62ce9a909f..363da210a5 100644 --- a/deny.toml +++ b/deny.toml @@ -69,4 +69,5 @@ external-default-features = "allow" unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = [] +# OpenShell's Cedar (ai-openshell), pinned by commit in Cargo.toml. +allow-git = ["https://github.com/ai-openshell/cedar.git"] diff --git a/deploy/rpm/create-vendor-archive.sh b/deploy/rpm/create-vendor-archive.sh new file mode 100755 index 0000000000..0d465c7239 --- /dev/null +++ b/deploy/rpm/create-vendor-archive.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Create the vendored Cargo dependency tarball (Source1 in openshell.spec) for +# the offline RPM build. Invoked by the create-archive action in .packit.yaml, +# which exports PACKIT_PROJECT_VERSION; a version may also be passed as $1. +# +# `cargo vendor` prints the cargo config needed to use the vendor directory to +# stdout (only without --quiet, which silences it). `%cargo_prep -v vendor` +# already writes the crates-io -> vendored-sources mapping, but git +# dependencies need their own [source."git+..."] tables or cargo refuses to +# resolve them offline. Capture only those tables into +# vendor/.cargo-git-sources.toml; %prep in openshell.spec appends the file to +# the cargo config the macro writes. Cargo ignores dotfiles inside a directory +# source, so the extra file is harmless. +set -euo pipefail + +version=${1:-${PACKIT_PROJECT_VERSION:-}} +if [ -z "${version}" ]; then + echo "usage: $0 (or set PACKIT_PROJECT_VERSION)" >&2 + exit 1 +fi +archive="openshell-${version}-vendor.tar.xz" +git_sources=vendor/.cargo-git-sources.toml + +rm -rf vendor +config=$(CARGO_HTTP_TIMEOUT=600 CARGO_NET_RETRY=5 cargo vendor --locked vendor) +printf '%s\n' "${config}" | + awk '/^\[source\."git\+/ {p=1} /^\[/ && !/^\[source\."git\+/ {p=0} p' \ + >"${git_sources}" +if [ -s "${git_sources}" ]; then + echo "captured git source replacements:" >&2 + grep '^\[source' "${git_sources}" >&2 +else + rm -f "${git_sources}" +fi + +tar -cJf "${archive}" vendor/ +rm -rf vendor diff --git a/docs/extensibility/supervisor-middleware/configure.mdx b/docs/extensibility/supervisor-middleware/configure.mdx index 7ea8183af3..cc2bd88533 100644 --- a/docs/extensibility/supervisor-middleware/configure.mdx +++ b/docs/extensibility/supervisor-middleware/configure.mdx @@ -163,6 +163,8 @@ The map key, such as `content-guard`, is the entry's stable identity in logs. Ea [Policy Schema](/how-it-works/policies/schema#network-middleware) lists every field and limit. +You can also keep middleware in its own file with a `network_middlewares` section, and pass it with `--middleware` to `openshell sandbox create` or `openshell policy set`. This is how middleware is supplied for a [Cedar policy](/how-it-works/policies/cedar#add-middleware-and-endpoint-settings), whose middleware file can also carry `endpoint_settings` and a `process` section. The policy itself must not also define `network_middlewares`. + ## Choose Failure Behavior Middleware fails when it cannot complete its check, for example when its service is unavailable, times out, returns an invalid result, or receives a payload over its limit. By default, OpenShell blocks the affected request, response, or WebSocket connection. The `on_error` field on a policy entry controls this behavior. @@ -175,7 +177,7 @@ Middleware fails when it cannot complete its check, for example when its service A denial from middleware always blocks traffic, regardless of `on_error`. Middleware decisions also stay enforced when the endpoint uses `enforcement: audit`. To observe traffic without blocking it, have your middleware allow the traffic and report findings. -`on_error` does not extend what middleware can inspect. The [current limitations](/extensibility/supervisor-middleware#current-limitations) apply under both settings. With the default behavior, an entry cannot select a `tls: skip` endpoint, because OpenShell cannot inspect that traffic. +`on_error` does not extend what middleware can inspect. The [current limitations](/extensibility/supervisor-middleware#current-limitations) apply under both settings. With the default behavior, an entry cannot select a `tls: skip` endpoint, including one set in a Cedar sandbox's `endpoint_settings`, because OpenShell cannot inspect that traffic. ## Operate Middleware Services diff --git a/docs/how-it-works/policies/cedar.mdx b/docs/how-it-works/policies/cedar.mdx new file mode 100644 index 0000000000..40fb3f8cf2 --- /dev/null +++ b/docs/how-it-works/policies/cedar.mdx @@ -0,0 +1,499 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Write Sandbox Policies in Cedar" +sidebar-title: "Cedar Policies" +description: "Author an OpenShell sandbox policy in Cedar instead of YAML and understand which Cedar policy shapes OpenShell accepts." +keywords: "Generative AI, Cybersecurity, Policy, Cedar, Network Policy, Landlock, Sandbox, Security" +position: 8 +--- + +A sandbox policy can be written in [Cedar](https://www.cedarpolicy.com/) instead of YAML. Cedar then decides every network connection, every inspected HTTP or JSON-RPC request, and the filesystem paths Landlock allows. A sandbox uses one format for its whole lifetime: YAML policies are evaluated by the built-in Rego engine, and Cedar policies by Cedar. + +Cedar's restricted language makes policies easier to analyze, for example to check that a revision grants no more access than the one it replaces. OpenShell accepts only Cedar policy shapes it can enforce exactly as written, and rejects the rest when you submit the policy. + +## Apply a Cedar Policy + +OpenShell treats a policy file as Cedar when its extension is `.cedar`, in any case. Any other extension is parsed as YAML. + +Create a sandbox with a Cedar policy: + +```shell +openshell sandbox create --policy ./policy.cedar -- claude +``` + +Replace the policy on a running sandbox: + +```shell +openshell policy set --policy ./policy.cedar --wait +``` + +The CLI validates the file before sending it, and the gateway validates it again. If the sandbox cannot load a revision, the `policy_validation_failure_mode` setting applies as it does for YAML policies. Refer to [Manage Policies](/how-it-works/policies/manage-policies). + +`openshell policy get ` prints the Cedar text of a Cedar sandbox's policy. + +## Add Middleware and Endpoint Settings + +Middleware, endpoint settings, and the process identity are configuration, not access control, so they are supplied separately from the Cedar file in a middleware file. The file has a `network_middlewares` section and an optional `process` section, both in the same format as the sections of a YAML policy, and an `endpoint_settings` section: + +```yaml +network_middlewares: + regex-redactor: + name: Redact API tokens + middleware: openshell/regex + order: 10 + config: + mode: redact + on_error: fail_closed + endpoints: + include: ["api.github.com"] + +endpoint_settings: + - host: gitlab.example.com + port: 443 + path: /api/** + allow_encoded_slash: true + - host: mcp.example.com + port: 443 + mcp: + versions: ["2025-06-18", "2025-11-25"] + strict_tool_names: false + +process: + run_as_user: sandbox + run_as_group: sandbox +``` + +Pass it with `--middleware` when you create the sandbox or set its policy: + +```shell +openshell sandbox create --policy ./policy.cedar --middleware ./middleware.yaml -- claude +openshell policy set --policy ./policy.cedar --middleware ./middleware.yaml --wait +``` + +`openshell policy get ` prints these sections after the Cedar text. `process` accepts the same identities as a YAML policy and, like a YAML policy's process section, cannot change on a running sandbox; without it, the compute driver selects the identity. The Landlock compatibility mode of a Cedar sandbox uses its default. + +Each `endpoint_settings` entry selects endpoints the way a YAML endpoint does: `host` is an exact host or a host glob, `port` or `ports` lists the ports, and the optional `path` uses the YAML endpoint path syntax. An entry can set these YAML endpoint fields: + +| Field | Applies to | +|---|---| +| `tls: skip` | Every connection to the endpoint, inspected or not. Cannot be combined with `path`. | +| `allow_encoded_slash` | Inspected requests. | +| `json_rpc.max_body_bytes`, `mcp.max_body_bytes` | JSON-RPC and MCP endpoints. | +| `mcp.versions`, `mcp.strict_tool_names` | MCP endpoints. Without `mcp.versions`, an MCP endpoint uses the YAML default revision. | +| `graphql_max_body_bytes`, `persisted_queries`, `graphql_persisted_queries` | GraphQL and GraphQL-over-WebSocket endpoints. | + +A setting takes effect only on connections the Cedar policy allows, and a protocol option only where the policy inspects that protocol. Fields that decide access, such as `protocol`, `enforcement`, `access`, `rules`, `deny_rules`, `allowed_ips`, and `binaries`, and credential fields, which providers supply, are rejected. A YAML policy rejects `endpoint_settings`, because its endpoints carry these fields directly. + +An entry with a `path` applies to requests whose path it matches, and an entry without one applies to every request. When several entries match, the most specific path wins, as for YAML endpoints sharing a host and port, and a later entry overrides an earlier one for the fields it sets. Two entries cannot select the same host, path, and port. An entry with a `path` takes the protocol and enforcement of the [inspected path](#paths) that matches it, and has no effect where no inspected path matches. + +## Policy Model + +Every policy is evaluated against a fixed schema in the `Sandbox` namespace. The principal is always a `Sandbox::Process`. + +| Action | Resource | Context fields | Decides | +|---|---|---|---| +| `ReadFile`, `WriteFile` | `FilesystemPath` | none | Landlock grants, fixed at sandbox start. | +| `NetworkConnect` | `NetworkEndpoint` | `binary_path`, `ancestors`, `binary_aliases`, `binary_globs`, and optional `destination_ip` | Whether a process may open a connection, and to which resolved addresses. | +| `HttpRequest` | `NetworkEndpoint` | `binary_path`, `ancestors`, `binary_aliases`, `binary_globs`, `method`, `path`, `query`, and the protocol fields below | Each request on an inspected connection. | + +A `NetworkEndpoint` id is `"host:port"`, with a lowercase host. `NetworkConnect` has no `method`, `path`, or `command`, because no request has been read at connect time, so a connection policy that reads one fails validation. Put method and path conditions on `HttpRequest`. A policy with `action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"]` that reads them also fails validation; split it into one policy per action. A `has` test of an attribute the schema does not declare, such as `principal has workdir` or `context has method` on `NetworkConnect`, could never hold and is rejected as well. + +This example grants filesystem access, allows `curl` to connect to the GitHub API, and inspects each request on that connection: + +```cedar +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { resource in Sandbox::FilesystemPath::"/sandbox" || resource in Sandbox::FilesystemPath::"/tmp" }; + +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/usr" +); + +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.binary_path == "/usr/bin/curl" }; + +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.method == "GET" && context.path like("/repos/*/*", "/") }; +``` + +## Filesystem Rules + +Landlock applies a flat allow-list of path subtrees when the sandbox starts, so OpenShell accepts only filesystem policies that mean exactly that. A policy that can apply to `ReadFile` or `WriteFile` must: + +- Be a `permit`. A `forbid` that can apply to files is rejected. +- List only `ReadFile` and `WriteFile` in its action scope. +- Use `principal` or `principal is Sandbox::Process`. +- Name paths with `resource in Sandbox::FilesystemPath::"/path"`, either in the scope or in a single `when` clause that only joins such tests with `||`. + +`resource ==` is rejected because Landlock grants the whole subtree under a directory. Paths must be absolute, must not contain `..`, and `/` cannot be granted write access. A Landlock read-write grant includes read, so a policy that lists `WriteFile` must also list `ReadFile`; a policy that lists `WriteFile` alone is rejected. + +As for a YAML policy with network rules, the supervisor adds its baseline runtime paths when a policy can permit `NetworkConnect`. A policy with no `NetworkConnect` permit and no filesystem permits applies no Landlock ruleset, like a YAML policy with no network rules and no filesystem paths. On a running sandbox, a policy update can add filesystem paths but cannot remove them, because Landlock cannot revoke access from a running process. + +## Network Rules + +Cedar evaluates `NetworkConnect` for every outbound connection, with the calling binary in `context.binary_path` and its ancestors in `context.ancestors`. Conditions on these actions are evaluated by Cedar as written. + +Cedar compares `context.binary_path` exactly, and the kernel reports the resolved executable, such as `/usr/bin/python3.12`, not a symlink such as `/usr/bin/python3`. To match a binary the way a YAML binary path matches it, symlink included, test all three fields: + +```cedar +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443" +) +when { + context.binary_path == "/usr/bin/python3" + || context.ancestors.contains("/usr/bin/python3") + || context.binary_aliases.contains("/usr/bin/python3") +}; +``` + +`context.binary_aliases` holds each path your policies test with `.contains`, `.containsAny`, or `.containsAll` that is a symlink in the sandbox resolving to the calling binary or one of its ancestors. The supervisor resolves these paths inside the sandbox once the entrypoint process starts, and again on each policy update, as it resolves YAML binary paths. Until then the set is empty, so a policy that needs it matches only the literal path. Paths must be literal absolute paths without `*`: like YAML, OpenShell never resolves a glob such as `context.binary_path like("/usr/lib/postgresql/*/bin/psql", "/")`. + +A YAML binary glob, such as `/usr/lib/node_modules/@openai/**`, matches the calling binary or any of its ancestors. `context.binary_path like("/usr/lib/node_modules/@openai/**", "/")` matches only the calling binary, because `like` tests one string, so write the glob as a member of `context.binary_globs`, which OpenShell matches against the binary and every ancestor: + +```cedar +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.openai.com:443" +) +when { context.binary_globs.contains("/usr/lib/node_modules/@openai/**") }; +``` + +`context.binary_globs` holds each pattern your policies test with `.contains`, `.containsAny`, or `.containsAll` that matches the calling binary or one of its ancestors, with the matcher YAML applies to binary globs: `*` matches within one path segment, and `**` matches across segments as a whole segment. Patterns must be literal absolute paths that contain `*` and are valid globs; OpenShell rejects any other use of the set. + +## Globs + +Write globs with a delimiter, as `like("pattern", "delimiter")`. With a delimiter, `*` matches within one segment and `**` matches across segments, as in YAML policies. Use `"."` for hosts and `"/"` for paths. Without a delimiter, `like` treats every `*` as matching across segments, so `context.path like "/repos/*"` also matches `/repos/a/b/c`. + +In a delimited pattern, write `**` only as a whole segment, such as `/files/**` or `**.example.com`. YAML reads a `**` next to other characters, as in `/files/report**`, as `*` within one segment, while Cedar's `**` crosses segments, so OpenShell rejects such a pattern. Write `*` for the YAML meaning. + +```cedar +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource is Sandbox::NetworkEndpoint +) +when { + resource.host like("*.example.com", ".") + && resource.port == 443 + && context.binary_path == "/usr/bin/curl" +}; +``` + +## Policy DNS + +Policy DNS resolves a host when a `NetworkConnect` permit names it in its scope, such as `resource == Sandbox::NetworkEndpoint::"pypi.org:443"`, or requires both the host and the port in its `when` conditions, as in the example above. The host condition can be an exact string or a host glob with a `.` delimiter that follows the YAML wildcard host rules: `*` or `**` as the whole first label, `*` within the first label, `*` as a whole later label, and no wildcard top-level domain such as `*.com`. A `when` condition `resource.host_port == "pypi.org:443"`, or `["pypi.org:443"].contains(resource.host_port)` with one element, names the host and port together; the literal must be valid the way a scope literal is, with a lowercase host and a port, or the policy is rejected. A `host_port` set with several elements does not make its hosts eligible. Policy DNS does not resolve any other host, so the connection fails closed. + +A host named exactly may resolve to a private address. A host matched by a glob may not, unless an allowing permit names it exactly or [restricts its addresses](#destination-addresses). + +## Destination Addresses + +`context.destination_ip` is the Cedar form of YAML `allowed_ips`. A `NetworkConnect` permit can require each address the host resolves to to lie in fixed ranges: + +```cedar +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"my-service.corp.net:8080" +) +when { + context.binary_path == "/usr/bin/curl" + && context has destination_ip + && context.destination_ip.isInRange(ip("10.0.5.0/24")) +}; +``` + +Leave out the host to allow any host on the port whose addresses are in range, like a YAML endpoint with `allowed_ips` and no `host`. Such a permit does not make a host eligible for policy DNS. + +OpenShell decides a connection twice. Before the host resolves, `destination_ip` is absent, and a permit counts as if its address conditions held. After the host resolves, Cedar decides again for each address, and every address must pass. Policy DNS drops answers outside a permit's ranges. + +An address is admitted only when: + +- It is not loopback, link-local (including cloud metadata), or unspecified. These stay blocked regardless of policy. +- Every allowing permit with a `destination_ip` condition includes it. +- For a private address, an allowing permit names the host exactly, or every allowing permit has a `destination_ip` condition. A permit without one admits only public addresses unless it names the host. +- Every attached provider endpoint for the host and port that sets `allowed_ips` includes it. Provider ranges only narrow; they never admit an address the Cedar policy rejects. + +Control-plane ports, such as 6443 and 2379, are blocked unless only public-only permits allow the connection and no matching provider endpoint sets `allowed_ips`, as in YAML. + +When the operator sets `proxy_connect_by_hostname`, the upstream proxy resolves the host again, so `destination_ip` checks do not bind the address it connects to. YAML `allowed_ips` has the same limitation. + +A permit must write its address condition as a top-level `when` conjunct: `context has destination_ip`, or `isInRange` tests joined with `||`. Conditions in that form intersect. OpenShell rejects a permit that reads `destination_ip` in any other form, a range that overlaps loopback, link-local, or unspecified addresses, and any policy that reads `destination_ip` without exactly the `NetworkConnect` action in its scope. + +A `forbid` may read `destination_ip` in any form, guarded by `context has destination_ip` as validation requires, and takes part in both decisions as written. Before the host resolves, the address is absent and the guard is false, so only the forbid's other conditions can deny; after it resolves, the forbid applies again to each address. A forbid such as `when { context.binary_path == "/usr/bin/curl" || (context has destination_ip && context.destination_ip.isInRange(ip("10.0.0.0/8"))) }` therefore denies `curl` at connect time, which matters with `proxy_connect_by_hostname`, and denies private addresses for every other binary once the host resolves. Write a forbid that only restricts addresses with `when`: an `unless` body that reads the address is false before resolution, so such a forbid denies every connection it covers at connect time. + +## Native TCP + +Add `@transport("tcp")` to a `NetworkConnect` permit to mark its endpoint as native TCP, like a YAML endpoint with `protocol: tcp`. The connection is allowed as plain L4 traffic and never inspected per request. + +```cedar +@transport("tcp") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"db.example.com:5432" +) +when { context.binary_path == "/usr/bin/psql" }; +``` + +As in YAML, OpenShell rejects a native TCP endpoint that: + +- Names an IP address or an invalid DNS host. Native TCP reaches hosts through policy DNS. +- Names no host and port the way [Policy DNS](#policy-dns) requires. +- Overlaps, on a host and port, an endpoint that an `HttpRequest` policy inspects or that another `NetworkConnect` permit names without `@transport("tcp")`. Host globs overlap when some host matches both. A permit without `@transport("tcp")` whose host cannot be derived the way [Policy DNS](#policy-dns) requires may allow any host, so OpenShell rejects it when a native TCP endpoint shares a port it may allow, or when it names no port either. + +`@transport` applies only to `permit` policies whose action scope is exactly `NetworkConnect`. + +## Request Rules + +The proxy decides at connect time whether to inspect a connection request by request. It inspects every endpoint that an `HttpRequest` policy names, whether the policy is a `permit` or a `forbid`. An `HttpRequest` policy that names no endpoint is rejected, because the proxy could not tell which connections it applies to. + +Cedar denies by default. Once any `HttpRequest` policy names an endpoint, each request to that endpoint needs a matching `permit`. + +### Wildcard hosts + +Name an endpoint exactly in the scope, as `resource == Sandbox::NetworkEndpoint::"api.example.com:443"`, or name a wildcard host in the `when` conditions with `resource is Sandbox::NetworkEndpoint` in the scope. A wildcard host inspects every host it matches, like a YAML endpoint `host: "*.example.com"` with request rules. Write it as exactly one host glob with a `.` delimiter and one port, each a top-level `&&` operand, following the [Policy DNS](#policy-dns) host rules: + +```cedar +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource is Sandbox::NetworkEndpoint +) +when { + resource.host like("*.example.com", ".") + && resource.port == 443 + && context.method == "GET" +}; +``` + +Any other way of naming the host, such as an undelimited `like`, a wildcard top-level domain, two hosts joined with `||`, or a glob without a port, is rejected. The request's `NetworkEndpoint` is the actual host and port, so the glob condition decides the request as written. + +A host matched by an exact endpoint and a wildcard host, or by two wildcard hosts, is inspected with the paths of all of them, and each request is routed to the most specific matching path, as for YAML endpoints sharing a host and port. Two such endpoints must inspect equally specific paths that can match the same request with the same protocol and enforcement, as YAML rejects overlapping endpoints that disagree. [Endpoint settings](#add-middleware-and-endpoint-settings) select wildcard-inspected hosts the same way they select any host. + +Endpoints are parsed as REST by default. Add `@protocol("json-rpc")`, `@protocol("mcp")`, `@protocol("graphql")`, `@protocol("websocket")`, or `@protocol("websocket-graphql")` to an `HttpRequest` policy to inspect that protocol instead. All policies that declare a protocol for one endpoint path must agree. `@protocol("sql")` is rejected, because OpenShell does not inspect SQL traffic. + +Fields that do not apply to a request are empty strings, `false`, or empty sets. + +| Protocol | Context fields | +|---|---| +| REST | `method`, `path` | +| JSON-RPC | `jsonrpc_method`, `jsonrpc_response` | +| MCP | `jsonrpc_method`, `mcp_tool`, `mcp_method_class`, `jsonrpc_receive_stream`, `jsonrpc_response` | +| GraphQL | `graphql_operation_type`, `graphql_operation_name`, `graphql_fields` | +| WebSocket | `method`, `path` | +| GraphQL over WebSocket | `method`, `path`, and the GraphQL fields | + +`context.method` is always uppercase. Unlike YAML, a `GET` condition does not also allow `HEAD`, so list `HEAD` when you want it allowed. + +For JSON-RPC and MCP, each call is evaluated on its own, and `method` is empty for a call. OpenShell denies a request that fails JSON-RPC inspection, and a call or response frame that is not sent with `POST`, before evaluating it. + +`resource.protocol` is the protocol a request is inspected with, such as `"graphql"` or `"websocket-graphql"`. On `NetworkConnect` it is the protocol of the policies without `@path`, or empty. + +### Paths + +Add `@path("pattern")` to `HttpRequest` policies to inspect one path of an endpoint with its own protocol and enforcement, as YAML endpoints that share a host and port do. The pattern uses the YAML endpoint `path` syntax: it starts with `/` or is `**`, `/prefix/**` matches `/prefix` and everything below it, and other patterns are globs. Each request is inspected as the most specific matching path declares, and policies without `@path` declare the inspection of every other path. When the policies name only paths, OpenShell denies a request that matches none of them. + +`@path` chooses how a request is parsed and enforced, not which policies apply to it. Every policy for the endpoint is evaluated for every request, so write the path in the policy's conditions: + +```cedar +@path("/repos/**") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.method == "GET" && context.path like("/repos/**", "/") }; + +@path("/graphql") +@protocol("graphql") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.path == "/graphql" && context.graphql_operation_type == "query" }; +``` + +A YAML GraphQL endpoint is authoritative for the requests it parses, so a broader REST rule never allows a GraphQL operation. Cedar applies the same rule: for a request parsed as GraphQL on a `graphql` or `websocket-graphql` path, permits attached to other paths of the endpoint that neither read a `graphql_*` field nor test `resource.protocol` are left out of the decision. In the example above, the `/repos/**` permit never allows an operation on `/graphql`, while a permit that reads `context.graphql_operation_type` or tests `resource.protocol` applies wherever it is written. Forbids always apply. Two equally specific paths that can match the same request must declare the same protocol and enforcement. + +### Query Parameters + +`context.query` holds the request's decoded query parameters as tags, one per key. Check that a key is present with `hasTag` before reading its value with `getTag`. YAML query globs treat `.` as a delimiter, so write the same globs with `like("pattern", ".")`: + +```cedar +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { + context.method == "GET" && context.path == "/download" + && context.query.hasTag("tag") && context.query.getTag("tag") like("foo-*", ".") +}; + +forbid ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443" +) +when { context.query.hasTag("force") && context.query.getTag("force") == "true" }; +``` + +When a key repeats, as in `?tag=foo-a&tag=foo-b`, OpenShell evaluates the request once for each combination of values. It allows the request only if no evaluation is denied and one `permit` allows every evaluation. This matches YAML: a permit must accept every value of each key it reads, and a `forbid` applies when each key it reads has one matching value. Write each YAML allow rule as its own `permit`, because two rules joined with `||` in one permit can together allow values that neither rule allows alone. OpenShell denies a request with more than 4096 combinations of the keys that policies read. + +### MCP + +`mcp_tool` is the tool name of a `tools/call` request. `mcp_method_class` is `available` for a core method of the selected MCP revision, and `extension` for any other method. An MCP endpoint does not allow its receive stream or client response frames by itself, so permit them explicitly. The endpoint accepts the YAML default revision, `2025-11-25`, unless an [endpoint setting](#add-middleware-and-endpoint-settings) lists other revisions. + +```cedar +@protocol("mcp") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.com:443" +) +when { + context.path == "/mcp" + && context.mcp_method_class == "available" + && (["initialize", "tools/list"].contains(context.jsonrpc_method) + || (context.jsonrpc_method == "tools/call" && context.mcp_tool like("github.*", "."))) +}; + +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"mcp.example.com:443" +) +when { + context.path == "/mcp" + && ((context.method == "GET" && context.jsonrpc_receive_stream) || context.jsonrpc_response) +}; +``` + +### GraphQL + +OpenShell evaluates each operation in a request separately and denies the request if any operation is denied. `graphql_operation_type` is lowercase. A hash-only persisted query, which sends no operation text, is looked up in the endpoint's `graphql_persisted_queries` [endpoint setting](#add-middleware-and-endpoint-settings) by hash, then by saved-query id, and Cedar evaluates the registered operation. OpenShell denies a hash-only query that is not registered, or any hash-only query unless `persisted_queries` is `allow_registered`, as for YAML. Match exact field names with `containsAll` and `containsAny`: + +```cedar +@protocol("graphql") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { + context.path == "/graphql" + && (context.graphql_operation_type == "query" + || (context.graphql_operation_type == "mutation" + && context.graphql_fields.containsAny(["createIssue", "commentOnIssue"]) + && ["createIssue", "commentOnIssue"].containsAll(context.graphql_fields))) +}; +``` + +Field globs, such as YAML `fields: ["create*"]`, cannot be written, because they need every field to match a pattern. Operation name globs work with `like`. + +### WebSocket + +On a `@protocol("websocket")` endpoint, the upgrade is a `GET` request, and each client text message is evaluated with `method` `WEBSOCKET_TEXT` and the `path` of the upgrade. A request that is not a WebSocket upgrade is denied. A message that Cedar denies closes the connection, or is logged and forwarded on an audit endpoint. + +`@protocol("websocket-graphql")` is a WebSocket endpoint whose client messages carry GraphQL operations, as a YAML WebSocket endpoint with GraphQL operation rules. Each operation message is evaluated with `method` `WEBSOCKET_TEXT`, the upgrade path, and the GraphQL fields. Control messages such as `connection_init` and `ping` pass without evaluation, and a message that is not a valid GraphQL-over-WebSocket client message is denied: + +```cedar +@path("/graphql") +@protocol("websocket-graphql") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"realtime.example.com:443" +) +when { + context.path == "/graphql" + && (context.method == "GET" + || (context.graphql_operation_type == "subscription" + && ["messageAdded"].containsAll(context.graphql_fields))) +}; +``` + +A permit that allows a `WEBSOCKET_TEXT` message on a `websocket-graphql` path without a GraphQL condition would allow every GraphQL operation, so OpenShell rejects it, as YAML rejects a generic `WEBSOCKET_TEXT` rule there. Condition message permits on the GraphQL fields, and permit the upgrade alone with `context.method == "GET"`. + +## Audit Mode + +Add `@enforcement("audit")` to an `HttpRequest` policy to make it an audit policy, the Cedar form of a YAML `enforcement: audit` endpoint. The policy's action scope must be exactly `HttpRequest`. + +When every deciding `HttpRequest` policy for an endpoint path is an audit policy, that path runs in audit mode: OpenShell logs a request its policies deny with decision `audit` and forwards it. Policies with `@path` set the mode of their path, and policies without it set the mode of the path-less endpoint. Audit policies still decide requests like enforced ones, on every path of their endpoint: as YAML applies the rules of every matching endpoint, an audit policy's `forbid` blocks a request routed to an enforced path of the same `host:port`, and its `permit` allows one. The routed path's mode alone decides whether a denial blocks, even when the denying policy is an enforced one attached to another path. OpenShell warns when it loads or validates a policy with an audit path beside enforced policies. + +Add `@enforcement("staged")` to try a policy without changing anything. A staged policy never changes a path's mode and never takes part in a decision. OpenShell logs each request whose decision it would change, with `cedar_audit:staged_deny` or `cedar_audit:staged_allow` and the policies that would decide it. Use a staged `forbid` to see what a new restriction would block before you enforce it: + +```cedar +@enforcement("staged") +@id("no-webhook-changes") +forbid ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.path like("/repos/*/*/hooks/**", "/") }; +``` + +Logs name policies by their `@id` annotation when they have one. + +## Providers and Credentials + +Providers attached to a Cedar sandbox supply credentials but never grant access. The Cedar policy alone decides which endpoints a process can reach and which addresses they may resolve to. A provider endpoint's `allowed_ips` can only narrow the addresses its connections reach (see [Destination Addresses](#destination-addresses)), and a provider endpoint without a host supplies no credentials. Attaching a provider makes its credentials available on connections that Cedar already allows to the provider's endpoints, with the provider's credential settings, such as request signing and body rewriting. + +A provider endpoint that carries credentials must be inspected per request, so write an `HttpRequest` policy for it. OpenShell refuses connections to a credentialed endpoint that Cedar does not inspect, unless the provider allows uninspected credentials. + +A provider's token grant applies to a request only when Cedar allows the request and the provider's own endpoint also allows it under the provider's rules, as in a YAML sandbox. OpenShell compiles the provider rules to Cedar and evaluates them with the same request, and any provider's deny rule withholds every token. Provider rules can only withhold a token; they never allow a request that Cedar denies. A request denied on an audit path gets no token. + +A few provider rule forms cannot be expressed in Cedar, and OpenShell compiles them so they withhold tokens rather than grant them: allow rules with `?` in a glob, GraphQL `fields` globs, or an endpoint `path` glob other than `*` or a trailing `/**` grant no token, and deny rules with them withhold tokens more broadly; and a glob with `[`, `{`, or `\` withholds every token. The sandbox logs each such rule when it loads the policy. JSON-RPC requests whose method is not uppercase and hash-only GraphQL persisted queries also get no token. + +`openshell policy get --full` shows the provider rules delivered to the sandbox in a separate section after the Cedar text and middleware, with a list of the endpoints that carry credentials. `--base` omits them. + +```cedar +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.binary_path == "/usr/bin/gh" }; + +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { context.method == "GET" }; +``` + +## Limitations + +- A sandbox cannot switch between YAML and Cedar after creation. Recreate the sandbox to change formats. +- Global policies must be YAML. +- Incremental `openshell policy update` merges are not available for Cedar policies. Use `openshell policy set` with the full policy. +- Policy templates are not supported. + +## Next Steps + +- The example policies in the [OpenShell repository](https://github.com/NVIDIA/OpenShell/tree/main/examples) have a Cedar version beside each YAML policy, and their scripts accept `POLICY_FORMAT=cedar`. + +- [Policies Overview](/how-it-works/policies/overview) covers the YAML policy format. +- [Policy Schema Reference](/how-it-works/policies/schema) lists every YAML field. +- [Supervisor Middleware Configuration](/extensibility/supervisor-middleware/configure) describes middleware fields and failure behavior. +- [Policy Schema Reference](/how-it-works/policies/schema) describes the endpoint fields that `endpoint_settings` reuses. diff --git a/docs/index.yml b/docs/index.yml index 596ac87172..007ff44808 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -75,6 +75,9 @@ navigation: - page: "Schema Reference" path: how-it-works/policies/schema.mdx slug: schema + - page: "Cedar Policies" + path: how-it-works/policies/cedar.mdx + slug: cedar - page: "Inference" path: how-it-works/inference.mdx - section: "Extensibility" diff --git a/e2e/cedar/README.md b/e2e/cedar/README.md new file mode 100644 index 0000000000..ecd0b4ca83 --- /dev/null +++ b/e2e/cedar/README.md @@ -0,0 +1,64 @@ + + + +# Cedar policy end-to-end test + +Runs a sandbox whose policy is written in pure Cedar and checks that every +layer enforces it: + +| Area | Source | Checks | +|---|---|---| +| Network | `policy.template.cedar` | Permitted `GET` reaches the upstream; other paths, a path one segment deeper than a delimited glob allows, other methods, a `forbid`, another binary, and an unlisted host are denied. A delimited host glob (`*.github.com`) admits `api.github.com` through policy DNS but not `github.com`. A `@enforcement("staged")` `forbid` lets its request through and is logged. | +| Policy update | `policy set --wait` with an extended `policy.template.cedar` | The running sandbox loads a changed Cedar policy, with the middleware file passed again: a path the first policy denied is then permitted, and a path it permitted is then forbidden. | +| Middleware | `middleware.template.yaml` via `--middleware` | The `openshell/regex` middleware redacts a secret in the request body before it reaches the upstream. A non-2xx upstream response is retried up to three times and then reported as an upstream failure, not an OpenShell decision. | +| Filesystem | `policy.template.cedar` | A `WriteFile` grant makes `/sandbox` writable, a `ReadFile` grant makes `/opt` readable, and `/srv` stays unreadable. | +| Syscalls | sandbox runtime | `memfd_create`, `ptrace`, user-namespace `unshare`, `mount`, `io_uring_setup`, raw netlink sockets, and direct TCP connects that bypass the proxy return `EPERM`. | + +Syscall filtering is not authored policy, in YAML or Cedar. The syscall checks +confirm that the sandbox's seccomp restrictions still apply when the policy is +Cedar. Middleware is configuration rather than access control, so it is passed +as a separate YAML file instead of being part of the Cedar policy. + +The network checks use `echo-server.py`, a small HTTP server the test starts +on the host and reaches at `host.openshell.internal`, so the suite does not +depend on the internet except for confirming that `example.com` is +unreachable and that the host glob check can reach `api.github.com`. + +The sandbox only trusts `host.openshell.internal` when it maps to the host +gateway address the gateway configured, as in CI. Where it does not (for +example a gateway started with `mise run gateway:docker` on macOS), the suite +needs an echo service that reflects request bodies. On macOS it defaults to +`https://httpbin.org/anything`, so it needs internet access there. On other +platforms, select one yourself: + +```shell +CEDAR_E2E_UPSTREAM_URL=https://httpbin.org/anything bash e2e/cedar/test.sh +``` + +The test substitutes that URL's host, port, and path into the Cedar policy and +the middleware selector. + +## Run it + +Against an ephemeral Docker gateway: + +```shell +mise run e2e:cedar +``` + +The suite is also part of `mise run e2e`, and CI runs it as the `cedar` entry of +the Docker E2E matrix. When `OPENSHELL_BIN` is set, as in CI, the task uses that +CLI instead of building one. + +Against a gateway you already started with `mise run gateway:docker`: + +```shell +OPENSHELL_GATEWAY= \ +OPENSHELL_BIN="$PWD/target/debug/openshell" \ +bash e2e/cedar/test.sh +``` + +Set `CEDAR_E2E_KEEP_SANDBOX=1` to keep the sandbox for debugging. The test +needs `python3` on the host and a sandbox image that provides `curl`, +`python3`, and the `/sandbox`, `/opt`, and `/srv` directories. `mise run +e2e:cedar` builds and uses the shared `openshell/e2e-python:dev` workload image. diff --git a/e2e/cedar/echo-server.py b/e2e/cedar/echo-server.py new file mode 100644 index 0000000000..5c1fc70858 --- /dev/null +++ b/e2e/cedar/echo-server.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Host-side HTTP server for the Cedar e2e suite. + +Answers every request with JSON describing the method, path, and body it +received, so the test can see exactly what reached the upstream. Binds an +ephemeral port on all interfaces and writes the port to the path given as the +first argument. +""" + +import json +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class EchoHandler(BaseHTTPRequestHandler): + def _echo(self) -> None: + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length).decode("utf-8", "replace") if length else "" + payload = json.dumps( + {"method": self.command, "path": self.path, "body": body} + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + do_GET = _echo + do_POST = _echo + do_PUT = _echo + do_DELETE = _echo + + def log_message(self, format: str, *args: object) -> None: + return + + +def main() -> None: + server = ThreadingHTTPServer(("0.0.0.0", 0), EchoHandler) + with open(sys.argv[1], "w", encoding="utf-8") as port_file: + port_file.write(str(server.server_address[1])) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/e2e/cedar/middleware.template.yaml b/e2e/cedar/middleware.template.yaml new file mode 100644 index 0000000000..31fefb2673 --- /dev/null +++ b/e2e/cedar/middleware.template.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Middleware configuration for the Cedar e2e suite, passed with --middleware. +# test.sh replaces __HOST__ with the upstream echo service's host. +# Middleware is configuration rather than policy, so it is not part of the +# Cedar file. +network_middlewares: + redact-keys: + name: Redact API keys + middleware: openshell/regex + order: 10 + config: + mode: redact + on_error: fail_closed + endpoints: + include: ["__HOST__"] diff --git a/e2e/cedar/policy.template.cedar b/e2e/cedar/policy.template.cedar new file mode 100644 index 0000000000..9da1215d92 --- /dev/null +++ b/e2e/cedar/policy.template.cedar @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar policy for the Cedar e2e suite. test.sh replaces __HOST__, __PORT__, +// and __PREFIX__ with the upstream echo service's host, port, and path prefix. + +// Filesystem: Landlock grants. The supervisor adds its baseline paths +// (/usr, /lib, /etc, /proc, /tmp, ...) on top of these, so the test only +// asserts on paths outside that baseline. +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource in Sandbox::FilesystemPath::"/sandbox" +); + +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource in Sandbox::FilesystemPath::"/opt" +); + +// Network: only curl may connect to the upstream. +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"__HOST__:__PORT__" +) +when { context.binary_path == "/usr/bin/curl" }; + +// Network: curl may reach any single-label subdomain of github.com. The +// delimited host glob also makes those hosts resolvable through policy DNS. +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource is Sandbox::NetworkEndpoint +) +when { + resource.host like("*.github.com", ".") + && resource.port == 443 + && context.binary_path == "/usr/bin/curl" +}; + +// Requests: GET one path segment under /allowed/, and POST only to +// /allowed/echo. The `/` delimiter keeps `*` within one segment. +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"__HOST__:__PORT__" +) +when { + (context.method == "GET" && context.path like("__PREFIX__/allowed/*", "/")) + || (context.method == "POST" && context.path == "__PREFIX__/allowed/echo") +}; + +// A forbid overrides the permit above. +forbid ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"__HOST__:__PORT__" +) +when { context.path == "__PREFIX__/allowed/secret" }; + +// A staged restriction: logged, but never part of the decision. +@enforcement("staged") +@id("staged-no-staged-path") +forbid ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"__HOST__:__PORT__" +) +when { context.path == "__PREFIX__/allowed/staged" }; diff --git a/e2e/cedar/test.sh b/e2e/cedar/test.sh new file mode 100755 index 0000000000..7073de90f2 --- /dev/null +++ b/e2e/cedar/test.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# End-to-end test for a sandbox whose policy is written in pure Cedar. +# +# Network and filesystem access come from policy.template.cedar. Middleware is +# configuration, not policy, so it comes from middleware.template.yaml through +# --middleware. Syscall filtering is not authored policy; the test checks that +# the sandbox's seccomp restrictions still hold when the policy is Cedar. +# +# Prereqs: a running gateway (see README.md) and python3 on the host. + +set -euo pipefail +export NO_COLOR=1 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +OPENSHELL_BIN="${OPENSHELL_BIN:-${REPO_ROOT}/target/debug/openshell}" +# Sandbox names are limited to 19 characters. +RUN_ID="${RUN_ID:-$(date +%H%M%S)}" +SANDBOX="${SANDBOX:-cedar-e2e-${RUN_ID}}" +TMP_DIR="$(mktemp -d)" +SERVER_PID="" +FAILURES=0 + +cleanup() { + if [[ "${CEDAR_E2E_KEEP_SANDBOX:-0}" != "1" ]]; then + "$OPENSHELL_BIN" sandbox delete "$SANDBOX" >/dev/null 2>&1 || true + fi + if [[ -n "$SERVER_PID" ]]; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + fi + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +pass() { printf 'PASS %s\n' "$1"; } +fail() { + printf 'FAIL %s\n' "$1" + if [[ $# -gt 1 ]]; then + printf ' %s\n' "$2" + fi + FAILURES=$((FAILURES + 1)) +} + +# Asserts that $2 equals $3. +expect_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected '$3', got '$2'"; fi +} + +# Asserts that $2 contains $3. +expect_contains() { + if [[ "$2" == *"$3"* ]]; then pass "$1"; else fail "$1" "expected to find '$3' in: $2"; fi +} + +# Runs a shell command inside the sandbox, returning its output and status. +in_sandbox() { + # Without &1 /tmp/cedar-e2e.py && python3 /tmp/cedar-e2e.py" | tail -1 +} + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + +# By default the upstream is echo-server.py on this host, reached through the +# host gateway alias. CEDAR_E2E_UPSTREAM_URL selects another echo service that +# reflects request bodies, such as https://httpbin.org/anything, for hosts +# where the alias is not trusted. On macOS the Docker driver never trusts the +# alias, so that is the default there. +if [[ -z "${CEDAR_E2E_UPSTREAM_URL:-}" && "$(uname -s)" == "Darwin" ]]; then + CEDAR_E2E_UPSTREAM_URL="https://httpbin.org/anything" +fi +if [[ -n "${CEDAR_E2E_UPSTREAM_URL:-}" ]]; then + ECHO_URL="${CEDAR_E2E_UPSTREAM_URL%/}" +else + python3 "${SCRIPT_DIR}/echo-server.py" "${TMP_DIR}/port" & + SERVER_PID=$! + for _ in $(seq 1 50); do + [[ -s "${TMP_DIR}/port" ]] && break + sleep 0.1 + done + ECHO_URL="http://host.openshell.internal:$(cat "${TMP_DIR}/port")" +fi +read -r UP_HOST UP_PORT UP_PREFIX < <(python3 - "$ECHO_URL" <<'PY' +import sys +from urllib.parse import urlsplit + +url = urlsplit(sys.argv[1]) +port = url.port or (443 if url.scheme == "https" else 80) +print(url.hostname, port, url.path.rstrip("/") or "") +PY +) + +sed -e "s|__HOST__|${UP_HOST}|g" -e "s|__PORT__|${UP_PORT}|g" -e "s|__PREFIX__|${UP_PREFIX}|g" \ + "${SCRIPT_DIR}/policy.template.cedar" > "${TMP_DIR}/policy.cedar" +sed -e "s|__HOST__|${UP_HOST}|g" \ + "${SCRIPT_DIR}/middleware.template.yaml" > "${TMP_DIR}/middleware.yaml" + +"$OPENSHELL_BIN" sandbox create \ + --name "$SANDBOX" \ + --policy "${TMP_DIR}/policy.cedar" \ + --middleware "${TMP_DIR}/middleware.yaml" \ + --no-auto-providers \ + --no-tty \ + --detach \ + -- sh -c "exec sleep infinity" >/dev/null + +ready=0 +for _ in $(seq 1 60); do + if "$OPENSHELL_BIN" sandbox exec --name "$SANDBOX" --no-tty -- true >/dev/null 2>&1 &2 + "$OPENSHELL_BIN" logs "$SANDBOX" 2>&1 | tail -40 >&2 || true + exit 1 +fi + +# --------------------------------------------------------------------------- +# Network: NetworkConnect and HttpRequest decisions +# --------------------------------------------------------------------------- + +status() { + capture_in_sandbox "curl -s -o /dev/null -w '%{http_code}' --max-time 20 $1 '${ECHO_URL}$2'" | tail -1 +} + +expect_eq "network: permitted GET reaches upstream" "$(status "" /allowed/ok)" "200" +expect_eq "network: path outside the permit is denied" "$(status "" /blocked)" "403" +expect_eq "network: method outside the permit is denied" "$(status "-X DELETE" /allowed/ok)" "403" +expect_eq "network: forbid overrides permit" "$(status "" /allowed/secret)" "403" +expect_eq "network: delimited path glob stays within one segment" \ + "$(status "" /allowed/deeper/path)" "403" +expect_eq "network: a staged audit forbid does not deny" "$(status "" /allowed/staged)" "200" + +github_status() { + capture_in_sandbox "curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://$1/" | tail -1 +} +# Any HTTP status proves DNS resolution, the CONNECT decision, and the TLS +# handshake succeeded; 000 means the connection never got that far. +api_status="$(github_status api.github.com)" +if [[ "$api_status" =~ ^[1-5][0-9][0-9]$ ]]; then + pass "network: delimited host glob admits a matching subdomain ($api_status)" +else + fail "network: delimited host glob admits a matching subdomain" "got '$api_status'" +fi +expect_eq "network: delimited host glob does not match the parent domain" \ + "$(github_status github.com)" "000" + +cat > "${TMP_DIR}/urllib.py" </dev/null; then + fail "network: host outside the policy is unreachable" "curl reached example.com" +else + pass "network: host outside the policy is unreachable" +fi + +# --------------------------------------------------------------------------- +# Policy update: `policy set --wait` on the running sandbox +# --------------------------------------------------------------------------- + +# The updated policy permits GET /blocked, denied above, and forbids +# GET /allowed/ok, permitted above, so both decisions must flip. +cp "${TMP_DIR}/policy.cedar" "${TMP_DIR}/policy-updated.cedar" +cat >> "${TMP_DIR}/policy-updated.cedar" <&1)"; then + pass "policy set: the sandbox loads an updated Cedar policy" +else + fail "policy set: the sandbox loads an updated Cedar policy" "$policy_set_output" +fi +expect_eq "policy set: a newly permitted path reaches upstream" "$(status "" /blocked)" "200" +expect_eq "policy set: a newly forbidden path is denied" "$(status "" /allowed/ok)" "403" + +# --------------------------------------------------------------------------- +# Middleware: configured with --middleware +# --------------------------------------------------------------------------- + +# The check needs the upstream to echo the request body. A shared public +# upstream (CEDAR_E2E_UPSTREAM_URL) can be flaky, so a non-2xx response is +# retried before it is reported as an upstream failure. +echo_body="" +echo_status="" +for attempt in 1 2 3; do + echo_response="$(capture_in_sandbox "curl -s --max-time 20 -X POST -H 'Content-Type: text/plain' \ + --data 'key=sk-abcdefghijklmnopqrstuvwxyz' -w '\n%{http_code}' '${ECHO_URL}/allowed/echo'")" + echo_status="${echo_response##*$'\n'}" + echo_body="${echo_response%$'\n'*}" + if [[ "$echo_status" =~ ^2[0-9][0-9]$ ]]; then + break + fi + echo "upstream ${ECHO_URL} returned HTTP ${echo_status} on attempt ${attempt}/3" >&2 + sleep 2 +done +if [[ "$echo_status" =~ ^2[0-9][0-9]$ ]]; then + expect_contains "middleware: request body is redacted before upstream" "$echo_body" "[REDACTED]" +elif [[ "$echo_status" == "403" ]]; then + fail "middleware: request body is redacted before upstream" \ + "OpenShell denied the permitted POST ${ECHO_URL}/allowed/echo: $echo_body" +else + fail "middleware: request body is redacted before upstream" \ + "the upstream ${ECHO_URL}, not OpenShell, returned HTTP ${echo_status} on 3 attempts; the echo body could not be checked" +fi +if [[ "$echo_body" == *"sk-abcdefghijklmnopqrstuvwxyz"* ]]; then + fail "middleware: secret never reaches upstream" "$echo_body" +else + pass "middleware: secret never reaches upstream" +fi + +# --------------------------------------------------------------------------- +# Filesystem: Landlock grants derived from the Cedar policy +# --------------------------------------------------------------------------- + +for path in /sandbox /opt /srv; do + if ! in_sandbox "test -d $path" >/dev/null; then + echo "sandbox image has no $path directory; the filesystem checks need it" >&2 + exit 1 + fi +done + +expect_eq "filesystem: WriteFile grant allows writing /sandbox" \ + "$(capture_in_sandbox "echo cedar > /sandbox/cedar-e2e && cat /sandbox/cedar-e2e" | tail -1)" "cedar" +if in_sandbox "ls /opt" >/dev/null; then + pass "filesystem: ReadFile grant allows reading /opt" +else + fail "filesystem: ReadFile grant allows reading /opt" +fi +expect_contains "filesystem: path without a grant is unreadable" \ + "$(in_sandbox "ls /srv" || true)" "Permission denied" + +# --------------------------------------------------------------------------- +# Syscalls: seccomp restrictions apply under a Cedar policy +# --------------------------------------------------------------------------- + +cat > "${TMP_DIR}/syscalls.py" <<'EOF' +import ctypes +import errno +import json +import platform +import socket + +libc = ctypes.CDLL(None, use_errno=True) +# memfd_create syscall numbers, for C libraries without the wrapper. +MEMFD_CREATE = {"x86_64": 319, "aarch64": 279} +# io_uring_setup has the same number on every architecture. +IO_URING_SETUP = 425 + + +def errno_name(code): + return errno.errorcode.get(code, str(code)) + + +def libc_probe(call): + ctypes.set_errno(0) + if call() >= 0: + return "allowed" + return errno_name(ctypes.get_errno()) + + +def socket_probe(call): + try: + call() + return "allowed" + except OSError as error: + return errno_name(error.errno) + + +def memfd_create(): + name = b"cedar-e2e" + if hasattr(libc, "memfd_create"): + return libc.memfd_create(name, 0) + return libc.syscall(MEMFD_CREATE[platform.machine()], name, 0) + + +probes = { + "memfd_create": lambda: libc_probe(memfd_create), + "ptrace": lambda: libc_probe(lambda: libc.ptrace(0, 0, None, None)), # PTRACE_TRACEME + "unshare_user": lambda: libc_probe(lambda: libc.unshare(0x10000000)), # CLONE_NEWUSER + "mount": lambda: libc_probe(lambda: libc.mount(b"none", b"/tmp", b"tmpfs", 0, None)), + "io_uring_setup": lambda: libc_probe( + lambda: libc.syscall(IO_URING_SETUP, 1, ctypes.create_string_buffer(120)) + ), + "netlink_uevent": lambda: socket_probe( + lambda: socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, 15).close() + ), + "direct_connect": lambda: socket_probe( + lambda: socket.create_connection(("198.51.100.1", 80), timeout=10).close() + ), +} +results = {} +for name, probe in probes.items(): + try: + results[name] = probe() + except Exception as error: # A broken probe must not hide the others. + results[name] = f"error:{type(error).__name__}:{error}" +print(json.dumps(results)) +EOF +syscall_json="$(python_in_sandbox "${TMP_DIR}/syscalls.py")" + +for syscall in memfd_create ptrace unshare_user mount io_uring_setup netlink_uevent; do + result="$(printf '%s' "$syscall_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('$syscall','missing'))" 2>/dev/null || echo "unparsable")" + expect_eq "syscall: $syscall is denied" "$result" "EPERM" +done +direct="$(printf '%s' "$syscall_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('direct_connect','missing'))" 2>/dev/null || echo "unparsable")" +if [[ "$direct" == "EPERM" || "$direct" == "EACCES" ]]; then + pass "syscall: direct TCP connect bypassing the proxy is denied" +else + fail "syscall: direct TCP connect bypassing the proxy is denied" "got $direct from $syscall_json" +fi + +# --------------------------------------------------------------------------- +# Observability and policy view +# --------------------------------------------------------------------------- + +sandbox_logs="$("$OPENSHELL_BIN" logs "$SANDBOX" 2>&1 || true)" +expect_contains "logs: Cedar is recorded as the deciding engine" "$sandbox_logs" "engine:cedar" +expect_contains "logs: a staged audit forbid is logged" "$sandbox_logs" "cedar_audit:staged_deny" + +policy_view="$("$OPENSHELL_BIN" policy get "$SANDBOX" --full 2>&1 || true)" +expect_contains "policy get: shows the Cedar policy" "$policy_view" 'Sandbox::Action::"NetworkConnect"' +expect_contains "policy get: shows middleware as its own section" "$policy_view" "# Middleware" + +echo +if [[ "$FAILURES" -gt 0 ]]; then + echo "${FAILURES} check(s) failed" + exit 1 +fi +echo "All Cedar e2e checks passed" diff --git a/e2e/mcp-conformance.sh b/e2e/mcp-conformance.sh index a9e912e34a..6ada283181 100755 --- a/e2e/mcp-conformance.sh +++ b/e2e/mcp-conformance.sh @@ -16,6 +16,8 @@ CONFORMANCE_REF="${OPENSHELL_MCP_CONFORMANCE_REF:-b9041ea41b0188581803459dbae71b CLIENT_IMAGE="${OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE:-openshell-mcp-conformance-client:local}" SCENARIOS="${OPENSHELL_MCP_CONFORMANCE_SCENARIOS:-}" SPEC_VERSION="${OPENSHELL_MCP_CONFORMANCE_SPEC_VERSION:-2025-11-25}" +# POLICY_FORMAT=cedar runs the client sandbox with policy-template.cedar. +export POLICY_FORMAT="${POLICY_FORMAT:-yaml}" TIMEOUT_MS="${OPENSHELL_MCP_CONFORMANCE_TIMEOUT_MS:-900000}" FORCE_REBUILD="${OPENSHELL_MCP_CONFORMANCE_FORCE_REBUILD:-0}" DOCKER_PULL="${OPENSHELL_MCP_CONFORMANCE_DOCKER_PULL:-0}" @@ -303,7 +305,7 @@ build_client_image() { pull_args=(--pull) fi - docker build "${pull_args[@]}" \ + docker build ${pull_args[@]+"${pull_args[@]}"} \ --label "${CLIENT_IMAGE_REF_LABEL}=${conformance_head}" \ --label "${CLIENT_IMAGE_DOCKERFILE_LABEL}=${dockerfile_hash}" \ --label "${CLIENT_IMAGE_DOCKERIGNORE_LABEL}=${dockerignore_hash}" \ @@ -319,32 +321,43 @@ create_client_sandbox() { return fi - local sandbox_name policy_file openshell + local sandbox_name policy_file middleware_file openshell + local -a render_args middleware_args sandbox_name="mcp-client-$$" - policy_file="$(mktemp "${TMPDIR:-/tmp}/openshell-mcp-conformance-base-policy.XXXXXX.yaml")" + policy_file="$(mktemp "${TMPDIR:-/tmp}/openshell-mcp-conformance-base-policy.XXXXXX.${POLICY_FORMAT}")" + middleware_file="" + middleware_args=() openshell="$(openshell_bin)" + render_args=( + "http://192.0.2.1:1/" "${policy_file}" + "${ROOT}/e2e/mcp-conformance/policy-template.${POLICY_FORMAT}" + "${SPEC_VERSION}" + ) + if [ "${POLICY_FORMAT}" = "cedar" ]; then + middleware_file="$(mktemp "${TMPDIR:-/tmp}/openshell-mcp-conformance-base-middleware.XXXXXX.yaml")" + render_args+=("${middleware_file}" "${ROOT}/e2e/mcp-conformance/middleware-template.yaml") + middleware_args=(--middleware "${middleware_file}") + fi # The upstream runner binds its per-scenario test server with listen(0), and # the port can be outside the OS ephemeral range. Create the reusable sandbox # with a harmless placeholder; the client wrapper installs the exact policy # for each scenario URL before executing the TypeScript client. - python3 "${ROOT}/e2e/mcp-conformance/render-policy.py" \ - "http://192.0.2.1:1/" "${policy_file}" \ - "${ROOT}/e2e/mcp-conformance/policy-template.yaml" \ - "${SPEC_VERSION}" >/dev/null + python3 "${ROOT}/e2e/mcp-conformance/render-policy.py" "${render_args[@]}" >/dev/null echo "Creating MCP conformance client sandbox ${sandbox_name}..." >&2 if ! "${openshell}" sandbox create \ --name "${sandbox_name}" \ --from "${CLIENT_IMAGE}" \ --policy "${policy_file}" \ + ${middleware_args[@]+"${middleware_args[@]}"} \ --no-tty \ --detach \ -- sleep infinity; then - rm -f "${policy_file}" + rm -f "${policy_file}" "${middleware_file}" return 1 fi - rm -f "${policy_file}" + rm -f "${policy_file}" "${middleware_file}" export OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX="${sandbox_name}" export OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT="${OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT:-1}" @@ -390,7 +403,7 @@ create_runner_container() { if ! docker run -d --rm \ --name "${RUNNER_CONTAINER}" \ --network "${network}" \ - "${add_host_args[@]}" \ + ${add_host_args[@]+"${add_host_args[@]}"} \ "${CLIENT_IMAGE}" \ sleep infinity >/dev/null; then RUNNER_CONTAINER="" diff --git a/e2e/mcp-conformance/README.md b/e2e/mcp-conformance/README.md index ca95cc3a17..518a2ac527 100644 --- a/e2e/mcp-conformance/README.md +++ b/e2e/mcp-conformance/README.md @@ -35,7 +35,7 @@ bridge at `host.openshell.internal` (the alias `e2e/with-docker-gateway.sh` attaches to the CI job container on the e2e network), at `host.docker.internal` on local Docker Desktop, or via `--add-host ...:host-gateway` on local Linux. -The generated policy uses `protocol: mcp`, inserts the conformance runner's spec revision into the endpoint allowlist, and sets `mcp.allow_all_known_mcp_methods: true` so omitted rule methods use the selected MCP method profile. The renderer accepts OpenShell's supported revisions, `2025-03-26`, `2025-06-18`, `2025-11-25`, and `2026-07-28`. The policy body lives in `policy-template.yaml`; the wrapper renders its MCP revision, host, port, and path placeholders from the upstream server URL. +The generated policy uses `protocol: mcp`, inserts the conformance runner's spec revision into the endpoint allowlist, and sets `mcp.allow_all_known_mcp_methods: true` so omitted rule methods use the selected MCP method profile. The renderer accepts OpenShell's supported revisions, `2025-03-26`, `2025-06-18`, `2025-11-25`, and `2026-07-28`. The policy body lives in `policy-template.yaml`; the wrapper renders its MCP revision, host, port, and path placeholders from the upstream server URL. Set `POLICY_FORMAT=cedar` to run the client sandbox with `policy-template.cedar` instead; the MCP revision and body limit then come from `middleware-template.yaml` as endpoint settings. OpenShell checks each request against the policy revision and delegates JSON-RPC structure and MCP method, direction, message-kind, parameter, and metadata type checks to `tower-mcp-types`, pinned to `0.22.2`. These checks follow Tower's deserialization and inspection APIs and do not establish complete JSON-schema conformance. OpenShell owns revision allowlisting, HTTP/body consistency, request limits, and policy enforcement. For the 2025 revisions, a valid standalone `initialize` proposes a version; later requests select their revision through `MCP-Protocol-Version`, with `2025-03-26` as the missing-header fallback. The sessionless `2026-07-28` profile carries one JSON-RPC request or explicitly allowed extension notification per POST. Requests require per-request metadata and matching protocol-version, method, and applicable name headers. Extension notifications require an exact method allow rule and the version header, but no request metadata or method/name mirrors. Responses and SSE payloads are relayed without policy parsing. The conformance runner and its reference client exercise behavior beyond these request inspection checks. diff --git a/e2e/mcp-conformance/client-through-openshell.sh b/e2e/mcp-conformance/client-through-openshell.sh index a3ab20c0c5..4470b3e112 100755 --- a/e2e/mcp-conformance/client-through-openshell.sh +++ b/e2e/mcp-conformance/client-through-openshell.sh @@ -47,7 +47,17 @@ require_active_gateway() { SERVER_URL="$1" CLIENT_SANDBOX="${OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX:?set OPENSHELL_MCP_CONFORMANCE_CLIENT_SANDBOX to the reusable conformance client sandbox name}" -POLICY_TEMPLATE="${ROOT}/e2e/mcp-conformance/policy-template.yaml" +# POLICY_FORMAT=cedar renders policy-template.cedar and its endpoint settings +# instead of policy-template.yaml. The client sandbox keeps one format. +POLICY_FORMAT="${POLICY_FORMAT:-yaml}" +case "${POLICY_FORMAT}" in + yaml | cedar) ;; + *) + echo "ERROR: POLICY_FORMAT must be yaml or cedar, not ${POLICY_FORMAT}" >&2 + exit 2 + ;; +esac +POLICY_TEMPLATE="${ROOT}/e2e/mcp-conformance/policy-template.${POLICY_FORMAT}" POLICY_WAIT="${OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT:-0}" POLICY_WAIT_TIMEOUT="${OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT_TIMEOUT:-60}" CLIENT_TIMEOUT_SECONDS="${OPENSHELL_MCP_CONFORMANCE_CLIENT_TIMEOUT_SECONDS:-120}" @@ -55,10 +65,17 @@ MCP_VERSION="${MCP_CONFORMANCE_PROTOCOL_VERSION:-${OPENSHELL_MCP_CONFORMANCE_SPE require_active_gateway -POLICY_FILE="$(mktemp "${TMPDIR:-/tmp}/openshell-mcp-conformance-policy.XXXXXX.yaml")" -trap 'rm -f "${POLICY_FILE}"' EXIT +POLICY_FILE="$(mktemp "${TMPDIR:-/tmp}/openshell-mcp-conformance-policy.XXXXXX.${POLICY_FORMAT}")" +RENDER_ARGS=("${SERVER_URL}" "${POLICY_FILE}" "${POLICY_TEMPLATE}" "${MCP_VERSION}") +MIDDLEWARE_ARGS=() +if [ "${POLICY_FORMAT}" = "cedar" ]; then + MIDDLEWARE_FILE="$(mktemp "${TMPDIR:-/tmp}/openshell-mcp-conformance-middleware.XXXXXX.yaml")" + RENDER_ARGS+=("${MIDDLEWARE_FILE}" "${ROOT}/e2e/mcp-conformance/middleware-template.yaml") + MIDDLEWARE_ARGS=(--middleware "${MIDDLEWARE_FILE}") +fi +trap 'rm -f "${POLICY_FILE}" "${MIDDLEWARE_FILE:-}"' EXIT -CLIENT_SERVER_URL="$(python3 "${ROOT}/e2e/mcp-conformance/render-policy.py" "${SERVER_URL}" "${POLICY_FILE}" "${POLICY_TEMPLATE}" "${MCP_VERSION}")" +CLIENT_SERVER_URL="$(python3 "${ROOT}/e2e/mcp-conformance/render-policy.py" "${RENDER_ARGS[@]}")" ENV_ARGS=() @@ -75,6 +92,7 @@ done POLICY_SET_COMMAND=( "${OPENSHELL_BIN}" policy set "${CLIENT_SANDBOX}" --policy "${POLICY_FILE}" + ${MIDDLEWARE_ARGS[@]+"${MIDDLEWARE_ARGS[@]}"} ) if [ "${POLICY_WAIT}" = "1" ]; then POLICY_SET_COMMAND+=(--wait --timeout "${POLICY_WAIT_TIMEOUT}") diff --git a/e2e/mcp-conformance/host-bridge.py b/e2e/mcp-conformance/host-bridge.py index e2b59a7d92..356c0410be 100644 --- a/e2e/mcp-conformance/host-bridge.py +++ b/e2e/mcp-conformance/host-bridge.py @@ -60,6 +60,7 @@ "OPENSHELL_MCP_CONFORMANCE_POLICY_WAIT_TIMEOUT", "OPENSHELL_PROVISION_TIMEOUT", "PATH", + "POLICY_FORMAT", "RUSTUP_HOME", "TMP", "TEMP", diff --git a/e2e/mcp-conformance/middleware-template.yaml b/e2e/mcp-conformance/middleware-template.yaml new file mode 100644 index 0000000000..1eb61afa0b --- /dev/null +++ b/e2e/mcp-conformance/middleware-template.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Endpoint settings and process identity for policy-template.cedar, passed +# with --middleware when POLICY_FORMAT=cedar. The endpoint settings are the +# `mcp` options of the YAML endpoint, which configure inspection rather than +# grant access, and `process` is the YAML policy's process section. +# render-policy.py substitutes the placeholders. + +process: + run_as_user: sandbox + run_as_group: sandbox + +endpoint_settings: + - host: ${host_json} + port: ${port} + mcp: + versions: [${mcp_version}] + max_body_bytes: 131072 diff --git a/e2e/mcp-conformance/policy-template.cedar b/e2e/mcp-conformance/policy-template.cedar new file mode 100644 index 0000000000..51112fdc2b --- /dev/null +++ b/e2e/mcp-conformance/policy-template.cedar @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy-template.yaml, used when POLICY_FORMAT=cedar. +// render-policy.py substitutes $${host}, $${port}, and $${path} (a quoted +// string) from the conformance server URL. The MCP revision and body limit +// are endpoint settings, rendered from middleware-template.yaml. +// +// Cedar has no `process` section; the client runs as `sandbox` like the YAML +// policy because middleware-template.yaml carries the process identity. +// Landlock compatibility uses its default, best_effort. A YAML binary path also +// matches an ancestor process and a symlink to the binary, so each binary +// condition tests all three. The YAML binary glob +// `/opt/mcp-conformance/node_modules/.bin/*` also matches an ancestor +// process; `context.binary_globs` lists the patterns that match the calling +// binary or an ancestor, so the condition tests it there. + +// filesystem_policy.read_write. Cedar has no include_workdir; the client +// image's workdir, /opt/mcp-conformance, is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" + || resource in Sandbox::FilesystemPath::"/home/sandbox" + || resource in Sandbox::FilesystemPath::"/opt/mcp-conformance" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/bin" + || resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/lib64" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/sys" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/opt" + || resource in Sandbox::FilesystemPath::"/var/log" +}; + +// network_policies.mcp_conformance: the client connects to the conformance +// server. `allowed_ips` is the destination_ip condition: every address the +// host resolves to must lie in one of the private ranges. +@id("mcp_conformance/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"${host}:${port}" +) +when { + (["/bin/sh", "/usr/bin/env", "/usr/local/bin/node", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/bin/sh", "/usr/bin/env", "/usr/local/bin/node", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/bin/sh", "/usr/bin/env", "/usr/local/bin/node", "/usr/bin/node"]) + || context.binary_globs.contains("/opt/mcp-conformance/node_modules/.bin/*")) + && context has destination_ip + && (context.destination_ip.isInRange(ip("10.0.0.0/8")) + || context.destination_ip.isInRange(ip("172.16.0.0/12")) + || context.destination_ip.isInRange(ip("192.168.0.0/16")) + || context.destination_ip.isInRange(ip("fc00::/7"))) +}; + +// `protocol: mcp`, `enforcement: enforce`, `mcp.allow_all_known_mcp_methods`: +// every core method of the selected MCP revision is allowed, and extension +// methods are not. +@id("mcp_conformance/methods") +@path(${path}) +@protocol("mcp") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"${host}:${port}" +) +when { + (["/bin/sh", "/usr/bin/env", "/usr/local/bin/node", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/bin/sh", "/usr/bin/env", "/usr/local/bin/node", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/bin/sh", "/usr/bin/env", "/usr/local/bin/node", "/usr/bin/node"]) + || context.binary_globs.contains("/opt/mcp-conformance/node_modules/.bin/*")) + && context.path == ${path} + && !context.jsonrpc_response + && context.mcp_method_class == "available" +}; + +// An MCP endpoint in YAML also allows the receive stream and client response +// frames, such as answers to elicitation/create; Cedar permits them +// explicitly. +@id("mcp_conformance/streams") +@path(${path}) +@protocol("mcp") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"${host}:${port}" +) +when { + (["/bin/sh", "/usr/bin/env", "/usr/local/bin/node", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/bin/sh", "/usr/bin/env", "/usr/local/bin/node", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/bin/sh", "/usr/bin/env", "/usr/local/bin/node", "/usr/bin/node"]) + || context.binary_globs.contains("/opt/mcp-conformance/node_modules/.bin/*")) + && context.path == ${path} + && ((context.method == "GET" && context.jsonrpc_receive_stream) + || context.jsonrpc_response) +}; diff --git a/e2e/mcp-conformance/render-policy.py b/e2e/mcp-conformance/render-policy.py index acd8e4a087..0df836a240 100644 --- a/e2e/mcp-conformance/render-policy.py +++ b/e2e/mcp-conformance/render-policy.py @@ -10,7 +10,12 @@ client sandbox with a placeholder policy and to install the per-scenario policy in client-through-openshell.sh. +A Cedar template (`.cedar`) has no room for endpoint settings, so the MCP +revision and body limit go to a middleware file rendered from a second +template; pass both as the last two arguments. + Usage: render-policy.py + [ ] """ import json @@ -21,13 +26,16 @@ from pathlib import Path from urllib.parse import urlparse, urlunparse -if len(sys.argv) != 5: +if len(sys.argv) not in (5, 7): raise SystemExit( "usage: render-policy.py " - " " + " [ ]" ) raw_url, policy_file, policy_template, mcp_version = sys.argv[1:5] +middleware_file, middleware_template = ( + sys.argv[5:7] if len(sys.argv) == 7 else (None, None) +) # Keep this boundary check synchronized with McpProtocolVersion::ALL. The # renderer is standalone Python, so it cannot import the Rust registry. supported_mcp_versions = {"2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"} @@ -75,13 +83,21 @@ (parsed.scheme, netloc, path, parsed.params, parsed.query, parsed.fragment) ) +values = { + # YAML template fields. + "host_spec": f"host: {json.dumps(target_host)}", + "port_spec": f" port: {port}", + # Cedar and middleware template fields. + "host": target_host, + "host_json": json.dumps(target_host), + "port": str(port), + "path": json.dumps(path), + "mcp_version": json.dumps(mcp_version), +} 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}", - path=json.dumps(path), - mcp_version=json.dumps(mcp_version), -) -Path(policy_file).write_text(policy, encoding="utf-8") +Path(policy_file).write_text(template.substitute(values), encoding="utf-8") +if middleware_file is not None and middleware_template is not None: + template = string.Template(Path(middleware_template).read_text(encoding="utf-8")) + Path(middleware_file).write_text(template.substitute(values), encoding="utf-8") print(rewritten) diff --git a/e2e/policy-advisor/README.md b/e2e/policy-advisor/README.md index 14b383cb44..874624e76d 100644 --- a/e2e/policy-advisor/README.md +++ b/e2e/policy-advisor/README.md @@ -49,6 +49,12 @@ DEMO_GITHUB_REPO=openshell-policy-demo \ bash e2e/policy-advisor/test.sh ``` +Set `POLICY_FORMAT=cedar` to start the sandbox from `policy.template.cedar`, +with `middleware.yaml` passed as `--middleware` to carry the process identity +the YAML policy sets. A Cedar policy accepts no merged rules, so in that mode +the test checks that the gateway rejects the proposal and skips the approval +and retry. + Requires Docker, `agent_policy_proposals_enabled=true`, and a GitHub token with contents write on the repository. The test auto-resolves the token from `DEMO_GITHUB_TOKEN`, `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`. diff --git a/e2e/policy-advisor/middleware.yaml b/e2e/policy-advisor/middleware.yaml new file mode 100644 index 0000000000..51fb3c4916 --- /dev/null +++ b/e2e/policy-advisor/middleware.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Process identity for policy.template.cedar, passed with --middleware when +# POLICY_FORMAT=cedar. A Cedar policy has no `process` section; this is the +# `process` section of policy.template.yaml. + +process: + run_as_user: sandbox + run_as_group: sandbox diff --git a/e2e/policy-advisor/policy.template.cedar b/e2e/policy-advisor/policy.template.cedar new file mode 100644 index 0000000000..a2a6eb4b60 --- /dev/null +++ b/e2e/policy-advisor/policy.template.cedar @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy.template.yaml: read-only REST access to +// api.github.com for curl. +// +// Cedar has no `process` section. policy.template.yaml sets run_as_user and +// run_as_group to `sandbox`; middleware.yaml carries the same identity, and +// test.sh passes it with --middleware. Landlock compatibility uses its +// default, best_effort. The agent-driven proposal loop that test.sh exercises +// merges YAML rules, so it needs the YAML policy. + +// filesystem_policy.read_write. Cedar has no include_workdir; /sandbox is the +// default workdir and is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/var/log" +}; + +// network_policies.github_api_readonly: curl connects to api.github.com:443. +// A YAML binary path also matches an ancestor process and a symlink to the +// binary, so the condition tests all three. +@id("github_api_readonly/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; + +// `protocol: rest`, `enforcement: enforce`, `access: read-only`: the preset +// allows GET, HEAD, and OPTIONS on every path. Naming the endpoint in an +// HttpRequest policy makes the proxy inspect each request. +@id("github_api_readonly/read-only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { + (context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl")) + && ["GET", "HEAD", "OPTIONS"].contains(context.method) +}; diff --git a/e2e/policy-advisor/test.sh b/e2e/policy-advisor/test.sh index 98854b52c9..2eddb7977b 100755 --- a/e2e/policy-advisor/test.sh +++ b/e2e/policy-advisor/test.sh @@ -7,7 +7,22 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" -POLICY_TEMPLATE="${SCRIPT_DIR}/policy.template.yaml" +# POLICY_FORMAT=cedar starts the sandbox from policy.template.cedar instead +# of policy.template.yaml, with middleware.yaml carrying the process identity +# the YAML policy's `process` section sets. A Cedar policy accepts no merged +# rules, so the test then checks that the gateway rejects the proposal instead +# of approving it. +POLICY_FORMAT="${POLICY_FORMAT:-yaml}" +MIDDLEWARE_ARGS=() +case "$POLICY_FORMAT" in + yaml) ;; + cedar) MIDDLEWARE_ARGS=(--middleware "${SCRIPT_DIR}/middleware.yaml") ;; + *) + printf 'POLICY_FORMAT must be yaml or cedar\n' >&2 + exit 2 + ;; +esac +POLICY_TEMPLATE="${SCRIPT_DIR}/policy.template.${POLICY_FORMAT}" RUNNER_SOURCE="${SCRIPT_DIR}/sandbox-runner.sh" if [[ -z "${OPENSHELL_BIN:-}" ]]; then @@ -216,7 +231,7 @@ Enable it with: create_temp_workspace() { TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openshell-agent-policy.XXXXXX")" - POLICY_FILE="${TMP_DIR}/policy.yaml" + POLICY_FILE="${TMP_DIR}/policy.${POLICY_FORMAT}" SSH_CONFIG="${TMP_DIR}/ssh_config" } @@ -228,6 +243,7 @@ create_sandbox() { --name "$DEMO_SANDBOX_NAME" \ --provider "$DEMO_GITHUB_PROVIDER_NAME" \ --policy "$POLICY_FILE" \ + ${MIDDLEWARE_ARGS[@]+"${MIDDLEWARE_ARGS[@]}"} \ --upload "${RUNNER_SOURCE}:/sandbox/policy-validation-runner.sh" \ --no-git-ignore \ --no-auto-providers @@ -328,6 +344,14 @@ submit_and_approve() { body="$(printf '%s\n' "$output" | http_body)" [[ "$status" == "202" ]] || fail "expected proposal submit HTTP 202, got HTTP $status" + if [[ "$POLICY_FORMAT" == "cedar" ]]; then + printf '%s\n' "$body" \ + | jq -e '(.accepted_chunks // 0) == 0 + and any(.rejection_reasons[]; contains("exactly one policy format"))' >/dev/null \ + || fail "expected the gateway to reject a proposal for a Cedar sandbox: $body" + info "${GREEN}Gateway rejected the proposal: a Cedar policy accepts no merged rules.${RESET}" + return + fi [[ "$(printf '%s\n' "$body" | jq -r '.accepted_chunks // 0')" != "0" ]] \ || fail "proposal was not accepted" printf '%s\n' "$body" | jq -r '"Proposal submitted: \(.accepted_chunks) accepted, \(.rejected_chunks) rejected"' | sed 's/^/ /' @@ -397,7 +421,9 @@ main() { run_policy_local_checks capture_initial_denial submit_and_approve - retry_until_allowed + if [[ "$POLICY_FORMAT" == "yaml" ]]; then + retry_until_allowed + fi show_logs printf "\n${BOLD}${GREEN}✓ Validation complete.${RESET}\n\n" diff --git a/examples/agent-driven-policy-management/README.md b/examples/agent-driven-policy-management/README.md index a0eaa63585..4ec1e6159b 100644 --- a/examples/agent-driven-policy-management/README.md +++ b/examples/agent-driven-policy-management/README.md @@ -93,6 +93,10 @@ rule for `api.github.com` plus the binary set Codex needs. The agent has to ask for the additional `PUT /repos/.../contents/...` write itself — that's the proposal you approve. +`policy.template.cedar` is the same starting policy in Cedar. The demo itself +needs YAML: approved proposals are merged into the sandbox policy, and the +gateway rejects proposals for a Cedar sandbox. + ## What gets approved (trust model) Every proposal lands in the gateway as a `PolicyChunk` — a structured object diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index 2cb10dedb9..27f5dc0439 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -15,7 +15,20 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" -POLICY_TEMPLATE="${SCRIPT_DIR}/policy.template.yaml" +# POLICY_FORMAT selects the starting policy: policy.template.yaml (default) +# or policy.template.cedar. The approval loop merges the agent's proposed +# YAML rules into the sandbox policy, and the gateway rejects proposals for a +# Cedar sandbox, so the demo runs only with YAML; POLICY_FORMAT=cedar stops +# with an explanation. +POLICY_FORMAT="${POLICY_FORMAT:-yaml}" +case "$POLICY_FORMAT" in + yaml | cedar) ;; + *) + printf 'POLICY_FORMAT must be yaml or cedar\n' >&2 + exit 2 + ;; +esac +POLICY_TEMPLATE="${SCRIPT_DIR}/policy.template.${POLICY_FORMAT}" TASK_TEMPLATE="${SCRIPT_DIR}/agent-task.md" SANDBOX_AGENT="${SCRIPT_DIR}/sandbox-agent.sh" @@ -52,7 +65,7 @@ DEMO_KEEP_SANDBOX="${DEMO_KEEP_SANDBOX:-0}" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openshell-policy-demo.XXXXXX")" PAYLOAD_DIR="${TMP_DIR}/payload" -POLICY_FILE="${TMP_DIR}/policy.yaml" +POLICY_FILE="${TMP_DIR}/policy.${POLICY_FORMAT}" AGENT_LOG="${TMP_DIR}/agent.log" mkdir -p "$PAYLOAD_DIR" @@ -216,6 +229,11 @@ resolve_codex_auth() { } validate_env() { + if [[ "$POLICY_FORMAT" == "cedar" ]]; then + fail "this demo approves agent proposals, which merge YAML rules into the sandbox policy; \ +the gateway rejects proposals for a Cedar sandbox. Run it with the default POLICY_FORMAT=yaml. \ +policy.template.cedar is the Cedar form of the starting policy: openshell sandbox create --policy ${POLICY_TEMPLATE}" + fi require_command curl require_command jq require_command "$OPENSHELL_BIN" diff --git a/examples/agent-driven-policy-management/policy.template.cedar b/examples/agent-driven-policy-management/policy.template.cedar new file mode 100644 index 0000000000..68668fa555 --- /dev/null +++ b/examples/agent-driven-policy-management/policy.template.cedar @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy.template.yaml, the initial policy of the +// agent-driven policy demo. It grants the same access, but the demo itself +// needs the YAML policy: approved proposals are YAML rules merged into the +// sandbox policy, and Cedar policies do not accept merges. +// +// Landlock compatibility (best_effort) and process identity use their +// defaults, as in policy.template.yaml. A YAML binary path also matches an +// ancestor process and a symlink to the binary, so each binary condition +// tests all three. The YAML binary glob `/usr/lib/node_modules/@openai/**` +// also matches an ancestor process; `context.binary_globs` lists the patterns +// that match the calling binary or an ancestor, so the condition tests it +// there. + +// filesystem_policy.read_write. Cedar has no include_workdir; /sandbox is the +// default workdir and is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/var/log" +}; + +// network_policies.codex: Codex and its Node runtime reach the OpenAI and +// ChatGPT endpoints. Each endpoint is `protocol: rest`, `enforcement: +// enforce`, `access: full`: one policy per endpoint allows the connection and +// every request on it, and naming the endpoint for HttpRequest makes the +// proxy inspect each request. +@id("codex/api.openai.com") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"api.openai.com:443" +) +when { + ["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**") +}; + +@id("codex/auth.openai.com") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"auth.openai.com:443" +) +when { + ["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**") +}; + +@id("codex/chatgpt.com") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"chatgpt.com:443" +) +when { + ["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**") +}; + +@id("codex/ab.chatgpt.com") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"ab.chatgpt.com:443" +) +when { + ["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/usr/bin/codex", "/sandbox/payload/codex", "/usr/bin/node"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**") +}; + +// network_policies.github_api_readonly: curl reads api.github.com. The +// `access: read-only` preset allows GET, HEAD, and OPTIONS on every path, so +// writes are denied. +@id("github_api_readonly/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; + +@id("github_api_readonly/read-only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { + (context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl")) + && ["GET", "HEAD", "OPTIONS"].contains(context.method) +}; + +// network_policies.github_raw_scoped: curl may GET one bootstrap path on +// raw.githubusercontent.com. A YAML GET rule also allows HEAD; Cedar lists +// HEAD explicitly. +@id("github_raw_scoped/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"raw.githubusercontent.com:443" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; + +@id("github_raw_scoped/rules") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"raw.githubusercontent.com:443" +) +when { + (context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl")) + && ["GET", "HEAD"].contains(context.method) + && context.path == "/github/rest-api-description/main/README.md" +}; diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml index 23dbd5037b..f560331801 100644 --- a/examples/governance-interceptor/Cargo.toml +++ b/examples/governance-interceptor/Cargo.toml @@ -13,7 +13,7 @@ license = "Apache-2.0" [dependencies] jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } openshell-core = { path = "../../crates/openshell-core", default-features = false } -openshell-policy = { path = "../../crates/openshell-policy" } +openshell-policy = { path = "../../crates/openshell-policy", default-features = false } openshell-providers = { path = "../../crates/openshell-providers" } prost = "0.14" prost-reflect = { version = "0.16.5", features = ["serde"] } diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 8a4b061b8f..bc71a474be 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -53,6 +53,9 @@ preserves repeated-field order. Policy and profile hashes use the hash is a separate operational revision identifier and is not expected to match the signed governance hash. +Pass `--policy policy.cedar` to govern sandboxes with the same policy written in +Cedar; the `.cedar` extension selects the format, as it does for the CLI. + The interceptor polls the policy file every second by default. When `policy.yaml` changes and parses successfully, the interceptor re-signs it immediately. New sandboxes receive the updated signed policy through `CreateSandbox`. If @@ -150,6 +153,8 @@ To run the governance smoke test suite and stop the gateway when it completes: ./smoke.sh --test-suite ``` +Set `POLICY_FORMAT=cedar` to run the suite with `policy.cedar`. + The suite uses a gateway-signed JWT for the created sandbox identity to attempt an unsigned policy widening and a policy proposal. It verifies that both are denied, telemetry is accepted, and the active policy version and hash remain diff --git a/examples/governance-interceptor/policy.cedar b/examples/governance-interceptor/policy.cedar new file mode 100644 index 0000000000..89bafed21c --- /dev/null +++ b/examples/governance-interceptor/policy.cedar @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy.yaml: curl has full REST access to +// api-1.example.com. The interceptor injects policy.yaml, so this file is a +// reference for creating a Cedar sandbox directly: +// +// openshell sandbox create --policy policy.cedar +// +// Landlock compatibility (best_effort) and process identity use their +// defaults, as in policy.yaml. + +// filesystem_policy.read_write. Cedar has no include_workdir; /sandbox is the +// default workdir and is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/var/log" +}; + +// network_policies.my_api: curl connects to api-1.example.com:443. A YAML +// binary path also matches an ancestor process and a symlink to the binary, +// so the condition tests all three. +@id("my_api/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api-1.example.com:443" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; + +// `protocol: rest`, `enforcement: enforce`, `access: full`: every method on +// every path. Naming the endpoint in an HttpRequest policy makes the proxy +// inspect each request. +@id("my_api/full") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api-1.example.com:443" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index d91aed79b0..4851581816 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -18,6 +18,10 @@ and keeps it running for interactive use. Options: --test-suite, --test Run the governance smoke test suite, then stop. -h, --help Show this help. + +Environment: + POLICY_FORMAT Governance policy format: yaml (default) uses + policy.yaml; cedar uses policy.cedar. EOF } @@ -39,11 +43,20 @@ while [[ "$#" -gt 0 ]]; do esac done +POLICY_FORMAT="${POLICY_FORMAT:-yaml}" +case "$POLICY_FORMAT" in + yaml | cedar) ;; + *) + echo "POLICY_FORMAT must be yaml or cedar" >&2 + exit 2 + ;; +esac + TMPDIR="$(mktemp -d)" LOG_DIR="$TMPDIR/logs" JWT_DIR="$TMPDIR/jwt" GATEWAY_CONFIG="$TMPDIR/gateway.toml" -POLICY_FILE="$TMPDIR/policy.yaml" +POLICY_FILE="$TMPDIR/policy.$POLICY_FORMAT" PROFILE_DIR="$TMPDIR/profiles" SETUP_LOG="$LOG_DIR/setup.log" GATEWAY_LOG="$LOG_DIR/gateway.log" @@ -344,6 +357,7 @@ gateway_id = "$RUN_ID" [[openshell.gateway.interceptors]] name = "provider-governance" grpc_endpoint = "http://$INTERCEPTOR_ADDR" +allow_insecure_transport = true order = 10 failure_policy = "fail_closed" binding_policy = "allowlist" @@ -437,6 +451,77 @@ configure_gateway() { wait_for_profile "slack" } +# Rewrites the governance policy file with an added example_api rule, which +# the interceptor reloads and propagates to running sandboxes. +write_reloaded_policy() { + if [[ "$POLICY_FORMAT" == "cedar" ]]; then + cat >"$POLICY_FILE" <<'EOF' +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/var/log" +}; + +@id("example_api/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"example.com:443" +); + +@id("example_api/read-only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"example.com:443" +) +when { ["GET", "HEAD", "OPTIONS"].contains(context.method) }; +EOF + return + fi + cat >"$POLICY_FILE" <<'EOF' +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 + +network_policies: + example_api: + name: example-api + endpoints: + - host: example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only +EOF +} + run_suite() { expect_output_contains "lists github profile" "github" "${CLI[@]}" profile list expect_output_contains "lists slack profile" "slack" "${CLI[@]}" profile list @@ -510,27 +595,7 @@ EOF fi pass "reads initial governance profile signature" - cat >"$POLICY_FILE" <<'EOF' -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 - -network_policies: - example_api: - name: example-api - endpoints: - - host: example.com - port: 443 - protocol: rest - enforcement: enforce - access: read-only -EOF + write_reloaded_policy wait_for_output_contains "gateway sees policy.yaml reload" "example_api" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json local policy_reload_hash policy_reload_hash="$(policy_hash_for_sandbox "$SANDBOX_NAME")" @@ -618,7 +683,7 @@ EOF fi wait_for_output_contains "running sandbox logs github profile reload" "$profile_reload_hash" "${CLI[@]}" logs "$SANDBOX_NAME" --source sandbox --since 90s - expect_failure "denies policy replacement" "${CLI[@]}" policy set "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" + expect_failure "denies policy replacement" "${CLI[@]}" policy set "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.$POLICY_FORMAT" run_step "deletes governed sandbox" "${CLI[@]}" sandbox delete "$SANDBOX_NAME" } @@ -664,7 +729,7 @@ run_setup_step "building governance interceptor" cargo build --quiet --manifest- run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell generate_gateway_jwt_bundle -cp "$EXAMPLE_DIR/policy.yaml" "$POLICY_FILE" +cp "$EXAMPLE_DIR/policy.$POLICY_FORMAT" "$POLICY_FILE" write_gateway_config start_interceptor start_gateway diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 48116c73c4..4e5f6752e8 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -235,6 +235,9 @@ impl PolicySigner { #[derive(Clone, Debug)] struct GovernanceInterceptorService { policy_signer: PolicySigner, + /// The governance policy's format, fixed for the interceptor's lifetime + /// because a sandbox cannot switch between YAML and Cedar. + policy_format: PolicyFormat, policy_state: Arc>, profiles_path: Option, profile_state: Arc>, @@ -267,24 +270,39 @@ impl GovernanceInterceptorService { Self::from_yaml(include_str!("../policy.yaml"), profiles, None) } - fn from_policy_and_profiles_path(policy_yaml: &str, path: PathBuf) -> Result { + fn from_policy_and_profiles_path( + policy_text: &str, + policy_format: PolicyFormat, + path: PathBuf, + ) -> Result { let profiles = load_provider_profiles(&path)?; - Self::from_yaml(policy_yaml, profiles, Some(path)) + Self::from_policy(policy_text, policy_format, profiles, Some(path)) } + #[cfg(test)] fn from_yaml( policy_yaml: &str, profiles: Vec, profiles_path: Option, + ) -> Result { + Self::from_policy(policy_yaml, PolicyFormat::Yaml, profiles, profiles_path) + } + + fn from_policy( + policy_text: &str, + policy_format: PolicyFormat, + profiles: Vec, + profiles_path: Option, ) -> Result { if profiles.is_empty() { return Err("at least one provider profile must be loaded".to_string()); } let policy_signer = PolicySigner::generate()?; let profile_state = profile_state_from_loaded(profiles, &policy_signer)?; - let policy_state = load_policy_state(policy_yaml, &policy_signer)?; + let policy_state = load_policy_state(policy_text, policy_format, &policy_signer)?; Ok(Self { policy_signer, + policy_format, policy_state: Arc::new(RwLock::new(policy_state)), profiles_path, profile_state: Arc::new(RwLock::new(profile_state)), @@ -472,8 +490,15 @@ impl GovernanceInterceptorService { .unwrap_or_else(|poisoned| poisoned.into_inner().clone()) } + #[cfg(test)] fn reload_policy_from_yaml(&self, policy_yaml: &str) -> Result, String> { - let next = load_policy_state(policy_yaml, &self.policy_signer)?; + self.reload_policy(policy_yaml) + } + + /// Reloads the governance policy from its text, in the format it was + /// loaded with. + fn reload_policy(&self, policy_text: &str) -> Result, String> { + let next = load_policy_state(policy_text, self.policy_format, &self.policy_signer)?; let mut guard = self .policy_state .write() @@ -798,12 +823,50 @@ fn validate_delete_provider_profile() -> InterceptorResult { deny("provider profile deletes are blocked by provider governance") } +/// The format of the governance policy file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PolicyFormat { + Yaml, + /// A Cedar policy, selected by a `.cedar` extension as the CLI selects it. + Cedar, +} + +impl PolicyFormat { + fn of(path: &Path) -> Self { + let cedar = path + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("cedar")); + if cedar { Self::Cedar } else { Self::Yaml } + } +} + +/// Parses the governance policy file. +/// +/// A Cedar policy is carried as `cedar_policy_source`, as the CLI builds it. +/// This example builds `openshell-policy` without Cedar support, so the +/// gateway validates the Cedar text when it creates the sandbox. +fn parse_policy(policy_text: &str, policy_format: PolicyFormat) -> Result { + match policy_format { + PolicyFormat::Yaml => parse_sandbox_policy(policy_text) + .map_err(|err| format!("failed to parse policy YAML: {err}")), + PolicyFormat::Cedar if policy_text.trim().is_empty() => { + Err("Cedar policy file is empty".to_string()) + } + PolicyFormat::Cedar => Ok(SandboxPolicy { + version: 1, + cedar_policy_source: policy_text.to_string(), + ..Default::default() + }), + } +} + fn load_policy_state( - policy_yaml: &str, + policy_text: &str, + policy_format: PolicyFormat, policy_signer: &PolicySigner, ) -> Result { - let policy_proto = parse_sandbox_policy(policy_yaml) - .map_err(|err| format!("failed to parse policy YAML: {err}"))?; + let policy_proto = parse_policy(policy_text, policy_format)?; let policy = sandbox_policy_to_proto_json(&policy_proto)?; let policy = normalize_for_struct(policy)?; let policy_hash = canonical_policy_hash(&policy_proto)?; @@ -1192,7 +1255,7 @@ fn spawn_policy_watch_worker( } }; - let policy_state = match service.reload_policy_from_yaml(&policy_yaml) { + let policy_state = match service.reload_policy(&policy_yaml) { Ok(Some(policy_state)) => policy_state, Ok(None) => continue, Err(err) => { @@ -1391,10 +1454,13 @@ async fn main() -> Result<(), Box> { } let policy_path = policy_path.unwrap_or_else(default_policy_path); - let policy_yaml = tokio::fs::read_to_string(&policy_path).await?; + let policy_text = tokio::fs::read_to_string(&policy_path).await?; let profiles_path = profiles_path.unwrap_or_else(default_profiles_path); - let service = - GovernanceInterceptorService::from_policy_and_profiles_path(&policy_yaml, profiles_path)?; + let service = GovernanceInterceptorService::from_policy_and_profiles_path( + &policy_text, + PolicyFormat::of(&policy_path), + profiles_path, + )?; if let Some(endpoint) = &gateway_endpoint { println!("policy reload propagation enabled through gateway endpoint {endpoint}"); diff --git a/examples/governance-interceptor/src/smoke_client.rs b/examples/governance-interceptor/src/smoke_client.rs index 8a73522617..eaa4c20961 100644 --- a/examples/governance-interceptor/src/smoke_client.rs +++ b/examples/governance-interceptor/src/smoke_client.rs @@ -5,8 +5,9 @@ use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use openshell_core::proto::{ - GetSandboxConfigRequest, NetworkActivitySummary, PolicyChunk, SubmitPolicyAnalysisRequest, - UpdateConfigRequest, open_shell_client::OpenShellClient, + GetSandboxConfigRequest, NetworkActivitySummary, NetworkEndpoint, NetworkPolicyRule, + PolicyChunk, SubmitPolicyAnalysisRequest, UpdateConfigRequest, + open_shell_client::OpenShellClient, }; use serde::Serialize; use tonic::Code; @@ -58,7 +59,7 @@ async fn main() -> Result<(), Box> { let before = client .get_sandbox_config(GetSandboxConfigRequest { name: sandbox_name.clone(), - workspace_scope: None, + workspace_scope: Some(default_workspace()), }) .await? .into_inner(); @@ -66,28 +67,46 @@ async fn main() -> Result<(), Box> { .policy .clone() .ok_or("governed sandbox did not have an active policy")?; - let mut added_rule = widened_policy - .network_policies - .values() - .next() - .cloned() - .ok_or("governed policy did not contain a network rule to clone")?; - added_rule.name = "sandbox-added".to_string(); - let endpoint_to_change = added_rule - .endpoints - .first_mut() - .ok_or("governed policy network rule did not contain an endpoint")?; - endpoint_to_change.host = "sandbox-added.example".to_string(); - widened_policy - .network_policies - .insert("sandbox_added".to_string(), added_rule.clone()); + let added_rule = if widened_policy.cedar_policy_source.is_empty() { + let mut added_rule = widened_policy + .network_policies + .values() + .next() + .cloned() + .ok_or("governed policy did not contain a network rule to clone")?; + added_rule.name = "sandbox-added".to_string(); + let endpoint_to_change = added_rule + .endpoints + .first_mut() + .ok_or("governed policy network rule did not contain an endpoint")?; + endpoint_to_change.host = "sandbox-added.example".to_string(); + widened_policy + .network_policies + .insert("sandbox_added".to_string(), added_rule.clone()); + added_rule + } else { + // A Cedar policy is widened by adding a permit to its text. The + // proposal below still carries a YAML rule, which is what a sandbox + // proposes. + widened_policy.cedar_policy_source.push_str( + "\npermit (principal, action == Sandbox::Action::\"NetworkConnect\",\n\ + resource == Sandbox::NetworkEndpoint::\"sandbox-added.example:443\");\n", + ); + NetworkPolicyRule { + name: "sandbox-added".to_string(), + endpoints: vec![NetworkEndpoint { + host: "sandbox-added.example".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + } + }; let policy_result = client .update_config(UpdateConfigRequest { sandbox: sandbox_name.clone(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), + workspace_scope: Some(default_workspace()), policy: Some(widened_policy), ..Default::default() }) @@ -97,6 +116,7 @@ async fn main() -> Result<(), Box> { let proposal_result = client .submit_policy_analysis(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), + workspace_scope: Some(default_workspace()), proposed_chunks: vec![PolicyChunk { rule_name: "sandbox_added".to_string(), proposed_rule: Some(added_rule), @@ -112,6 +132,7 @@ async fn main() -> Result<(), Box> { client .submit_policy_analysis(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), + workspace_scope: Some(default_workspace()), network_activity_summaries: vec![NetworkActivitySummary { network_activity_count: 1, ..Default::default() @@ -125,7 +146,7 @@ async fn main() -> Result<(), Box> { let after = client .get_sandbox_config(GetSandboxConfigRequest { name: sandbox_name, - workspace_scope: None, + workspace_scope: Some(default_workspace()), }) .await? .into_inner(); @@ -177,3 +198,8 @@ fn expect_permission_denied( Ok(_) => Err(format!("{operation} unexpectedly succeeded").into()), } } + +/// The workspace the smoke suite's sandbox runs in. +fn default_workspace() -> openshell_core::proto::WorkspaceSelector { + openshell_core::proto::workspace_selector("default".to_string()) +} diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs index 640a3e2862..f53ad05ec9 100644 --- a/examples/governance-interceptor/src/tests.rs +++ b/examples/governance-interceptor/src/tests.rs @@ -590,6 +590,55 @@ fn policy_reload_updates_hash_and_preserves_last_valid_state_on_error() { assert_eq!(policy_state(&service).policy_hash, changed.policy_hash); } +#[test] +fn cedar_policy_is_carried_as_cedar_source_and_signed() { + let profiles = load_provider_profiles(&default_profiles_path()).unwrap(); + let service = GovernanceInterceptorService::from_policy( + include_str!("../policy.cedar"), + PolicyFormat::Cedar, + profiles, + None, + ) + .unwrap(); + let state = policy_state(&service); + assert_eq!( + state.policy_proto.cedar_policy_source, + include_str!("../policy.cedar") + ); + assert!(state.policy_proto.network_policies.is_empty()); + assert!(state.policy.get("cedarPolicySource").is_some()); + assert!(!state.policy_signature.is_empty()); + + let changed = service + .reload_policy(&format!( + "{}\n// reloaded\n", + include_str!("../policy.cedar") + )) + .unwrap() + .expect("policy hash should change"); + assert_ne!(state.policy_hash, changed.policy_hash); + let err = service + .reload_policy(" \n") + .expect_err("an empty Cedar policy should be rejected"); + assert!(err.contains("empty")); +} + +#[test] +fn policy_format_follows_the_file_extension() { + assert_eq!( + PolicyFormat::of(Path::new("policy.cedar")), + PolicyFormat::Cedar + ); + assert_eq!( + PolicyFormat::of(Path::new("policy.CEDAR")), + PolicyFormat::Cedar + ); + assert_eq!( + PolicyFormat::of(Path::new("policy.yaml")), + PolicyFormat::Yaml + ); +} + #[test] fn signed_governance_policy_update_is_allowed() { let service = service(); diff --git a/examples/jupyter-sandbox/README.md b/examples/jupyter-sandbox/README.md index 153e636fa0..e7dad3e984 100644 --- a/examples/jupyter-sandbox/README.md +++ b/examples/jupyter-sandbox/README.md @@ -39,6 +39,9 @@ openshell sandbox create \ --IdentityProvider.token="$JUPYTER_TOKEN" ``` +To use Cedar, pass `--policy policy.cedar`. A Cedar policy has no process +section, so the server runs as the image user, `jovyan`. + The CLI prints the service URL after the sandbox is ready. Jupyter starts as the sandbox's main process and listens on its loopback port. Keep the token in this shell for step 3. diff --git a/examples/jupyter-sandbox/policy.cedar b/examples/jupyter-sandbox/policy.cedar new file mode 100644 index 0000000000..1c183e2161 --- /dev/null +++ b/examples/jupyter-sandbox/policy.cedar @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy.yaml: filesystem access for the notebook server and +// no outbound network access. The Jupyter service is exposed inbound through +// the gateway, which a sandbox policy does not govern. +// +// Cedar has no `process` section. policy.yaml sets run_as_user `jovyan` and +// run_as_group `users`; a Cedar sandbox uses the identity the compute driver +// selects, which for Docker and Podman is the image user, `jovyan`. Landlock +// compatibility uses its default, best_effort. + +// filesystem_policy.read_write. Cedar has no include_workdir; the image's +// workdir, /home/jovyan, is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/home/jovyan" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/opt" + || resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/var/log" +}; diff --git a/examples/local-inference/README.md b/examples/local-inference/README.md index 7cc36513ed..c0229ed913 100644 --- a/examples/local-inference/README.md +++ b/examples/local-inference/README.md @@ -12,6 +12,7 @@ model, request shape, timeout, and streaming behavior. | `nvidia-inference.yaml` | Example profile for the endpoint, credential, and allowed Python binaries | | `inference.py` | Native endpoint streaming and non-streaming client | | `sandbox-policy.yaml` | Minimal policy that lets Python install the OpenAI client from PyPI | +| `sandbox-policy.cedar` | Cedar version of the policy, which also grants the NVIDIA endpoint | ## Run the Example @@ -42,6 +43,10 @@ openshell sandbox create \ -- python3 /sandbox/inference.py ``` +To use Cedar, pass `--policy examples/local-inference/sandbox-policy.cedar` +instead. Providers attached to a Cedar sandbox supply credentials but grant no +access, so the Cedar policy permits the NVIDIA endpoint itself. + The profile contributes the NVIDIA endpoint to the effective network policy and injects an opaque `NVIDIA_API_KEY` placeholder. The proxy substitutes the real key only for requests that match the profile endpoint. Inspect the composed diff --git a/examples/local-inference/sandbox-policy.cedar b/examples/local-inference/sandbox-policy.cedar new file mode 100644 index 0000000000..baf0c596f3 --- /dev/null +++ b/examples/local-inference/sandbox-policy.cedar @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of sandbox-policy.yaml with the nvidia-inference provider +// attached: Python may reach PyPI so pip can install dependencies, and the +// NVIDIA API Catalog endpoint. Landlock compatibility (best_effort) and +// process identity use their defaults, as in sandbox-policy.yaml. + +// filesystem_policy.read_write. Cedar has no include_workdir; /sandbox is the +// default workdir and is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/var/log" + || resource in Sandbox::FilesystemPath::"/proc/self" + || resource in Sandbox::FilesystemPath::"/dev/urandom" +}; + +// network_policies.pypi: the endpoints have no protocol, so connections are +// plain L4 and no HttpRequest policy names them. A YAML binary path also +// matches an ancestor process and a symlink to the binary, so each condition +// tests all three. +@id("pypi/connect/pypi.org") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"pypi.org:443" +) +when { + context.binary_path == "/usr/bin/python3.13" + || context.ancestors.contains("/usr/bin/python3.13") + || context.binary_aliases.contains("/usr/bin/python3.13") +}; + +@id("pypi/connect/files.pythonhosted.org") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"files.pythonhosted.org:443" +) +when { + context.binary_path == "/usr/bin/python3.13" + || context.ancestors.contains("/usr/bin/python3.13") + || context.binary_aliases.contains("/usr/bin/python3.13") +}; + +// The NVIDIA endpoint. With YAML, the attached nvidia-inference provider +// (nvidia-inference.yaml) adds this endpoint to the network policy. A Cedar +// sandbox's providers supply credentials but grant no access, so the policy +// grants what the profile grants: its binaries may connect, and the +// `access: read-write` preset allows GET, HEAD, OPTIONS, POST, PUT, and PATCH. +// A credentialed endpoint must be inspected, which the HttpRequest policy +// does. The profile's binary glob `/sandbox/.venv/**` also matches an +// ancestor process in YAML; `context.binary_globs` lists the patterns that +// match the calling binary or an ancestor, so the condition tests it there. +@id("nvidia-inference/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"integrate.api.nvidia.com:443" +) +when { + ["/usr/bin/python3", "/usr/bin/python3.13", "/usr/local/bin/python"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/python3", "/usr/bin/python3.13", "/usr/local/bin/python"]) + || context.binary_aliases.containsAny(["/usr/bin/python3", "/usr/bin/python3.13", "/usr/local/bin/python"]) + || context.binary_globs.contains("/sandbox/.venv/**") +}; + +@id("nvidia-inference/read-write") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"integrate.api.nvidia.com:443" +) +when { + (["/usr/bin/python3", "/usr/bin/python3.13", "/usr/local/bin/python"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/python3", "/usr/bin/python3.13", "/usr/local/bin/python"]) + || context.binary_aliases.containsAny(["/usr/bin/python3", "/usr/bin/python3.13", "/usr/local/bin/python"]) + || context.binary_globs.contains("/sandbox/.venv/**")) + && ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"].contains(context.method) +}; diff --git a/examples/multi-agent-notepad/README.md b/examples/multi-agent-notepad/README.md index 910128316a..ac24e309ba 100644 --- a/examples/multi-agent-notepad/README.md +++ b/examples/multi-agent-notepad/README.md @@ -63,7 +63,8 @@ The demo also exercises two OpenShell features: to understand the agent side. - `policy.template.yaml` — the network policy applied to every sandbox in the run. Renders to a concrete policy with the configured owner, repo, and - run id. + run id. `policy.template.cedar` is the same policy in Cedar; set + `POLICY_FORMAT=cedar` to use it. - `prompts/worker.md`, `prompts/synthesis.md` — agent instructions. ## Prerequisites diff --git a/examples/multi-agent-notepad/demo.sh b/examples/multi-agent-notepad/demo.sh index 53535a644d..c13def7328 100755 --- a/examples/multi-agent-notepad/demo.sh +++ b/examples/multi-agent-notepad/demo.sh @@ -6,7 +6,17 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -POLICY_TEMPLATE="${SCRIPT_DIR}/policy.template.yaml" +# POLICY_FORMAT=cedar renders policy.template.cedar instead of +# policy.template.yaml. +POLICY_FORMAT="${POLICY_FORMAT:-yaml}" +case "$POLICY_FORMAT" in + yaml | cedar) ;; + *) + printf 'error: POLICY_FORMAT must be yaml or cedar\n' >&2 + exit 2 + ;; +esac +POLICY_TEMPLATE="${SCRIPT_DIR}/policy.template.${POLICY_FORMAT}" PROMPTS_DIR="${SCRIPT_DIR}/prompts" OPENSHELL_BIN="${OPENSHELL_BIN:-openshell}" @@ -24,7 +34,7 @@ DEMO_CODEX_PROVIDER_NAME="${DEMO_CODEX_PROVIDER_NAME:-codex-oauth-${DEMO_RUN_ID} DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-memory-${DEMO_RUN_ID}}" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openshell-codex-github.XXXXXX")" -POLICY_FILE="${TMP_DIR}/policy.yaml" +POLICY_FILE="${TMP_DIR}/policy.${POLICY_FORMAT}" PAYLOAD_DIR="${TMP_DIR}/payload" RUNNER_FILE="${PAYLOAD_DIR}/demo-runner.sh" PROMPTS_UPLOAD_DIR="${PAYLOAD_DIR}/prompts" diff --git a/examples/multi-agent-notepad/policy.template.cedar b/examples/multi-agent-notepad/policy.template.cedar new file mode 100644 index 0000000000..2fc2f08ae2 --- /dev/null +++ b/examples/multi-agent-notepad/policy.template.cedar @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy.template.yaml, applied to every sandbox in the +// multi-agent notepad demo. demo.sh replaces __OWNER__, __REPO__, and +// __RUN_ID__ in either file; run it with POLICY_FORMAT=cedar to use this one. +// +// Landlock compatibility (best_effort) and process identity use their +// defaults, as in policy.template.yaml. A YAML binary path also matches an +// ancestor process and a symlink to the binary, so each binary condition +// tests all three. The YAML binary glob `/usr/lib/node_modules/@openai/**` +// also matches an ancestor process; `context.binary_globs` lists the patterns +// that match the calling binary or an ancestor, so the condition tests it +// there. + +// filesystem_policy.read_write. Cedar has no include_workdir; /sandbox is the +// default workdir and is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/var/log" +}; + +// network_policies.codex: Codex and its Node runtime reach the OpenAI and +// ChatGPT endpoints. Each endpoint is `protocol: rest`, `enforcement: +// enforce`, `access: full`: one policy per endpoint allows the connection and +// every request on it, and naming the endpoint for HttpRequest makes the +// proxy inspect each request. +@id("codex/api.openai.com") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"api.openai.com:443" +) +when { + ["/usr/bin/codex", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/codex", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/usr/bin/codex", "/usr/bin/node"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**") +}; + +@id("codex/auth.openai.com") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"auth.openai.com:443" +) +when { + ["/usr/bin/codex", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/codex", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/usr/bin/codex", "/usr/bin/node"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**") +}; + +@id("codex/chatgpt.com") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"chatgpt.com:443" +) +when { + ["/usr/bin/codex", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/codex", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/usr/bin/codex", "/usr/bin/node"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**") +}; + +@id("codex/ab.chatgpt.com") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"NetworkConnect", Sandbox::Action::"HttpRequest"], + resource == Sandbox::NetworkEndpoint::"ab.chatgpt.com:443" +) +when { + ["/usr/bin/codex", "/usr/bin/node"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/codex", "/usr/bin/node"]) + || context.binary_aliases.containsAny(["/usr/bin/codex", "/usr/bin/node"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**") +}; + +// network_policies.codex_plugins: git fetches the Codex plugin repository. +// A YAML GET rule also allows HEAD; Cedar lists HEAD explicitly. The `/` +// delimiter keeps `*` within one path segment, as in YAML. +@id("codex_plugins/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"github.com:443" +) +when { + ["/usr/bin/git", "/usr/lib/git-core/git-remote-http"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/git", "/usr/lib/git-core/git-remote-http"]) + || context.binary_aliases.containsAny(["/usr/bin/git", "/usr/lib/git-core/git-remote-http"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**") +}; + +@id("codex_plugins/rules") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"github.com:443" +) +when { + (["/usr/bin/git", "/usr/lib/git-core/git-remote-http"].contains(context.binary_path) + || context.ancestors.containsAny(["/usr/bin/git", "/usr/lib/git-core/git-remote-http"]) + || context.binary_aliases.containsAny(["/usr/bin/git", "/usr/lib/git-core/git-remote-http"]) + || context.binary_globs.contains("/usr/lib/node_modules/@openai/**")) + && ((["GET", "HEAD"].contains(context.method) + && context.path like("/openai/plugins.git/info/refs*", "/")) + || (context.method == "POST" + && context.path == "/openai/plugins.git/git-upload-pack")) +}; + +// network_policies.github_memory: curl reads the shared repository and writes +// the notes of this run. +@id("github_memory/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; + +@id("github_memory/rules") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { + (context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl")) + && ((["GET", "HEAD"].contains(context.method) + && (context.path == "/repos/__OWNER__/__REPO__" + || context.path == "/repos/__OWNER__/__REPO__/contents/runs/__RUN_ID__" + || context.path like("/repos/__OWNER__/__REPO__/contents/runs/__RUN_ID__/**", "/"))) + || (context.method == "PUT" + && context.path like("/repos/__OWNER__/__REPO__/contents/runs/__RUN_ID__/**", "/"))) +}; diff --git a/examples/policy-advisor/README.md b/examples/policy-advisor/README.md index bc9ac6fd7d..2e3311c8d4 100644 --- a/examples/policy-advisor/README.md +++ b/examples/policy-advisor/README.md @@ -34,6 +34,7 @@ the script progresses through each gate. | File | Description | |---|---| | `sandbox-policy.yaml` | Restrictive policy that only allows `api.anthropic.com:443` | +| `sandbox-policy.cedar` | The same policy in Cedar; the CTF needs YAML, because approvals merge YAML rules | | `ctf.py` | Python script with 7 network gates | | `README.md` | This walkthrough | diff --git a/examples/policy-advisor/sandbox-policy.cedar b/examples/policy-advisor/sandbox-policy.cedar new file mode 100644 index 0000000000..7d08a43215 --- /dev/null +++ b/examples/policy-advisor/sandbox-policy.cedar @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of sandbox-policy.yaml. +// +// sandbox-policy.yaml has no filesystem_policy, so the supervisor grants the +// workdir and its baseline runtime paths. A Cedar sandbox gets the same +// baseline because this policy has a NetworkConnect permit, and this policy +// grants the workdir, /sandbox. +// +// The YAML endpoint api.anthropic.com:443 lists no binaries, so it lets no +// process connect: a YAML binary list must match the calling binary. The +// permit below is its translation: it names the endpoint, which makes +// api.anthropic.com resolvable through policy DNS as in YAML, and admits no +// binary. Providers attached to a Cedar sandbox supply credentials but grant +// no access, and the Policy Advisor approval flow merges YAML rules, so to +// let an agent reach the API, add its binary to the condition, for example +// `context.binary_path == "/usr/local/bin/claude"`. + +@id("anthropic/api.anthropic.com:443") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.anthropic.com:443" +) +when { false }; + +@id("filesystem/workdir") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource in Sandbox::FilesystemPath::"/sandbox" +); diff --git a/examples/sandbox-policy-quickstart/README.md b/examples/sandbox-policy-quickstart/README.md index 11da5f9eaa..565128bd3f 100644 --- a/examples/sandbox-policy-quickstart/README.md +++ b/examples/sandbox-policy-quickstart/README.md @@ -15,6 +15,7 @@ while writes are blocked — all without restarting anything. | File | Description | | ------------- | -------------------------------------------------------------------- | | `policy.yaml` | Complete policy with the same rule, for `sandbox create --policy` | +| `policy.cedar` | The same policy written in Cedar | | `demo.sh` | Automated script that runs the full walkthrough non-interactively | ## Walkthrough @@ -114,6 +115,11 @@ Do not apply it to a running sandbox with `openshell policy set`, which replaces the entire policy and is rejected if the file drops a filesystem path the sandbox already has. +[`policy.cedar`](policy.cedar) is the same policy in Cedar. Run +`POLICY_FORMAT=cedar bash examples/sandbox-policy-quickstart/demo.sh` to follow +the walkthrough with it; step 4 then runs `openshell policy set` with the whole +Cedar policy, because Cedar policies are replaced rather than merged. + ### 5. Connect and verify: GET works ```bash diff --git a/examples/sandbox-policy-quickstart/demo.sh b/examples/sandbox-policy-quickstart/demo.sh index 8cc8b2145a..d7360a0a99 100755 --- a/examples/sandbox-policy-quickstart/demo.sh +++ b/examples/sandbox-policy-quickstart/demo.sh @@ -14,14 +14,29 @@ # 6. Shows logs and cleans up # # Usage: bash examples/sandbox-policy-quickstart/demo.sh +# +# POLICY_FORMAT=cedar runs the same walkthrough with a Cedar policy: the +# sandbox starts from the filesystem rules of policy.cedar, and step 4 sets +# the whole of policy.cedar, since Cedar policies are replaced, not merged. set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SANDBOX_NAME="policy-demo" SSH_CONFIG=$(mktemp) +POLICY_FORMAT="${POLICY_FORMAT:-yaml}" +case "$POLICY_FORMAT" in + yaml | cedar) ;; + *) + printf 'POLICY_FORMAT must be yaml or cedar\n' >&2 + exit 2 + ;; +esac +POLICY_DIR=$(mktemp -d) cleanup() { rm -f "$SSH_CONFIG" + rm -rf "$POLICY_DIR" printf '\n' step "Cleaning up" openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true @@ -84,11 +99,23 @@ wait_for_ssh() { # ------------------------------------------------------------------ step "1/7 Creating sandbox \"${SANDBOX_NAME}\" (default-deny networking)" -run openshell sandbox create \ - --name "$SANDBOX_NAME" \ - --no-auto-providers \ - --no-tty \ - -- echo "sandbox ready" +if [[ "$POLICY_FORMAT" == "cedar" ]]; then + # The filesystem rules of policy.cedar, without its network rules. + sed '/^\/\/ network_policies\.github_api:/,$d' "${SCRIPT_DIR}/policy.cedar" \ + > "${POLICY_DIR}/initial.cedar" + run openshell sandbox create \ + --name "$SANDBOX_NAME" \ + --policy "${POLICY_DIR}/initial.cedar" \ + --no-auto-providers \ + --no-tty \ + --detach +else + run openshell sandbox create \ + --name "$SANDBOX_NAME" \ + --no-auto-providers \ + --no-tty \ + --detach +fi step "Connecting to sandbox" openshell sandbox ssh-config "$SANDBOX_NAME" > "$SSH_CONFIG" @@ -118,11 +145,17 @@ openshell logs "$SANDBOX_NAME" --since 1m --source sandbox -n 10 2>&1 \ # ------------------------------------------------------------------ step "4/7 Adding a read-only GitHub API rule" -run openshell policy update "$SANDBOX_NAME" \ - --rule-name github_api \ - --binary /usr/bin/curl \ - --add-endpoint api.github.com:443:read-only:rest:enforce \ - --wait +if [[ "$POLICY_FORMAT" == "cedar" ]]; then + run openshell policy set "$SANDBOX_NAME" \ + --policy "${SCRIPT_DIR}/policy.cedar" \ + --wait +else + run openshell policy update "$SANDBOX_NAME" \ + --rule-name github_api \ + --binary /usr/bin/curl \ + --add-endpoint api.github.com:443:read-only:rest:enforce \ + --wait +fi # ------------------------------------------------------------------ @@ -140,10 +173,12 @@ sandbox_exec curl -sS --max-time 10 https://api.github.com/octocat | sed 's/^/ step "6/7 Attempting POST — should be BLOCKED by L7" printf " ${BOLD}\$ curl -sS -X POST https://api.github.com/repos/octocat/hello-world/issues -d '{\"title\":\"oops\"}'${RESET}\n" +# ssh joins its arguments into one remote command line, so quote the header +# and body for the remote shell. RESPONSE=$(sandbox_exec curl -sS --max-time 10 -X POST \ https://api.github.com/repos/octocat/hello-world/issues \ - -H "Content-Type: application/json" \ - -d '{"title":"oops"}') + -H "'Content-Type: application/json'" \ + -d "'{\"title\":\"oops\"}'") printf " ${YELLOW}%s${RESET}\n" "$RESPONSE" # ------------------------------------------------------------------ diff --git a/examples/sandbox-policy-quickstart/policy.cedar b/examples/sandbox-policy-quickstart/policy.cedar new file mode 100644 index 0000000000..d3b7cb1182 --- /dev/null +++ b/examples/sandbox-policy-quickstart/policy.cedar @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy.yaml: curl may read from the GitHub REST API, and +// POST, PUT, PATCH, and DELETE are blocked. +// +// openshell sandbox create --name demo --policy policy.cedar +// +// A Cedar sandbox stays Cedar for its lifetime, and `openshell policy update` +// does not merge into a Cedar policy. Replace it with `openshell policy set`. +// Landlock compatibility (best_effort) and process identity use their +// defaults, as in policy.yaml. + +// filesystem_policy.read_write. Cedar has no include_workdir; /sandbox is the +// default workdir and is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/bin" + || resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/var/log" +}; + +// network_policies.github_api: curl connects to api.github.com:443. A YAML +// binary path also matches an ancestor process and a symlink to the binary, +// so the condition tests all three. +@id("github_api/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; + +// `protocol: rest`, `enforcement: enforce`, `access: read-only`: the preset +// allows GET, HEAD, and OPTIONS on every path. Naming the endpoint in an +// HttpRequest policy makes the proxy inspect each request. +@id("github_api/read-only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.github.com:443" +) +when { + (context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl")) + && ["GET", "HEAD", "OPTIONS"].contains(context.method) +}; diff --git a/examples/supervisor-middleware-content-guard/Cargo.toml b/examples/supervisor-middleware-content-guard/Cargo.toml index 89e7b7a85e..5f5ffd8ee6 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.toml +++ b/examples/supervisor-middleware-content-guard/Cargo.toml @@ -21,7 +21,7 @@ tokio-stream = "0.1" tonic = { version = "0.14", features = ["transport"] } [dev-dependencies] -openshell-policy = { path = "../../crates/openshell-policy" } +openshell-policy = { path = "../../crates/openshell-policy", default-features = false } [[bin]] name = "supervisor-middleware-content-guard" diff --git a/examples/supervisor-middleware-content-guard/README.md b/examples/supervisor-middleware-content-guard/README.md index fcdc37df56..02417c7a85 100644 --- a/examples/supervisor-middleware-content-guard/README.md +++ b/examples/supervisor-middleware-content-guard/README.md @@ -85,6 +85,16 @@ The included policy allows `curl` to POST to `https://httpbin.org/anything` and openshell sandbox create --policy examples/supervisor-middleware-content-guard/policy.yaml ``` +To use the Cedar version, pass the middleware separately, since a Cedar policy carries none: + +```shell +openshell sandbox create \ + --policy examples/supervisor-middleware-content-guard/policy.cedar \ + --middleware examples/supervisor-middleware-content-guard/middleware.yaml +``` + +Set `POLICY_FORMAT=cedar` to run `smoke.sh` with these files. + From the sandbox, send a matching request: ```shell diff --git a/examples/supervisor-middleware-content-guard/middleware.yaml b/examples/supervisor-middleware-content-guard/middleware.yaml new file mode 100644 index 0000000000..d0b973dfc6 --- /dev/null +++ b/examples/supervisor-middleware-content-guard/middleware.yaml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Middleware for policy.cedar, passed with --middleware. It is the +# network_middlewares section of policy.yaml, which a Cedar policy cannot +# carry because middleware is configuration rather than access control. + +network_middlewares: + prototype-content-guard: + name: Prototype content guard + middleware: content-guard-example + order: 10 + config: + mode: redact + terms: + - prototype-secret + - internal-only + replacement: "[FILTERED]" + on_error: fail_closed + endpoints: + include: + - httpbin.org + - host.openshell.internal diff --git a/examples/supervisor-middleware-content-guard/policy.cedar b/examples/supervisor-middleware-content-guard/policy.cedar new file mode 100644 index 0000000000..dc4faa59da --- /dev/null +++ b/examples/supervisor-middleware-content-guard/policy.cedar @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy.yaml. The content-guard middleware is configuration, +// not policy, so it lives in middleware.yaml: +// +// openshell sandbox create --policy policy.cedar --middleware middleware.yaml +// +// policy.yaml has no filesystem_policy, so the supervisor grants the workdir +// and its baseline runtime paths. A Cedar sandbox gets the same baseline, and +// this policy grants the workdir, /sandbox. +// +// The YAML endpoints set no `enforcement`, which defaults to audit: a request +// the rules deny is logged and forwarded. Every HttpRequest policy below is +// therefore @enforcement("audit"). A YAML binary path also matches an +// ancestor process and a symlink to the binary, so each condition tests all +// three. + +@id("filesystem/workdir") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource in Sandbox::FilesystemPath::"/sandbox" +); + +// network_policies.httpbin. +@id("httpbin/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"httpbin.org:443" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; + +@id("httpbin/rules") +@enforcement("audit") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"httpbin.org:443" +) +when { + (context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl")) + && context.method == "POST" + && context.path == "/anything" +}; + +// network_policies.httpbingo. +@id("httpbingo/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"httpbingo.org:443" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; + +@id("httpbingo/rules") +@enforcement("audit") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"httpbingo.org:443" +) +when { + (context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl")) + && context.method == "POST" + && context.path == "/anything" +}; + +// network_policies.guard-responses. A YAML GET rule also allows HEAD; Cedar +// lists HEAD explicitly. +@id("guard-responses/connect") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"host.openshell.internal:18081" +) +when { + context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl") +}; + +@id("guard-responses/rules") +@enforcement("audit") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"host.openshell.internal:18081" +) +when { + (context.binary_path == "/usr/bin/curl" + || context.ancestors.contains("/usr/bin/curl") + || context.binary_aliases.contains("/usr/bin/curl")) + && ["GET", "HEAD"].contains(context.method) + && ["/clean", "/sensitive"].contains(context.path) +}; diff --git a/examples/supervisor-middleware-content-guard/smoke.sh b/examples/supervisor-middleware-content-guard/smoke.sh index e37c8a5317..a7ab28747b 100755 --- a/examples/supervisor-middleware-content-guard/smoke.sh +++ b/examples/supervisor-middleware-content-guard/smoke.sh @@ -26,6 +26,9 @@ Environment: gateway and sandbox containers. CONTENT_GUARD_SMOKE_DRIVER Compute driver: docker (default) or podman. + POLICY_FORMAT Sandbox policy format: yaml (default) uses + policy.yaml; cedar uses policy.cedar with + middleware.yaml. EOF } @@ -113,6 +116,14 @@ case "$COMPUTE_DRIVER" in exit 1 ;; esac +POLICY_FORMAT="${POLICY_FORMAT:-yaml}" +case "$POLICY_FORMAT" in + yaml | cedar) ;; + *) + echo "POLICY_FORMAT must be yaml or cedar" >&2 + exit 1 + ;; +esac if [[ "$SERVICE_HOST" == "localhost" || "$SERVICE_HOST" == "::1" || "$SERVICE_HOST" == 127.* || "$SERVICE_HOST" == *:* ]]; then echo "CONTENT_GUARD_SMOKE_HOST must be a non-loopback IPv4 address: $SERVICE_HOST" >&2 exit 1 @@ -410,6 +421,17 @@ wait_for_gateway() { fail "gateway starts with content guard" } +# Sets POLICY_ARGS to the sandbox policy flags for POLICY_FORMAT. A YAML +# policy carries its middleware; a Cedar policy takes it from a middleware +# file. The argument is the file holding the middleware. +set_policy_args() { + if [[ "$POLICY_FORMAT" == "cedar" ]]; then + POLICY_ARGS=(--policy "$EXAMPLE_DIR/policy.cedar" --middleware "$1") + else + POLICY_ARGS=(--policy "$1") + fi +} + create_sandbox() { CLI=( env @@ -417,10 +439,15 @@ create_sandbox() { "$CLI_BIN" --gateway-endpoint "$GATEWAY_ENDPOINT" ) + if [[ "$POLICY_FORMAT" == "cedar" ]]; then + set_policy_args "$EXAMPLE_DIR/middleware.yaml" + else + set_policy_args "$EXAMPLE_DIR/policy.yaml" + fi SANDBOX_CREATED=1 run_setup_step \ "creating content guard sandbox" \ - "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" --no-tty --detach -- sleep infinity + "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" "${POLICY_ARGS[@]}" --no-tty --detach -- sleep infinity } request() { @@ -479,9 +506,14 @@ run_suite() { # Recreate with the same terms in deny mode, through the external service. "${CLI[@]}" sandbox delete "$SANDBOX_NAME" >>"$SETUP_LOG" 2>&1 SANDBOX_CREATED=0 - sed '/replacement:/d; s/mode: redact/mode: deny/' "$EXAMPLE_DIR/policy.yaml" >"$SMOKE_TMP_DIR/deny.yaml" + if [[ "$POLICY_FORMAT" == "cedar" ]]; then + sed '/replacement:/d; s/mode: redact/mode: deny/' "$EXAMPLE_DIR/middleware.yaml" >"$SMOKE_TMP_DIR/deny.yaml" + else + sed '/replacement:/d; s/mode: redact/mode: deny/' "$EXAMPLE_DIR/policy.yaml" >"$SMOKE_TMP_DIR/deny.yaml" + fi + set_policy_args "$SMOKE_TMP_DIR/deny.yaml" SANDBOX_CREATED=1 - run_setup_step "creating deny sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --policy "$SMOKE_TMP_DIR/deny.yaml" --no-tty --detach -- sleep infinity + run_setup_step "creating deny sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" "${POLICY_ARGS[@]}" --no-tty --detach -- sleep infinity if ! response_request sensitive >"$response_output" 2>>"$SETUP_LOG" || ! grep -Fq 'HTTP/1.1 403 Forbidden' "$response_output" || ! grep -Fq 'content_match' "$response_output" || diff --git a/examples/transparent-tcp-redis/README.md b/examples/transparent-tcp-redis/README.md index 4e04525da7..27608402e0 100644 --- a/examples/transparent-tcp-redis/README.md +++ b/examples/transparent-tcp-redis/README.md @@ -77,6 +77,9 @@ The example policy allows any sandbox binary to use this one Redis endpoint so the demo works across base images with different Python installation paths. In a production policy, replace `/**` with the exact path of the client binary. +`policy.cedar` is the same policy in Cedar, with `@transport("tcp")` in place of +`protocol: tcp`. Run the demo with `POLICY_FORMAT=cedar` to use it. + ## Configuration Override resource names or the image without editing the files: diff --git a/examples/transparent-tcp-redis/demo.sh b/examples/transparent-tcp-redis/demo.sh index 0d8b24c523..fd7bede371 100755 --- a/examples/transparent-tcp-redis/demo.sh +++ b/examples/transparent-tcp-redis/demo.sh @@ -6,7 +6,16 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -POLICY_FILE="${SCRIPT_DIR}/policy.yaml" +# POLICY_FORMAT=cedar uses policy.cedar instead of policy.yaml. +POLICY_FORMAT="${POLICY_FORMAT:-yaml}" +case "$POLICY_FORMAT" in + yaml | cedar) ;; + *) + printf 'POLICY_FORMAT must be yaml or cedar\n' >&2 + exit 2 + ;; +esac +POLICY_FILE="${SCRIPT_DIR}/policy.${POLICY_FORMAT}" CLIENT_FILE="${SCRIPT_DIR}/redis_client.py" SANDBOX_NAME="${SANDBOX_NAME:-tcp-redis-demo}" @@ -135,7 +144,7 @@ run openshell sandbox create \ --upload "${CLIENT_FILE}:/sandbox" \ --no-auto-providers \ --no-tty \ - -- echo 'sandbox ready' + --detach printf '\nRunning native Redis commands from the sandbox...\n' run openshell sandbox exec \ diff --git a/examples/transparent-tcp-redis/policy.cedar b/examples/transparent-tcp-redis/policy.cedar new file mode 100644 index 0000000000..4c8513985d --- /dev/null +++ b/examples/transparent-tcp-redis/policy.cedar @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy.yaml: any binary may open native TCP connections to +// redis.openshell.demo:6379 when the host resolves to a private address. +// Landlock compatibility (best_effort) and process identity use their +// defaults, as in policy.yaml. + +// filesystem_policy.read_write. Cedar has no include_workdir; /sandbox is the +// default workdir and is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/var/log" +}; + +// network_policies.redis. `protocol: tcp` is @transport("tcp"): the +// connection is plain L4 traffic, reached through policy DNS. The binary glob +// `/**` matches every binary. `allowed_ips` is the destination_ip condition: +// every address the host resolves to must lie in one of the ranges. +@id("redis/connect") +@transport("tcp") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"NetworkConnect", + resource == Sandbox::NetworkEndpoint::"redis.openshell.demo:6379" +) +when { + context.binary_path like("/**", "/") + && context has destination_ip + && (context.destination_ip.isInRange(ip("10.0.0.0/8")) + || context.destination_ip.isInRange(ip("172.16.0.0/12")) + || context.destination_ip.isInRange(ip("192.168.0.0/16"))) +}; diff --git a/openshell.spec b/openshell.spec index 2af40c1734..f3562020dc 100644 --- a/openshell.spec +++ b/openshell.spec @@ -98,6 +98,13 @@ management, agent execution, and provider access via gRPC. # Extract vendored Cargo dependencies and configure offline build tar xf %{SOURCE1} %cargo_prep -v vendor +# %%cargo_prep only redirects crates-io to the vendor directory. Git +# dependencies need their own [source."git+..."] replacement tables, which +# deploy/rpm/create-vendor-archive.sh captures from `cargo vendor` when +# building Source1. Absent when the lockfile has no git dependencies. +if [ -f vendor/.cargo-git-sources.toml ]; then + cat vendor/.cargo-git-sources.toml >> .cargo/config.toml +fi # Patch workspace version from placeholder to actual build identity. sed -i 's/^version = "0.0.0"/version = "%{openshell_cargo_version}"/' Cargo.toml diff --git a/proto/openshell.proto b/proto/openshell.proto index f4186ea440..1f25704a78 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -2759,8 +2759,8 @@ message UpdateConfigRequest { // The new policy to apply. // // Sandbox scope (`global=false`): - // - only network_policies may differ from create-time - // policy; static fields must match version 1. + // - only network_policies and cedar_policy_source may differ from + // create-time policy; static fields must match version 1. // // Global scope (`global=true`): // - applies to all sandboxes in full (no merge). diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 7f7f407c3c..29b0377a0a 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -33,6 +33,35 @@ message SandboxPolicy { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. map network_middlewares = 6; + // Raw Cedar policy source (.cedar syntax, against the canonical schema + // published by the openshell-policy-cedar-schema crate). When non-empty, + // this sandbox's network, per-request, and filesystem decisions come + // entirely from Cedar: Landlock grants are derived from the Cedar text + // (plus the supervisor's baseline paths), and `filesystem` is not used. + // `landlock.compatibility`, `process`, and `network_middlewares` are + // still honored; the CLI sets `process` and `network_middlewares` for a + // Cedar policy from the middleware file. Rejected at validation if + // `network_policies` or `filesystem` paths are also set. + // A sandbox cannot switch between Cedar and YAML after creation, and + // global policies must be YAML. + string cedar_policy_source = 7; + // Gateway-managed, for Cedar policies only: the network rules of the + // sandbox's attached providers, keyed like provider-composed + // `network_policies` entries. They supply endpoint settings for credential + // injection (credential markers, rewrite options, request signing) on + // connections the Cedar policy allows, and never grant access themselves. + // Rejected in user-submitted policies. + map provider_credential_rules = 8; + // For Cedar policies only: endpoint settings that are configuration, not + // access control, authored in the `endpoint_settings` section of the + // middleware file. Each entry selects endpoints by `host` (exact or glob), + // `port`/`ports`, and an optional `path`, and may set only `tls`, + // `allow_encoded_slash`, `json_rpc_max_body_bytes`, + // `graphql_max_body_bytes`, `mcp` (`versions`, `strict_tool_names`), + // `persisted_queries`, and `graphql_persisted_queries`. The Cedar policy + // decides access and inspection; these settings apply to the connections + // and requests it inspects. Rejected when `cedar_policy_source` is empty. + repeated NetworkEndpoint endpoint_settings = 9; } // Filesystem access policy. diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index 350c0dd595..68a221e03d 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -42,7 +42,7 @@ The launcher: - For `--harness codex`, configures gateway-managed refresh for `CODEX_AUTH_ACCESS_TOKEN` and rotates it before launching the sandbox. - Passes the non-secret Codex account ID from host auth as `CODEX_ACCOUNT_ID`. The harness writes it literally into `auth.json` for local workspace discovery; access tokens remain opaque provider placeholders and refresh tokens remain gateway-only. - Enables `agent_policy_proposals_enabled` and `proposal_approval_mode=auto` at gateway scope. -- Uses the gator image policy copied to `/etc/openshell/policy.yaml`. +- Uses the gator image policy copied to `/etc/openshell/policy.yaml`. `policy.cedar` is the same policy in Cedar, with `middleware.yaml` carrying its process identity for `--middleware`; an image policy must be YAML, and policy proposals merge YAML rules, so the launcher keeps the YAML policy. - Installs the gator-specific `gh` wrapper from `gator/bin/gh` as `/usr/local/bin/gh` to fail closed when same-head-SHA history cannot be checked, prevent duplicate dispositions, and require versioned review payloads. - Installs `gator/bin/review-feedback-ledger` as `/usr/local/bin/review-feedback-ledger` so reviews receive tree- and patch-aware scope, prior summaries and findings, resolution state, convergence telemetry, and the three-round Warning budget. Patch identity uses fetched merge-base/head trees in a temporary bare Git diff --git a/scripts/agents/gator/middleware.yaml b/scripts/agents/gator/middleware.yaml new file mode 100644 index 0000000000..0daf24eebb --- /dev/null +++ b/scripts/agents/gator/middleware.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Process identity for policy.cedar, passed with --middleware beside it. A +# Cedar policy has no `process` section; this is the `process` section of +# policy.yaml. The launcher keeps the YAML policy (see README.md), so this +# file is for starting a gator sandbox from policy.cedar by hand. + +process: + run_as_user: sandbox + run_as_group: sandbox diff --git a/scripts/agents/gator/policy.cedar b/scripts/agents/gator/policy.cedar new file mode 100644 index 0000000000..986a3adbea --- /dev/null +++ b/scripts/agents/gator/policy.cedar @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cedar version of policy.yaml, the gator sandbox policy. It grants +// filesystem access and no network access: without a NetworkConnect permit, +// Cedar denies every connection, like the empty network_policies map. +// +// Cedar has no `process` section. policy.yaml sets run_as_user and +// run_as_group to `sandbox`; middleware.yaml carries the same identity for +// `--middleware`. Landlock compatibility uses its default, best_effort. The +// gator image bakes policy.yaml into /etc/openshell/policy.yaml, which must +// be YAML; pass this file with `openshell sandbox create --policy` and +// middleware.yaml with `--middleware` instead. Gator relies on agent policy +// proposals, which merge YAML rules, so they do not apply to a Cedar sandbox. + +// filesystem_policy.read_write. Cedar has no include_workdir; /sandbox is the +// default workdir and is listed explicitly. +@id("filesystem/read_write") +permit ( + principal is Sandbox::Process, + action in [Sandbox::Action::"ReadFile", Sandbox::Action::"WriteFile"], + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/sandbox" + || resource in Sandbox::FilesystemPath::"/tmp" + || resource in Sandbox::FilesystemPath::"/dev/null" +}; + +// filesystem_policy.read_only. +@id("filesystem/read_only") +permit ( + principal is Sandbox::Process, + action == Sandbox::Action::"ReadFile", + resource is Sandbox::FilesystemPath +) +when { + resource in Sandbox::FilesystemPath::"/usr" + || resource in Sandbox::FilesystemPath::"/lib" + || resource in Sandbox::FilesystemPath::"/proc" + || resource in Sandbox::FilesystemPath::"/dev/urandom" + || resource in Sandbox::FilesystemPath::"/app" + || resource in Sandbox::FilesystemPath::"/etc" + || resource in Sandbox::FilesystemPath::"/var/log" +}; diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 785e12c9c6..ba7f150ae3 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -192,12 +192,15 @@ func TestConverterCoversAllProtoFields_TokenGrantFailureObservation(t *testing.T func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) { handled := fieldSet{ - "version": true, - "filesystem": true, - "network_policies": true, - "process": true, - "landlock": true, - "network_middlewares": true, + "version": true, + "filesystem": true, + "network_policies": true, + "process": true, + "landlock": true, + "network_middlewares": true, + "cedar_policy_source": true, + "provider_credential_rules": true, + "endpoint_settings": true, } assertAllFieldsCovered(t, (&sandboxpb.SandboxPolicy{}).ProtoReflect().Descriptor(), handled, nil) diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 1a473654e9..55001d767c 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -113,10 +113,11 @@ func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { return nil } result := &types.SandboxPolicy{ - Version: p.GetVersion(), - Filesystem: filesystemPolicyFromProto(p.GetFilesystem()), - Landlock: landlockPolicyFromProto(p.GetLandlock()), - Process: processPolicyFromProto(p.GetProcess()), + Version: p.GetVersion(), + Filesystem: filesystemPolicyFromProto(p.GetFilesystem()), + Landlock: landlockPolicyFromProto(p.GetLandlock()), + Process: processPolicyFromProto(p.GetProcess()), + CedarPolicySource: p.GetCedarPolicySource(), } if np := p.GetNetworkPolicies(); np != nil { result.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(np)) @@ -126,6 +127,22 @@ func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { } } } + if rules := p.GetProviderCredentialRules(); rules != nil { + result.ProviderCredentialRules = make(map[string]types.NetworkPolicyRule, len(rules)) + for k, v := range rules { + if converted := NetworkPolicyRuleFromProto(v); converted != nil { + result.ProviderCredentialRules[k] = *converted + } + } + } + if settings := p.GetEndpointSettings(); settings != nil { + result.EndpointSettings = make([]types.PolicyNetworkEndpoint, len(settings)) + for i, ep := range settings { + if ep != nil { + result.EndpointSettings[i] = policyNetworkEndpointFromProto(ep) + } + } + } if mw := p.GetNetworkMiddlewares(); mw != nil { result.NetworkMiddlewares = make(map[string]types.NetworkMiddlewareConfig, len(mw)) for k, v := range mw { @@ -144,10 +161,11 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { return nil } result := &sbv1.SandboxPolicy{ - Version: p.Version, - Filesystem: filesystemPolicyToProto(p.Filesystem), - Landlock: landlockPolicyToProto(p.Landlock), - Process: processPolicyToProto(p.Process), + Version: p.Version, + Filesystem: filesystemPolicyToProto(p.Filesystem), + Landlock: landlockPolicyToProto(p.Landlock), + Process: processPolicyToProto(p.Process), + CedarPolicySource: p.CedarPolicySource, } if p.NetworkPolicies != nil { result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies)) @@ -155,6 +173,18 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { result.NetworkPolicies[k] = NetworkPolicyRuleToProto(&v) } } + if p.ProviderCredentialRules != nil { + result.ProviderCredentialRules = make(map[string]*sbv1.NetworkPolicyRule, len(p.ProviderCredentialRules)) + for k, v := range p.ProviderCredentialRules { + result.ProviderCredentialRules[k] = NetworkPolicyRuleToProto(&v) + } + } + if p.EndpointSettings != nil { + result.EndpointSettings = make([]*sbv1.NetworkEndpoint, len(p.EndpointSettings)) + for i := range p.EndpointSettings { + result.EndpointSettings[i] = policyNetworkEndpointToProto(&p.EndpointSettings[i]) + } + } if p.NetworkMiddlewares != nil { result.NetworkMiddlewares = make(map[string]*sbv1.NetworkMiddlewareConfig, len(p.NetworkMiddlewares)) for k, v := range p.NetworkMiddlewares { diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go index dde2205773..7e7fa4b3ac 100644 --- a/sdk/go/openshell/v1/internal/converter/policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -252,6 +252,28 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { assert.Equal(t, "db", db.Name) } +func TestSandboxPolicyEndpointSettingsRoundTrip(t *testing.T) { + original := &v1.SandboxPolicy{ + CedarPolicySource: "permit (principal, action, resource);", + EndpointSettings: []v1.PolicyNetworkEndpoint{ + {Host: "gitlab.example.com", Port: 443, Path: "/api/**", AllowEncodedSlash: true}, + }, + } + + proto := SandboxPolicyToProto(original) + require.Len(t, proto.GetEndpointSettings(), 1) + assert.Equal(t, "/api/**", proto.GetEndpointSettings()[0].GetPath()) + + roundTrip := SandboxPolicyFromProto(proto) + require.Len(t, roundTrip.EndpointSettings, 1) + assert.Equal(t, original.EndpointSettings[0].Host, roundTrip.EndpointSettings[0].Host) + assert.True(t, roundTrip.EndpointSettings[0].AllowEncodedSlash) + + // The SDK copy is isolated from the proto it came from. + proto.EndpointSettings[0].Host = "mutated.example.com" + assert.Equal(t, "gitlab.example.com", roundTrip.EndpointSettings[0].Host) +} + func TestSandboxPolicyDeepCopy(t *testing.T) { // Build a proto, convert to SDK, mutate proto, verify SDK is isolated. proto := &sbv1.SandboxPolicy{ diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index 8fb0712b60..bb017e8bb1 100644 --- a/sdk/go/openshell/v1/types/policy.go +++ b/sdk/go/openshell/v1/types/policy.go @@ -131,6 +131,20 @@ type SandboxPolicy struct { // NetworkMiddlewares contains named middleware pipeline configurations for // network egress. Nil means no middleware is specified; an empty map is distinct from nil. NetworkMiddlewares map[string]NetworkMiddlewareConfig + // CedarPolicySource is the policy written in Cedar. When non-empty, Cedar + // enforces network, per-request, and filesystem access, and NetworkPolicies + // must be empty. Filesystem and Landlock are ignored. + CedarPolicySource string + // ProviderCredentialRules is set by the gateway for Cedar policies: the + // network rules of attached providers, used only for credential injection + // settings. They never grant access. Do not set it when writing a policy. + ProviderCredentialRules map[string]NetworkPolicyRule + // EndpointSettings is set only for Cedar policies: endpoint configuration + // that is not access control (TLS skip, encoded slashes, body limits, MCP + // revisions, GraphQL persisted queries), selected by host, port, and + // optional path. The Cedar policy decides access and inspection. Nil means + // no endpoint settings are specified. + EndpointSettings []PolicyNetworkEndpoint } // NetworkMiddlewareConfig configures a supervisor middleware pipeline for diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 68a550bdec..d8afec9b3a 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -11766,8 +11766,8 @@ type UpdateConfigRequest struct { // The new policy to apply. // // Sandbox scope (`global=false`): - // - only network_policies may differ from create-time - // policy; static fields must match version 1. + // - only network_policies and cedar_policy_source may differ from + // create-time policy; static fields must match version 1. // // Global scope (`global=true`): // - applies to all sandboxes in full (no merge). diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 237c2c94ba..2a871052b6 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -315,8 +315,37 @@ type SandboxPolicy struct { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. NetworkMiddlewares map[string]*NetworkMiddlewareConfig `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 + // Raw Cedar policy source (.cedar syntax, against the canonical schema + // published by the openshell-policy-cedar-schema crate). When non-empty, + // this sandbox's network, per-request, and filesystem decisions come + // entirely from Cedar: Landlock grants are derived from the Cedar text + // (plus the supervisor's baseline paths), and `filesystem` is not used. + // `landlock.compatibility`, `process`, and `network_middlewares` are + // still honored; the CLI sets `process` and `network_middlewares` for a + // Cedar policy from the middleware file. Rejected at validation if + // `network_policies` or `filesystem` paths are also set. + // A sandbox cannot switch between Cedar and YAML after creation, and + // global policies must be YAML. + CedarPolicySource string `protobuf:"bytes,7,opt,name=cedar_policy_source,json=cedarPolicySource,proto3" json:"cedar_policy_source,omitempty"` + // Gateway-managed, for Cedar policies only: the network rules of the + // sandbox's attached providers, keyed like provider-composed + // `network_policies` entries. They supply endpoint settings for credential + // injection (credential markers, rewrite options, request signing) on + // connections the Cedar policy allows, and never grant access themselves. + // Rejected in user-submitted policies. + ProviderCredentialRules map[string]*NetworkPolicyRule `protobuf:"bytes,8,rep,name=provider_credential_rules,json=providerCredentialRules,proto3" json:"provider_credential_rules,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // For Cedar policies only: endpoint settings that are configuration, not + // access control, authored in the `endpoint_settings` section of the + // middleware file. Each entry selects endpoints by `host` (exact or glob), + // `port`/`ports`, and an optional `path`, and may set only `tls`, + // `allow_encoded_slash`, `json_rpc_max_body_bytes`, + // `graphql_max_body_bytes`, `mcp` (`versions`, `strict_tool_names`), + // `persisted_queries`, and `graphql_persisted_queries`. The Cedar policy + // decides access and inspection; these settings apply to the connections + // and requests it inspects. Rejected when `cedar_policy_source` is empty. + EndpointSettings []*NetworkEndpoint `protobuf:"bytes,9,rep,name=endpoint_settings,json=endpointSettings,proto3" json:"endpoint_settings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxPolicy) Reset() { @@ -391,6 +420,27 @@ func (x *SandboxPolicy) GetNetworkMiddlewares() map[string]*NetworkMiddlewareCon return nil } +func (x *SandboxPolicy) GetCedarPolicySource() string { + if x != nil { + return x.CedarPolicySource + } + return "" +} + +func (x *SandboxPolicy) GetProviderCredentialRules() map[string]*NetworkPolicyRule { + if x != nil { + return x.ProviderCredentialRules + } + return nil +} + +func (x *SandboxPolicy) GetEndpointSettings() []*NetworkEndpoint { + if x != nil { + return x.EndpointSettings + } + return nil +} + // Filesystem access policy. type FilesystemPolicy struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2286,7 +2336,7 @@ var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + "\n" + - "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x0fdatamodel.proto\"\xa8\x05\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x0fdatamodel.proto\"\x9f\b\n" + "\rSandboxPolicy\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + "\n" + @@ -2295,13 +2345,19 @@ const file_sandbox_proto_rawDesc = "" + "\blandlock\x18\x03 \x01(\v2$.openshell.sandbox.v1.LandlockPolicyR\blandlock\x12=\n" + "\aprocess\x18\x04 \x01(\v2#.openshell.sandbox.v1.ProcessPolicyR\aprocess\x12c\n" + "\x10network_policies\x18\x05 \x03(\v28.openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntryR\x0fnetworkPolicies\x12l\n" + - "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x1ak\n" + + "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x12.\n" + + "\x13cedar_policy_source\x18\a \x01(\tR\x11cedarPolicySource\x12|\n" + + "\x19provider_credential_rules\x18\b \x03(\v2@.openshell.sandbox.v1.SandboxPolicy.ProviderCredentialRulesEntryR\x17providerCredentialRules\x12R\n" + + "\x11endpoint_settings\x18\t \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\x10endpointSettings\x1ak\n" + "\x14NetworkPoliciesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + "\x05value\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x05value:\x028\x01\x1at\n" + "\x17NetworkMiddlewaresEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12C\n" + - "\x05value\x18\x02 \x01(\v2-.openshell.sandbox.v1.NetworkMiddlewareConfigR\x05value:\x028\x01\"w\n" + + "\x05value\x18\x02 \x01(\v2-.openshell.sandbox.v1.NetworkMiddlewareConfigR\x05value:\x028\x01\x1as\n" + + "\x1cProviderCredentialRulesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + + "\x05value\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\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" + @@ -2503,7 +2559,7 @@ func file_sandbox_proto_rawDescGZIP() []byte { } var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 33) var file_sandbox_proto_goTypes = []any{ (NetworkTlsMode)(0), // 0: openshell.sandbox.v1.NetworkTlsMode (NetworkEnforcementMode)(0), // 1: openshell.sandbox.v1.NetworkEnforcementMode @@ -2535,16 +2591,17 @@ var file_sandbox_proto_goTypes = []any{ (*SupervisorMiddlewareService)(nil), // 27: openshell.sandbox.v1.SupervisorMiddlewareService nil, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry nil, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 30: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 31: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 32: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 33: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 34: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 35: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 36: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 37: google.protobuf.Struct - (*datamodelv1.WorkspaceSelector)(nil), // 38: openshell.datamodel.v1.WorkspaceSelector - (*durationpb.Duration)(nil), // 39: google.protobuf.Duration + nil, // 30: openshell.sandbox.v1.SandboxPolicy.ProviderCredentialRulesEntry + nil, // 31: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 32: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 33: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 34: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 35: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 36: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 37: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 38: google.protobuf.Struct + (*datamodelv1.WorkspaceSelector)(nil), // 39: openshell.datamodel.v1.WorkspaceSelector + (*durationpb.Duration)(nil), // 40: google.protobuf.Duration } var file_sandbox_proto_depIdxs = []int32{ 6, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy @@ -2552,46 +2609,49 @@ var file_sandbox_proto_depIdxs = []int32{ 8, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy 28, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry 29, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - 13, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 20, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 37, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct - 11, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector - 0, // 9: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode - 1, // 10: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode - 2, // 11: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset - 17, // 12: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule - 16, // 13: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 30, // 14: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - 14, // 15: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions - 12, // 16: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding - 31, // 17: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry - 32, // 18: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry - 18, // 19: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow - 33, // 20: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry - 34, // 21: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 38, // 22: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 35, // 23: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - 24, // 24: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue - 3, // 25: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 5, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 36, // 27: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - 4, // 28: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 27, // 29: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 39, // 30: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration - 9, // 31: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 10, // 32: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 15, // 33: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 19, // 34: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 35: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 36: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 37: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 24, // 38: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 25, // 39: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 40, // [40:40] is the sub-list for method output_type - 40, // [40:40] is the sub-list for method input_type - 40, // [40:40] is the sub-list for extension type_name - 40, // [40:40] is the sub-list for extension extendee - 0, // [0:40] is the sub-list for field type_name + 30, // 5: openshell.sandbox.v1.SandboxPolicy.provider_credential_rules:type_name -> openshell.sandbox.v1.SandboxPolicy.ProviderCredentialRulesEntry + 13, // 6: openshell.sandbox.v1.SandboxPolicy.endpoint_settings:type_name -> openshell.sandbox.v1.NetworkEndpoint + 13, // 7: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 20, // 8: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 38, // 9: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct + 11, // 10: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector + 0, // 11: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode + 1, // 12: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode + 2, // 13: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset + 17, // 14: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 16, // 15: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 31, // 16: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 14, // 17: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions + 12, // 18: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 32, // 19: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 33, // 20: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry + 18, // 21: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 34, // 22: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 35, // 23: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry + 39, // 24: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 36, // 25: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 24, // 26: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 3, // 27: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 5, // 28: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 37, // 29: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 4, // 30: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 27, // 31: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 40, // 32: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration + 9, // 33: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 10, // 34: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 9, // 35: openshell.sandbox.v1.SandboxPolicy.ProviderCredentialRulesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 15, // 36: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 19, // 37: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 19, // 38: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 19, // 39: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 19, // 40: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 24, // 41: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 25, // 42: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 43, // [43:43] is the sub-list for method output_type + 43, // [43:43] is the sub-list for method input_type + 43, // [43:43] is the sub-list for extension type_name + 43, // [43:43] is the sub-list for extension extendee + 0, // [0:43] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } @@ -2612,7 +2672,7 @@ func file_sandbox_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)), NumEnums: 5, - NumMessages: 32, + NumMessages: 33, NumExtensions: 0, NumServices: 0, }, diff --git a/tasks/test.toml b/tasks/test.toml index c1df20262a..a689df4f2b 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -92,8 +92,8 @@ run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" ta hide = true [e2e] -description = "Run all end-to-end tests (Rust + Python + MCP)" -depends = ["e2e:rust", "e2e:python", "e2e:mcp"] +description = "Run all end-to-end tests (Rust + Python + MCP + Cedar)" +depends = ["e2e:rust", "e2e:python", "e2e:mcp", "e2e:cedar"] ["e2e:test"] description = "Build the current checkout and run a named host or Nix test-guest E2E suite" @@ -295,6 +295,14 @@ run = "e2e/rust/e2e-kubernetes.sh" description = "Run Docker conformance and Rust e2e tests against a standalone gateway" run = "e2e/rust/e2e-docker.sh" +["e2e:cedar"] +description = "Run the pure-Cedar policy e2e suite (network, filesystem, syscalls, --middleware) against a Docker-backed gateway" +run = [ + "if [ -z \"${OPENSHELL_BIN:-}\" ]; then cargo build -p openshell-cli; fi", + "CONTAINER_ENGINE=docker bash tasks/scripts/e2e-build-workload.sh", + "OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE=openshell/e2e-python:dev OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY=never e2e/with-docker-gateway.sh bash -lc 'OPENSHELL_BIN=\"${OPENSHELL_BIN:-$PWD/target/debug/openshell}\" bash e2e/cedar/test.sh'", +] + ["e2e:mechanistic-existing-endpoint"] description = "Run #2821 existing inspected-endpoint auto-approval regression" run = [ From 29fb1de8caea29b45d8aeffb27ab77bc69e8980d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 17:46:12 +0000 Subject: [PATCH 02/19] fix(cedar): bound host analysis and isolate staged evaluation Signed-off-by: Johnny Greco --- Cargo.lock | 1 + crates/openshell-policy-cedar/Cargo.toml | 1 + crates/openshell-policy-cedar/src/analysis.rs | 150 +++++++++++++----- crates/openshell-policy-cedar/src/lib.rs | 32 +++- .../tests/l7_host_globs.rs | 46 ++++++ .../openshell-policy-cedar/tests/l7_paths.rs | 29 +++- crates/openshell-policy-cedar/tests/query.rs | 49 ++++++ 7 files changed, 260 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 844a7b2d87..27da3f6b4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5152,6 +5152,7 @@ dependencies = [ "openshell-policy-cedar-schema", "serde_json", "thiserror 2.0.20", + "tracing", ] [[package]] diff --git a/crates/openshell-policy-cedar/Cargo.toml b/crates/openshell-policy-cedar/Cargo.toml index 0ce7128511..7a96994dcf 100644 --- a/crates/openshell-policy-cedar/Cargo.toml +++ b/crates/openshell-policy-cedar/Cargo.toml @@ -19,6 +19,7 @@ openshell-core = { path = "../openshell-core", default-features = false } openshell-policy-cedar-schema = { path = "../openshell-policy-cedar-schema" } serde_json = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-policy-cedar/src/analysis.rs b/crates/openshell-policy-cedar/src/analysis.rs index 2a3dab9055..f671c4714c 100644 --- a/crates/openshell-policy-cedar/src/analysis.rs +++ b/crates/openshell-policy-cedar/src/analysis.rs @@ -499,8 +499,10 @@ pub struct PolicyAnalysis { /// The keys of [`Self::l7_endpoints`] whose host is a glob, compiled /// with the matcher policy DNS and YAML endpoints use, in key order. pub(crate) l7_host_patterns: Vec, - /// The query keys a policy can read. + /// The query keys deciding policies can read. pub(crate) query_keys: QueryKeys, + /// The query keys all policies, including staged policies, can read. + pub(crate) staged_query_keys: QueryKeys, /// What each `NetworkConnect` permit requires of a connection, by id. pub(crate) network_permits: HashMap, /// Each permit that reads `destination_ip`, without its address @@ -687,13 +689,17 @@ pub fn analyze(schema: &Schema, policies: &PolicySet) -> Result = BTreeMap::new(); let mut hostless_permits = Vec::new(); let mut query_keys = QueryKeys::default(); + let mut staged_query_keys = QueryKeys::default(); let mut binary_alias_paths = BTreeSet::new(); let mut binary_glob_patterns = BTreeSet::new(); let mut http_permits = HashMap::new(); for policy in policies.policies() { let policy_id = display_id(policy); - query_keys.add_policy(policy); + staged_query_keys.add_policy(policy); + if !is_staged(policy) { + query_keys.add_policy(policy); + } let json = policy .to_json() .map_err(|_| CedarEngineError::UnsupportedPolicy { @@ -974,6 +980,7 @@ pub fn analyze(schema: &Schema, policies: &PolicySet) -> Result bool + Copy) -> boo } } -/// True if `expr` reads `resource.`, or the whole `resource` record. +/// True if `expr` reads or tests `resource.`. fn mentions_resource_attr(expr: &Value, attr: &str) -> bool { match expr { Value::Object(fields) => fields.iter().any(|(name, value)| { - if name == "Var" && value.as_str() == Some("resource") { - return true; - } - if name == "." && is_resource_attr(expr, attr) { - return true; + if (name == "." || name == "has") + && value + .get("left") + .and_then(|left| left.get("Var")) + .and_then(Value::as_str) + == Some("resource") + { + return value.get("attr").and_then(Value::as_str) == Some(attr); } mentions_resource_attr(value, attr) }), @@ -1912,48 +1922,59 @@ fn host_tokens(pattern: &str) -> Vec { /// Returns whether some host matches both host patterns, each an exact /// host or a host glob in policy DNS syntax. pub fn host_patterns_overlap(left: &str, right: &str) -> bool { + if !left.contains('*') && !right.contains('*') { + return left == right; + } + // Authored selectors are bounded at load time. Keep this public helper + // conservative for oversized callers too: an unknown overlap must not + // bypass checks for conflicting inspection modes. + if left.len() > MAX_DNS_NAME_LEN || right.len() > MAX_DNS_NAME_LEN { + return true; + } let (left, right) = (host_tokens(left), host_tokens(right)); let accepts = |token: HostToken, c: char| match token { HostToken::Literal(literal) => literal == c, HostToken::Star { crosses_labels } => crosses_labels || c != '.', }; - // Search the states (position in left, position in right) reachable - // while reading one host through both patterns. - let mut seen = vec![vec![false; right.len() + 1]; left.len() + 1]; - let mut pending = vec![(0, 0)]; - while let Some((i, j)) = pending.pop() { - if std::mem::replace(&mut seen[i][j], true) { - continue; - } - if i == left.len() && j == right.len() { - return true; - } - let (l, r) = (left.get(i).copied(), right.get(j).copied()); - // A star may match nothing. - if let Some(HostToken::Star { .. }) = l { - pending.push((i + 1, j)); - } - if let Some(HostToken::Star { .. }) = r { - pending.push((i, j + 1)); - } - // Read one character that both patterns accept. Two stars reading - // the same character stay where they are, so only a literal moves. - match (l, r) { - (Some(HostToken::Literal(a)), Some(HostToken::Literal(b))) if a == b => { - pending.push((i + 1, j + 1)); + // Every transition advances at least one pattern position. Keep only + // the current and next row of reachable states, rather than a matrix. + let mut current = vec![false; right.len() + 1]; + let mut next = vec![false; right.len() + 1]; + current[0] = true; + for i in 0..=left.len() { + for j in 0..=right.len() { + if !current[j] { + continue; } - (Some(HostToken::Literal(c)), Some(star @ HostToken::Star { .. })) - if accepts(star, c) => - { - pending.push((i + 1, j)); + if i == left.len() && j == right.len() { + return true; } - (Some(star @ HostToken::Star { .. }), Some(HostToken::Literal(c))) - if accepts(star, c) => - { - pending.push((i, j + 1)); + let (l, r) = (left.get(i).copied(), right.get(j).copied()); + if let Some(HostToken::Star { .. }) = l { + next[j] = true; + } + if let Some(HostToken::Star { .. }) = r { + current[j + 1] = true; + } + match (l, r) { + (Some(HostToken::Literal(a)), Some(HostToken::Literal(b))) if a == b => { + next[j + 1] = true; + } + (Some(HostToken::Literal(c)), Some(star @ HostToken::Star { .. })) + if accepts(star, c) => + { + next[j] = true; + } + (Some(star @ HostToken::Star { .. }), Some(HostToken::Literal(c))) + if accepts(star, c) => + { + current[j + 1] = true; + } + _ => {} } - _ => {} } + std::mem::swap(&mut current, &mut next); + next.fill(false); } false } @@ -2213,6 +2234,9 @@ fn parse_endpoint(policy_id: &str, endpoint: &str) -> Result<(String, u16), Ceda if host.is_empty() { return Err(invalid("host must not be empty")); } + if host.len() > MAX_DNS_NAME_LEN { + return Err(invalid("host selector must not exceed 253 bytes")); + } if host.ends_with('.') { return Err(invalid("host must not end with '.'")); } @@ -2280,7 +2304,16 @@ fn condition_endpoint( HostConstraint::Eligible(_) | HostConstraint::Ineligible => single_port(&operands), }; Ok(match (host, port) { - (HostConstraint::Eligible(host), Some(port)) => Some((host, port)), + (HostConstraint::Eligible(host), Some(port)) => { + if host.len() > MAX_DNS_NAME_LEN { + return Err(CedarEngineError::InvalidEndpoint { + policy_id: policy_id.to_string(), + endpoint: format!("{host}:{port}"), + reason: "host selector must not exceed 253 bytes".to_string(), + }); + } + Some((host, port)) + } _ => None, }) } @@ -2699,3 +2732,36 @@ pub fn display_id(policy: &Policy) -> String { .annotation(ID_ANNOTATION) .map_or_else(|| policy.id().to_string(), ToString::to_string) } + +#[cfg(test)] +mod overlap_tests { + use super::host_patterns_overlap; + + #[test] + fn literal_and_glob_boundaries() { + for (left, right, overlaps) in [ + ("api.example.com", "api.example.com", true), + ("api.example.com", "other.example.com", false), + ("*.example.com", "api.example.com", true), + ("*.example.com", "deep.api.example.com", false), + ("**.example.com", "deep.api.example.com", true), + ("a*.example.com", "*b.example.com", true), + ("*.example.com", "*.example.org", false), + ] { + assert_eq!( + host_patterns_overlap(left, right), + overlaps, + "{left} vs {right}" + ); + assert_eq!( + host_patterns_overlap(right, left), + overlaps, + "{right} vs {left}" + ); + } + let huge = "a".repeat(100_000); + assert!(host_patterns_overlap(&huge, &huge)); + assert!(!host_patterns_overlap(&huge, "other.example.com")); + assert!(host_patterns_overlap(&format!("*.{huge}"), "*.example.com")); + } +} diff --git a/crates/openshell-policy-cedar/src/lib.rs b/crates/openshell-policy-cedar/src/lib.rs index 55f1189e9b..746c895cb2 100644 --- a/crates/openshell-policy-cedar/src/lib.rs +++ b/crates/openshell-policy-cedar/src/lib.rs @@ -519,6 +519,20 @@ impl CedarEngine { merged.into_iter() } + /// Returns configured endpoint selectors and paths without expanding host globs. + pub fn configured_l7_endpoints( + &self, + ) -> impl Iterator { + self.analysis + .l7_endpoints + .iter() + .flat_map(|((host, port), paths)| { + paths + .iter() + .map(move |(path, endpoint)| (host.clone(), *port, path.clone(), *endpoint)) + }) + } + /// Evaluates one network-connect request against the loaded policy set. /// /// # Errors @@ -647,7 +661,8 @@ impl CedarEngine { /// has more than [`MAX_QUERY_COMBINATIONS`] value combinations, and /// another [`CedarEngineError`] if the request cannot be represented in /// the loaded schema, or if Cedar reports an error while evaluating any - /// policy. + /// deciding policy. Errors in the staged comparison are logged without + /// changing the enforced decision. pub fn evaluate_l7( &self, request: &HttpRequestInput, @@ -655,7 +670,6 @@ impl CedarEngine { let endpoint = self.l7_endpoint_at(&request.host, request.port, &request.endpoint_path); let protocol = endpoint.map_or("", |endpoint| endpoint.protocol.as_str()); let query = QueryCombinations::new(&request.query, &self.analysis.query_keys)?; - let evaluate = |policies: &PolicySet| self.decide_l7(policies, request, protocol, &query); // Any entry selecting the host that declares the routed path works: // its exclusion set covers every entry that can select the host. let graphql = request @@ -675,13 +689,21 @@ impl CedarEngine { let (deciding, all) = graphql.map_or((&self.deciding_policies, &self.policies), |sets| { (&sets.deciding, &sets.all) }); - let decision = evaluate(deciding)?; + let decision = self.decide_l7(deciding, request, protocol, &query)?; // Compare decisions, not the staged set's decision alone: Cedar denies // by default, so a set of only staged `forbid`s denies every request // whether or not one of them matched. let staged = if endpoint.is_some_and(|endpoint| endpoint.staged) { - let staged = evaluate(all)?; - (staged.is_allow() != decision.is_allow()).then_some(staged) + match QueryCombinations::new(&request.query, &self.analysis.staged_query_keys) + .and_then(|query| self.decide_l7(all, request, protocol, &query)) + { + Ok(staged) => (staged.is_allow() != decision.is_allow()).then_some(staged), + Err(_) => { + // Never include authored expressions or request query values. + tracing::debug!("staged Cedar evaluation failed; retaining enforced decision"); + None + } + } } else { None }; diff --git a/crates/openshell-policy-cedar/tests/l7_host_globs.rs b/crates/openshell-policy-cedar/tests/l7_host_globs.rs index 2dd7bf85ed..7b2d00ece2 100644 --- a/crates/openshell-policy-cedar/tests/l7_host_globs.rs +++ b/crates/openshell-policy-cedar/tests/l7_host_globs.rs @@ -31,6 +31,24 @@ when {{ {condition} && {extra} }}; const WILDCARD_443: &str = r#"resource.host like("*.example.com", ".") && resource.port == 443"#; +#[test] +fn oversized_selectors_are_rejected_before_overlap_analysis() { + let host = "a".repeat(100_000); + let scoped = format!( + r#"permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"{host}:443");"# + ); + assert!(matches!( + rejection(&format!("{scoped}{scoped}")), + CedarEngineError::InvalidEndpoint { .. } + )); + let glob = format!(r#"resource.host like("*.{host}.com", ".") && resource.port == 443"#); + assert!(matches!( + rejection(&glob_policy("", "permit", &glob, "true")), + CedarEngineError::InvalidEndpoint { .. } + )); +} + /// A request from curl to `host:443`, routed to the Cedar path `endpoint_path`. fn request(host: &str, method: &str, path: &str, endpoint_path: &str) -> HttpRequestInput { HttpRequestInput { @@ -96,6 +114,22 @@ fn a_host_glob_inspects_every_matching_host() { ); } +#[test] +fn configured_endpoints_preserve_host_globs_and_paths() { + let engine = engine(&glob_policy( + r#"@path("/mcp/**") @protocol("mcp")"#, + "permit", + WILDCARD_443, + "true", + )); + let configured: Vec<_> = engine.configured_l7_endpoints().collect(); + assert_eq!(configured.len(), 1); + assert_eq!(configured[0].0, "*.example.com"); + assert_eq!(configured[0].1, 443); + assert_eq!(configured[0].2, "/mcp/**"); + assert_eq!(configured[0].3.protocol, L7Protocol::Mcp); +} + #[test] fn a_forbid_with_a_host_glob_inspects_too() { let engine = engine(&glob_policy( @@ -185,6 +219,18 @@ fn glob_endpoints_take_paths_protocols_and_enforcement() { Some(L7Protocol::Graphql) ); assert!(inspected(&engine, "example.com").is_empty()); + for operation in ["query", "mutation"] { + let request = HttpRequestInput { + graphql_request: true, + graphql_operation_type: operation.to_string(), + ..request("other.example.com", "GET", "/graphql", "/graphql") + }; + assert_eq!( + engine.evaluate_l7(&request).unwrap().is_allow(), + operation == "query", + "a REST host-glob permit must not allow a GraphQL {operation}" + ); + } } #[test] diff --git a/crates/openshell-policy-cedar/tests/l7_paths.rs b/crates/openshell-policy-cedar/tests/l7_paths.rs index a3eabb66d8..37f320b241 100644 --- a/crates/openshell-policy-cedar/tests/l7_paths.rs +++ b/crates/openshell-policy-cedar/tests/l7_paths.rs @@ -749,6 +749,33 @@ fn graphql_aware_permits_of_other_paths_still_apply() { assert!(evaluation.is_allow(), "{evaluation:?}"); } +#[test] +fn other_resource_attributes_do_not_make_rest_permits_graphql_aware() { + for rest_scope in [ + " && resource.port == 443", + " && resource has port", + r#" && resource.host like("*.example.com", ".")"#, + r#" && resource == Sandbox::NetworkEndpoint::"api.example.com:443""#, + " && resource == resource", + ] { + let engine = engine(&rest_and_graphql(rest_scope)); + assert!( + !engine + .evaluate_l7(&graphql("mutation", &["createIssue"])) + .unwrap() + .is_allow(), + "{rest_scope}" + ); + assert!( + engine + .evaluate_l7(&graphql("query", &["viewer"])) + .unwrap() + .is_allow(), + "{rest_scope}" + ); + } +} + #[test] fn websocket_graphql_messages_are_authoritative_but_the_upgrade_is_not() { let policy = format!( @@ -756,7 +783,7 @@ fn websocket_graphql_messages_are_authoritative_but_the_upgrade_is_not() { http_policy( r#"@path("/**") @id("rest")"#, "permit", - r#"["GET", "WEBSOCKET_TEXT"].contains(context.method)"# + r#"["GET", "WEBSOCKET_TEXT"].contains(context.method) && resource.port == 443"# ), http_policy( r#"@path("/ws") @protocol("websocket-graphql") @id("ws")"#, diff --git a/crates/openshell-policy-cedar/tests/query.rs b/crates/openshell-policy-cedar/tests/query.rs index d990d44a5a..352277f0f2 100644 --- a/crates/openshell-policy-cedar/tests/query.rs +++ b/crates/openshell-policy-cedar/tests/query.rs @@ -328,6 +328,55 @@ fn staged_policies_combine_like_deciding_ones() { ); } +#[test] +fn staged_query_expansion_cannot_fail_an_enforced_decision() { + let staged = http_policy( + r#"@enforcement("staged")"#, + "forbid", + &format!("{} && {}", tag_like("a", "*"), tag_like("b", "*")), + ); + let values: Vec = (0..65).map(|value| value.to_string()).collect(); + let values: Vec<&str> = values.iter().map(String::as_str).collect(); + let request = request(&[("a", &values), ("b", &values[..64])]); + for condition in ["true", "false"] { + let baseline = http_policy("", "permit", condition); + let expected = engine(&baseline).evaluate_l7(&request).unwrap(); + let actual = engine(&format!("{baseline}{staged}")) + .evaluate_l7(&request) + .unwrap(); + assert_eq!(actual.decision, expected.decision); + assert_eq!(actual.staged, None, "the comparison exceeds its budget"); + } +} + +#[test] +fn staged_runtime_errors_do_not_change_enforced_decisions() { + let overflow = "resource.port * 9223372036854775807 > 0"; + for condition in ["true", "false"] { + let baseline = http_policy("", "permit", condition); + let expected = engine(&baseline).evaluate_l7(&request(&[])).unwrap(); + let actual = engine(&format!( + "{}{}", + baseline, + http_policy(r#"@enforcement("staged")"#, "forbid", overflow), + )) + .evaluate_l7(&request(&[])) + .unwrap(); + assert_eq!(actual.decision, expected.decision); + assert_eq!(actual.staged, None); + } + // The same error in an enforced policy must still fail closed. + let enforced = engine(&format!( + "{}{}", + http_policy("", "permit", "true"), + http_policy("", "forbid", overflow), + )); + assert!(matches!( + enforced.evaluate_l7(&request(&[])), + Err(CedarEngineError::Evaluation { .. }) + )); +} + #[test] fn query_is_not_part_of_network_connect() { let error = CedarEngine::from_policy_str( From f6175bd4d490942fa76c4c7f99863599516a6b71 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 17:46:27 +0000 Subject: [PATCH 03/19] docs(cedar): preserve policy format and middleware during iteration Signed-off-by: Johnny Greco --- CONTRIBUTING.md | 2 +- docs/how-it-works/policies/cedar.mdx | 6 +++- skills/generate-sandbox-policy/SKILL.md | 22 ++++++++++-- skills/openshell-cli/SKILL.md | 48 ++++++++++++++++--------- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4aef8d279c..8b1bd05c7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ Public skills live in `skills/` and work without an OpenShell source checkout. I | `openshell-cli` | CLI usage, sandbox lifecycle, provider management, and BYOC workflows | | `debug-openshell-cluster` | Diagnose gateway deployment and health issues | | `debug-inference` | Diagnose attached-provider inference, native endpoints, and migration from the retired managed endpoint | -| `generate-sandbox-policy` | Generate YAML sandbox policies from requirements or API documentation | +| `generate-sandbox-policy` | Generate or edit YAML and Cedar sandbox policies while preserving their format and middleware configuration | Public skills use `openshell --help` for installed command syntax and published OpenShell documentation for product concepts and configuration. They must not depend on repository-relative source or documentation files. diff --git a/docs/how-it-works/policies/cedar.mdx b/docs/how-it-works/policies/cedar.mdx index 40fb3f8cf2..d638757d9a 100644 --- a/docs/how-it-works/policies/cedar.mdx +++ b/docs/how-it-works/policies/cedar.mdx @@ -16,6 +16,10 @@ Cedar's restricted language makes policies easier to analyze, for example to che OpenShell treats a policy file as Cedar when its extension is `.cedar`, in any case. Any other extension is parsed as YAML. +Host selectors, including host globs, must not exceed 253 bytes. + +Both the gateway and sandbox runtime must support Cedar. The CLI refuses to send Cedar policies to a gateway that does not advertise Cedar support, and the gateway withholds Cedar configuration from an older runtime. Upgrade both before applying a Cedar policy. + Create a sandbox with a Cedar policy: ```shell @@ -356,7 +360,7 @@ forbid ( when { context.query.hasTag("force") && context.query.getTag("force") == "true" }; ``` -When a key repeats, as in `?tag=foo-a&tag=foo-b`, OpenShell evaluates the request once for each combination of values. It allows the request only if no evaluation is denied and one `permit` allows every evaluation. This matches YAML: a permit must accept every value of each key it reads, and a `forbid` applies when each key it reads has one matching value. Write each YAML allow rule as its own `permit`, because two rules joined with `||` in one permit can together allow values that neither rule allows alone. OpenShell denies a request with more than 4096 combinations of the keys that policies read. +When a key repeats, as in `?tag=foo-a&tag=foo-b`, OpenShell evaluates the request once for each combination of values. It allows the request only if no evaluation is denied and one `permit` allows every evaluation. This matches YAML: a permit must accept every value of each key it reads, and a `forbid` applies when each key it reads has one matching value. Write each YAML allow rule as its own `permit`, because two rules joined with `||` in one permit can together allow values that neither rule allows alone. OpenShell denies a request with more than 4096 combinations of the keys that deciding policies read. Staged policies have a separate comparison budget; exceeding it or encountering an evaluation error skips the staged comparison and preserves the enforced decision. ### MCP diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 67245d1ba5..825935828b 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -1,17 +1,33 @@ --- name: generate-sandbox-policy -description: Generate sandbox security policies from plain-language requirements and optional REST API documentation. Produces L4 or fine-grained L7 network policies and ordered network middleware configuration. Use for API access rules, middleware host selection, failure behavior, or built-in and operator-run middleware attachment. Trigger keywords - generate policy, create policy, update policy, change policy, sandbox policy, network policy, API policy, security policy, allow API, restrict API, network middleware, supervisor middleware. +description: Generate or edit YAML and Cedar sandbox security policies from plain-language requirements and optional REST API documentation, preserving the authored format and separate Cedar middleware configuration. Use for L4 or L7 API access rules, middleware host selection, failure behavior, or built-in and operator-run middleware attachment. Trigger keywords - generate policy, create policy, update policy, change policy, sandbox policy, network policy, API policy, security policy, allow API, restrict API, network middleware, supervisor middleware. --- # Generate Sandbox Policy -Generate YAML sandbox network policies and network middleware configuration from API documentation and natural-language user requirements. +Generate sandbox policies and network middleware configuration from API documentation and natural-language user requirements. Preserve YAML or Cedar when editing an existing policy. ## Overview This skill translates a user's plain-language policy intent into a valid sandbox policy. The amount of detail the user provides determines the granularity of the generated policy — from broad L4 or preset-based policies (just a host:port) up to fine-grained per-endpoint L7 rules (full API docs). -The output is a `network_policies` YAML block, an optional `network_middlewares` block, and optionally a full policy file that conforms to the sandbox policy schema. +For YAML, the output is a `network_policies` block, an optional `network_middlewares` block, and optionally a full policy file. For Cedar, the output is `.cedar` source plus a separate configuration file when needed. + +## Choose and Preserve the Policy Format + +Read the existing file before choosing an authoring workflow. A `.cedar` extension selects Cedar; other extensions select YAML. For a running sandbox, use `openshell policy get --base --output json` and inspect `policy.cedar_policy_source`: a nonempty value identifies Cedar. The `openshell-cli` skill's policy iteration workflow shows how to extract the source and its separate configuration without mixing sections from table output. + +A sandbox cannot switch formats after creation. Keep Cedar source in `.cedar`; do not convert an existing Cedar policy to YAML or append YAML to it. Global policies must be YAML. For new policies, honor the requested format; use YAML when none is specified. + +### Cedar Authoring + +Use the published [Cedar policy guide](https://docs.nvidia.com/openshell/latest/how-it-works/policies/cedar) as the authority for accepted policy shapes, schema fields, and examples. Confirm the installed CLI's `sandbox create --help` and `policy set --help` before applying changes. The remaining numbered steps and YAML templates in this skill apply to YAML policies only; do not insert `network_policies`, access presets, or YAML rules into Cedar source. + +1. Preserve the existing filesystem grants, process configuration, endpoint settings, middleware, and unrelated Cedar policies. For a new policy, use the guide's complete Cedar examples to include the filesystem permissions the workload needs. +2. Translate the requested scope into Cedar `permit` and `forbid` policies. `NetworkConnect` controls connections; `HttpRequest` controls inspected requests. An HTTP-only permit does not grant its connection. Restrict binary identity, hosts, ports, methods, and paths to the requested scope, and retain forbids. A provider attached to a Cedar sandbox supplies credentials and never grants access; explicitly permit only the intended provider endpoints and requests. +3. Keep `network_middlewares`, `endpoint_settings`, and `process` in the separate YAML configuration file passed with `--middleware`. Preserve all three sections when replacing the policy, even if only the Cedar source changes. Process identity remains static after activation. +4. Apply the complete source with `openshell policy set --policy policy.cedar --middleware middleware.yaml --wait`, omitting `--middleware` only when the policy has no separate configuration to retain. Incremental `policy update` and YAML access presets do not author Cedar policies. +5. Verify the requested allow and deny behavior and the loaded revision. Report the scope and relevant breadth concerns in plain language. Cedar staged policies are observational; use enforce mode for a required restriction. ## Step 1: Gather Inputs diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 75d2de5057..9186f309cc 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -11,7 +11,7 @@ Guide agents through using the `openshell` CLI for sandbox and platform manageme The OpenShell CLI (`openshell`) is the primary interface for managing sandboxes, providers, policies, settings, exposed services, and gateway registrations. Gateway service lifecycle is handled outside the CLI by packages, systemd, or Helm. This skill teaches agents how to orchestrate CLI commands for common and complex workflows. -**Companion skill**: For creating or modifying sandbox policy YAML content (network rules, L7 inspection, access presets), use the `generate-sandbox-policy` skill. This skill covers the CLI *commands* for the policy lifecycle; `generate-sandbox-policy` covers policy *content authoring*. +**Companion skill**: For creating or modifying sandbox policy content (YAML or Cedar network rules, L7 inspection, and middleware), use the `generate-sandbox-policy` skill. This skill covers the CLI *commands* for the policy lifecycle; `generate-sandbox-policy` covers policy *content authoring*. **Self-teaching**: The CLI has comprehensive built-in help. When you encounter a command or option not covered in this skill, walk the help tree: @@ -299,7 +299,7 @@ openshell sandbox create \ Key flags: - `--provider`: Attach configured credential providers for API keys, tokens, and other secrets (repeatable) -- `--policy`: Custom policy YAML (otherwise uses built-in default or `OPENSHELL_SANDBOX_POLICY` env var) +- `--policy`: Custom policy YAML or `.cedar` file (otherwise uses built-in default or `OPENSHELL_SANDBOX_POLICY` env var). Cedar middleware, endpoint settings, and process identity go in a separate file passed with `--middleware`. - `--gpu [COUNT]`: Request the driver's default GPU selection or a specific GPU count - `--cpu`, `--memory`: Set per-sandbox compute sizing. Docker/Podman apply limits; Kubernetes applies matching requests and limits. - `--driver-config-json`: Pass experimental driver-specific sandbox configuration @@ -544,6 +544,8 @@ This is the most important multi-step workflow. It enables a tight feedback cycl **Key concept**: Policies have static fields (immutable after activation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. Drivers without the standard supervisor fetch revisions through the sandbox configuration API and report whether they loaded them. +Determine the authored format before editing. A sandbox keeps YAML or Cedar for its lifetime; changing formats requires recreation. Cedar replaces YAML access rules with Cedar source, while middleware, endpoint settings, and process identity use a separate `--middleware` file. Preserve both files when replacing a Cedar policy. Global policies and incremental `policy update` operations support YAML only. Use the published [Cedar policy guide](https://docs.nvidia.com/openshell/latest/how-it-works/policies/cedar) for Cedar authoring and supported policy shapes. + If startup reports `ConfigurationInvalid`, inspect `openshell sandbox get` and repair the complete policy or provider set through the gateway. The workload has not started on its first activation, so static fields can also be replaced @@ -576,7 +578,7 @@ Create sandbox with initial policy Pull current policy │ │ │ â–¼ │ - Modify policy YAML │ + Modify policy │ (use generate-sandbox-policy) │ │ │ â–¼ │ @@ -610,16 +612,24 @@ Look for `DENIED` log lines. `NET:OPEN [MED] DENIED` marks a blocked connection, ### Step 3: Pull the current policy -```bash -set -o pipefail -openshell policy get dev --base | sed '1,/^---$/d' > current-policy.yaml +Use structured output to detect the format and save editable files. This example requires `jq`. It writes YAML policy data as JSON, which the policy parser accepts, and writes Cedar configuration as YAML keys with JSON flow values: + +```shell +openshell policy get dev --base --output json > current-policy.json +if jq -e '(.policy.cedar_policy_source // "") | length > 0' current-policy.json >/dev/null; then + jq -j '.policy.cedar_policy_source' current-policy.json > current-policy.cedar + jq -r '.policy | {network_middlewares, endpoint_settings, process} | with_entries(select(.value != null)) | to_entries[] | "\(.key): \(.value | tojson)"' \ + current-policy.json > current-middleware.yaml +else + jq '.policy' current-policy.json > current-policy.yaml +fi ``` -`--base` returns the editable policy without provider-composed entries; OpenShell composes attached provider rules separately. The command prints revision details, a `---` line, and then the policy YAML. The `sed` expression keeps only the YAML, because `policy set` cannot parse the revision details. Use `--full` only to inspect the effective policy, not as input to `policy set`. +Check that `policy get` succeeded before running the extraction. `--base` returns the editable policy without provider-composed entries. Use `--full` only to inspect the effective policy. Table output includes metadata and separators; a Cedar policy can include another section for its middleware, endpoint settings, and process identity, so stripping only the first separator does not produce a reusable policy file. Never save Cedar source under a `.yaml` extension or append its configuration sections to the `.cedar` file. ### Step 4: Modify the policy -Edit `current-policy.yaml` to allow the blocked actions. **For policy content authoring, delegate to the `generate-sandbox-policy` skill.** That skill handles: +Edit the extracted `.yaml` or `.cedar` file to allow the blocked actions, preserving the sandbox's format and any Cedar middleware file. **For policy content authoring, use the `generate-sandbox-policy` skill.** Its YAML recipes handle: - Network endpoint rule structure - L4 vs REST, WebSocket, JSON-RPC, and MCP L7 policy decisions @@ -643,6 +653,12 @@ and `debug-openshell-cluster` to investigate middleware failures. openshell policy set dev --policy current-policy.yaml --wait ``` +For Cedar, supply the preserved configuration file, even when only the Cedar source changed: + +```shell +openshell policy set dev --policy current-policy.cedar --middleware current-middleware.yaml --wait +``` + The gateway validates the complete effective candidate—including attached provider-profile policy—before it stores a direct update, incremental merge, approved proposal, provider attachment, or profile update that affects attached @@ -809,7 +825,7 @@ openshell logs work-session --tail --source sandbox When denied actions appear: -1. Prefer incremental updates for additive network changes: +1. For YAML sandboxes, prefer incremental updates for additive network changes: `openshell policy update work-session --add-endpoint api.github.com:443:read-only:rest:enforce --binary /usr/bin/gh --wait` `openshell policy update work-session --rule-name allow_api_github_com_443 --binary /usr/bin/gh --add-allow 'api.github.com:443:POST:/repos/*/issues' --wait` @@ -823,13 +839,11 @@ When denied actions appear: one. `--add-allow` and `--add-deny` require `--rule-name` and the complete binary scope through repeated `--binary` or explicit `--any-binary`. Declare every port on the endpoint in the operation, for example `api.example.com:443,8443:POST:/admin`. Use `--endpoint-path` to disambiguate endpoints within the selected rule; an explicitly empty path selects an endpoint without a path selector. The gateway rejects missing or mismatched scope before persistence. Inspect the current policy and confirm the intended affected scope; do not automatically fill declarations from current policy just to make a rejection pass. -2. Use full YAML replacement for broad network changes or settings that - `policy update` cannot express, including any change that would otherwise - require restating a large existing scope. Filesystem, Landlock, and process - changes still require recreating the sandbox: - `openshell policy get work-session --base | sed '1,/^---$/d' > policy.yaml` - Modify the policy with the `generate-sandbox-policy` skill. - `openshell policy set work-session --policy policy.yaml --wait` +2. Use full replacement for Cedar sandboxes and for YAML changes that + `policy update` cannot express. Follow Workflow 4 to extract the base policy + in its existing format, preserve the Cedar middleware file, and apply it + with `policy set --wait`. Filesystem, Landlock, and process changes still + require recreating the sandbox. 3. Verify with `openshell policy list work-session`. The user does not need to disconnect. Policy updates are hot-reloaded; `--wait` blocks until the sandbox confirms the revision or the timeout expires. Delete the sandbox when the session ends: @@ -1000,6 +1014,6 @@ $ openshell sandbox upload --help | Skill | When to use | |-------|------------| -| `generate-sandbox-policy` | Creating or modifying policy YAML content (network rules, L7 inspection, access presets, endpoint configuration, and network middleware) | +| `generate-sandbox-policy` | Creating or modifying YAML or Cedar policy content, preserving its format and separate Cedar configuration | | `debug-openshell-cluster` | Diagnosing gateway deployment, runtime, or health failures | | `debug-inference` | Diagnosing attached-provider inference, native endpoints, host-backed models, and migration from the retired managed endpoint | From 2900b22e7ce7c07b8b1b59d317d20a99bd27845d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 18:08:11 +0000 Subject: [PATCH 04/19] fix(policy): negotiate Cedar support before policy transfer Signed-off-by: Johnny Greco --- crates/openshell-cli/src/run.rs | 30 ++++- .../tests/ensure_providers_integration.rs | 1 + .../openshell-cli/tests/mtls_integration.rs | 1 + .../tests/provider_commands_integration.rs | 1 + .../sandbox_create_lifecycle_integration.rs | 123 +++++++++++++++++- .../sandbox_name_fallback_integration.rs | 1 + crates/openshell-core/src/grpc_client.rs | 14 ++ crates/openshell-sdk/src/client.rs | 24 ++++ crates/openshell-sdk/src/raw.rs | 3 + crates/openshell-sdk/tests/client_mock.rs | 34 +++++ crates/openshell-server/src/auth/authz.rs | 3 + crates/openshell-server/src/grpc/auth_rpc.rs | 1 + crates/openshell-server/src/grpc/mod.rs | 1 + crates/openshell-server/src/grpc/policy.rs | 116 ++++++++++++++++- crates/openshell-server/tests/common/mod.rs | 1 + .../src/debug_rpc.rs | 1 + crates/openshell-tui/src/lib.rs | 2 + proto/openshell.proto | 4 + proto/sandbox.proto | 3 + .../openshell/v1/cedar_compatibility_test.go | 71 ++++++++++ sdk/go/openshell/v1/config_client.go | 23 +++- sdk/go/openshell/v1/config_client_test.go | 2 + sdk/go/openshell/v1/sandbox_client.go | 6 + sdk/go/proto/openshellv1/openshell.pb.go | 21 ++- sdk/go/proto/sandboxv1/sandbox.pb.go | 19 ++- sdk/typescript/src/client.test.ts | 40 ++++++ sdk/typescript/src/client.ts | 20 ++- 27 files changed, 551 insertions(+), 15 deletions(-) create mode 100644 sdk/go/openshell/v1/cedar_compatibility_test.go diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index dac43b1bb6..c59f0002ab 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -570,6 +570,9 @@ pub async fn sandbox_create( let effective_server = server.to_string(); let effective_tls = tls.clone(); + let policy = load_sandbox_policy_with_middleware(policy, middleware)?; + require_cedar_gateway_support(&mut client, policy.as_ref()).await?; + // Provider profiles are import-only, so the catalog lives on the gateway. // Its only consumer is the credential warning, which is advisory: an // unreachable catalog degrades the warning to its generic form rather than @@ -617,7 +620,6 @@ pub async fn sandbox_create( ensure_required_providers(&mut client, providers, auto_providers_override, workspace) .await?; - let policy = load_sandbox_policy_with_middleware(policy, middleware)?; let resource_limits = if template.is_none() { build_sandbox_resource_limits(cpu, memory)? } else { @@ -1538,6 +1540,26 @@ fn load_sandbox_policy(cli_path: Option<&str>) -> Result> Ok(policy) } +/// Reject Cedar before any provisioning or policy mutation on older gateways. +async fn require_cedar_gateway_support( + client: &mut crate::tls::GrpcClient, + policy: Option<&SandboxPolicy>, +) -> Result<()> { + if policy.is_some_and(|policy| !policy.cedar_policy_source.is_empty()) { + let info = client + .health(openshell_core::proto::HealthRequest {}) + .await + .into_diagnostic()? + .into_inner(); + if !info.supports_cedar_policy { + return Err(miette::miette!( + "gateway does not advertise Cedar policy support; upgrade the gateway before sending Cedar policies" + )); + } + } + Ok(()) +} + /// The load-time warnings of a policy's Cedar source, if it has one. fn cedar_policy_warning_lines(policy: &SandboxPolicy) -> Vec { if policy.cedar_policy_source.is_empty() { @@ -1727,6 +1749,7 @@ where let config_result = client .get_sandbox_config(GetSandboxConfigRequest { + supports_cedar_policy: true, name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( workspace.to_string(), @@ -5411,6 +5434,7 @@ pub async fn sandbox_policy_set_global( let policy = files.load()?; let mut client = grpc_client(server, tls).await?; + require_cedar_gateway_support(&mut client, Some(&policy)).await?; let response = client .update_config(UpdateConfigRequest { policy: Some(policy), @@ -5444,6 +5468,7 @@ pub async fn sandbox_settings_get( let mut client = grpc_client(server, tls).await?; let response = client .get_sandbox_config(GetSandboxConfigRequest { + supports_cedar_policy: true, name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( workspace.to_string(), @@ -5746,6 +5771,7 @@ pub async fn sandbox_policy_set( let policy = files.load()?; let mut client = grpc_client(server, tls).await?; + require_cedar_gateway_support(&mut client, Some(&policy)).await?; // Get current version so we can detect no-ops. let current_version = client @@ -5899,6 +5925,7 @@ pub async fn sandbox_policy_update( let mut client = grpc_client(server, tls).await?; let current = client .get_sandbox_config(GetSandboxConfigRequest { + supports_cedar_policy: true, name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( workspace.to_string(), @@ -6189,6 +6216,7 @@ where let config = client .get_sandbox_config(GetSandboxConfigRequest { + supports_cedar_policy: true, name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( workspace.to_string(), diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index ba5f60c6cc..7d47ca3c9f 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -143,6 +143,7 @@ impl OpenShell for TestOpenShell { _request: tonic::Request, ) -> Result, Status> { Ok(Response::new(HealthResponse { + supports_cedar_policy: false, status: ServiceStatus::Healthy.into(), version: "test".to_string(), })) diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 4428666184..6683c995fb 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -95,6 +95,7 @@ impl OpenShell for TestOpenShell { _request: tonic::Request, ) -> Result, Status> { Ok(Response::new(HealthResponse { + supports_cedar_policy: false, status: ServiceStatus::Healthy.into(), version: "test".to_string(), })) diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 538eebf060..6322bea576 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -296,6 +296,7 @@ impl OpenShell for TestOpenShell { _request: tonic::Request, ) -> Result, Status> { Ok(Response::new(HealthResponse { + supports_cedar_policy: false, status: ServiceStatus::Healthy.into(), version: "test".to_string(), })) diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index e231f80bf5..1d69c7f2aa 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -65,6 +65,8 @@ struct SandboxState { fail_list_provider_profiles: Arc, deleted_names: Arc>>>, create_requests: Arc>>, + provider_create_requests: Arc, + policy_update_requests: Arc, expose_service_requests: Arc>>, fail_delete_sandbox_message: Arc>>, vm_error_after_started: Arc, @@ -155,6 +157,7 @@ impl OpenShell for TestOpenShell { _request: tonic::Request, ) -> Result, Status> { Ok(Response::new(HealthResponse { + supports_cedar_policy: false, status: ServiceStatus::Healthy.into(), version: "test".to_string(), })) @@ -164,7 +167,7 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("unused")) + Err(Status::permission_denied("platform administrator required")) } async fn create_sandbox( @@ -544,6 +547,9 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { + self.state + .provider_create_requests + .fetch_add(1, Ordering::SeqCst); Ok(Response::new(ProviderResponse::default())) } @@ -952,6 +958,9 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { + self.state + .policy_update_requests + .fetch_add(1, Ordering::SeqCst); Err(Status::unimplemented("not implemented in test")) } @@ -4001,3 +4010,115 @@ async fn service_forward_falls_back_to_session_tokens_once_for_a_legacy_gateway( ); forward.abort(); } + +#[tokio::test] +async fn cedar_policy_rejects_legacy_gateway_before_any_mutation() { + let server = run_server().await; + let dir = helpers::tempdir().unwrap(); + let path = dir.path().join("policy.cedar"); + fs::write(&path, r#"permit(principal, action == Sandbox::Action::"NetworkConnect", resource == Sandbox::NetworkEndpoint::"api.github.com:443");"#).unwrap(); + let path = path.to_str().unwrap(); + let tls = test_tls(&server); + let error = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("cedar-legacy"), + providers: &["github".to_string()], + auto_providers_override: Some(true), + policy: Some(path), + ..test_config() + }, + "default", + &tls, + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains("Cedar policy support"), + "{error}" + ); + for global in [false, true] { + let files = run::PolicyFiles { + policy: path, + middleware: None, + }; + let result = if global { + run::sandbox_policy_set_global(&server.endpoint, files, true, false, 1, "default", &tls) + .await + } else { + run::sandbox_policy_set( + &server.endpoint, + "cedar-legacy", + files, + false, + 1, + "default", + &tls, + ) + .await + }; + let error = result.unwrap_err(); + assert!( + error.to_string().contains("Cedar policy support"), + "{error}" + ); + } + assert!(create_requests(&server).await.is_empty()); + assert_eq!( + server + .openshell + .state + .provider_create_requests + .load(Ordering::SeqCst), + 0 + ); + assert_eq!( + server + .openshell + .state + .policy_update_requests + .load(Ordering::SeqCst), + 0 + ); +} + +#[tokio::test] +async fn cedar_discovery_sync_rejects_legacy_gateway_before_policy_mutation() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let gateway = TestOpenShell::default(); + let service = gateway.clone(); + let server = tokio::spawn(async move { + Server::builder() + .add_service(OpenShellServer::new(service)) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + Environment::from_pairs(&[("OPENSHELL_SANDBOX_TOKEN", "compatibility-test-token")]) + .run_async(async { + let policy = openshell_core::proto::SandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".to_string(), + ..Default::default() + }; + let error = openshell_core::grpc_client::sync_policy( + &endpoint, + "cedar-legacy", + &policy, + "default", + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains("Cedar policy support"), + "{error}" + ); + assert_eq!( + gateway.state.policy_update_requests.load(Ordering::SeqCst), + 0 + ); + }) + .await; + server.abort(); +} diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 94a185567d..abd0d3f246 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -110,6 +110,7 @@ impl OpenShell for TestOpenShell { _request: tonic::Request, ) -> Result, Status> { Ok(Response::new(HealthResponse { + supports_cedar_policy: false, status: ServiceStatus::Healthy.into(), version: "test".to_string(), })) diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index ce76bc3d7b..3ca5d25b68 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -954,6 +954,7 @@ async fn fetch_settings_snapshot_with_client( ) -> Result { let response = client .get_sandbox_config(GetSandboxConfigRequest { + supports_cedar_policy: true, workspace_scope: workspace.map(crate::proto::workspace_selector), name: sandbox_name.to_string(), }) @@ -991,6 +992,18 @@ async fn sync_policy_with_client( policy: &ProtoSandboxPolicy, workspace: &str, ) -> Result<()> { + if !policy.cedar_policy_source.is_empty() { + let info = client + .health(crate::proto::HealthRequest {}) + .await + .map_err(grpc_status_error)? + .into_inner(); + if !info.supports_cedar_policy { + return Err(grpc_status_error(Status::failed_precondition( + "gateway does not advertise Cedar policy support; upgrade the gateway before sending Cedar policies", + ))); + } + } client .update_config(UpdateConfigRequest { sandbox: sandbox.to_string(), @@ -1740,6 +1753,7 @@ impl CachedOpenShellClient { .client .clone() .get_sandbox_config(GetSandboxConfigRequest { + supports_cedar_policy: true, workspace_scope, name: sandbox_name.to_string(), }) diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index 69b75ffbd2..87e58008c7 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -135,6 +135,25 @@ impl OpenShellClient { }) } + /// Reject Cedar policies before mutation if the gateway cannot interpret them. + /// Call this before sending Cedar through the raw gRPC escape hatch as well. + pub async fn require_cedar_policy_support( + &self, + policy: Option<&proto::SandboxPolicy>, + ) -> Result<()> { + if policy.is_some_and(|policy| !policy.cedar_policy_source.is_empty()) { + let info = self + .unary(|mut grpc| async move { grpc.health(proto::HealthRequest {}).await }) + .await?; + if !info.supports_cedar_policy { + return Err(SdkError::from_status(tonic::Status::failed_precondition( + "gateway does not advertise Cedar policy support; upgrade the gateway before sending Cedar policies", + ))); + } + } + Ok(()) + } + /// Create a new sandbox from a curated [`SandboxSpec`]. pub async fn create_sandbox(&self, spec: SandboxSpec) -> Result { let request = create_sandbox_request(spec); @@ -152,6 +171,8 @@ impl OpenShellClient { &self, spec: SandboxTemplateCreateSpec, ) -> Result { + self.require_cedar_policy_support(spec.policy.as_ref()) + .await?; let request = create_sandbox_from_template_request(spec); let response = self .unary(|mut grpc| { @@ -901,6 +922,9 @@ impl WorkspaceScopedClient { &self, spec: SandboxTemplateCreateSpec, ) -> Result { + self.client + .require_cedar_policy_support(spec.policy.as_ref()) + .await?; let mut request = create_sandbox_from_template_request(spec); request.workspace_scope = Some(proto::workspace_selector(&self.workspace)); let response = self diff --git a/crates/openshell-sdk/src/raw.rs b/crates/openshell-sdk/src/raw.rs index dd3b7b165b..87949886f7 100644 --- a/crates/openshell-sdk/src/raw.rs +++ b/crates/openshell-sdk/src/raw.rs @@ -8,6 +8,9 @@ //! [`crate::client::OpenShellClient`] doesn't expose the RPC or field you //! need. The high-level surface is sandbox-focused for MVP; providers, //! policy, logs, settings, SSH, and forwarding all live here. +//! Before sending a Cedar policy, call +//! [`crate::OpenShellClient::require_cedar_policy_support`] to reject older +//! gateways that would discard Cedar fields. //! //! ```ignore //! use openshell_sdk::{ClientConfig, OpenShellClient}; diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 9784bea474..8e00cd5a87 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -49,6 +49,7 @@ struct MockState { last_get_name: Mutex>, last_get_workspace: Mutex>, last_create: Mutex>, + supports_cedar_policy: bool, last_template_create: Mutex>, last_template_get: Mutex>, last_template_list: Mutex>, @@ -305,6 +306,7 @@ impl OpenShell for TestOpenShell { } } Ok(Response::new(proto::HealthResponse { + supports_cedar_policy: self.state.supports_cedar_policy, status: proto::ServiceStatus::Healthy.into(), version: "test-1.2.3".to_string(), })) @@ -2405,3 +2407,35 @@ async fn watch_logs_addresses_the_sandbox_by_canonical_name() { ); } } + +#[tokio::test] +async fn cedar_template_create_checks_gateway_before_mutation() { + for supported in [false, true] { + let state = Arc::new(MockState { + supports_cedar_policy: supported, + ..Default::default() + }); + let endpoint = start_mock(state.clone()).await; + let client = OpenShellClient::connect(ClientConfig::new(&endpoint)) + .await + .unwrap(); + let spec = || SandboxTemplateCreateSpec { + template_name: "python".to_string(), + policy: Some(proto::SandboxPolicy { + cedar_policy_source: "permit(principal, action, resource);".to_string(), + ..Default::default() + }), + ..Default::default() + }; + let result = client.create_sandbox_from_template(spec()).await; + assert_eq!(result.is_ok(), supported); + assert_eq!(state.last_create.lock().await.is_some(), supported); + *state.last_create.lock().await = None; + let result = client + .workspace("staging") + .create_sandbox_from_template(spec()) + .await; + assert_eq!(result.is_ok(), supported); + assert_eq!(state.last_create.lock().await.is_some(), supported); + } +} diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index 8d2e0eca48..8ff2d8b8ba 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -198,6 +198,9 @@ mod tests { fn user_blocked_for_platform_admin_methods() { let id = identity_with_roles(&["openshell-user"]); let policy = default_policy(); + // Cedar capability discovery must remain usable by ordinary users; + // elevated gateway metadata is deliberately unavailable to them. + assert!(policy.check(&id, "/openshell.v1.OpenShell/Health").is_ok()); assert!( policy .check(&id, "/openshell.v1.OpenShell/CreateWorkspace") diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 5ba6b4fef7..553ea9f8f9 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -254,6 +254,7 @@ pub async fn handle_refresh_sandbox_token( )); } else { let mut config_request = Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_record .metadata .as_ref() diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index b366dc4a58..b5b3e7648d 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -247,6 +247,7 @@ impl OpenShell for OpenShellService { _request: Request, ) -> Result, Status> { Ok(Response::new(HealthResponse { + supports_cedar_policy: true, status: ServiceStatus::Healthy.into(), version: openshell_core::VERSION.to_string(), })) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 2faa3a9cab..43666cc474 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2805,7 +2805,21 @@ async fn handle_get_sandbox_config_inner( MinWorkspaceRole::User, ) .await?; - Ok(Response::new(load_sandbox_config(state, &sandbox).await?)) + let config = load_sandbox_config(state, &sandbox).await?; + if matches!(principal, Principal::Sandbox(_)) + && !req.supports_cedar_policy + && config + .policy + .as_ref() + .is_some_and(|policy| !policy.cedar_policy_source.is_empty()) + { + // Do not return an empty compatibility response: an older supervisor + // might treat it as a usable YAML configuration. + return Err(Status::unimplemented( + "Cedar policy requires a supervisor with Cedar support", + )); + } + Ok(Response::new(config)) } /// Resolve the same effective configuration for authenticated RPCs and trusted @@ -4845,6 +4859,7 @@ pub(super) async fn handle_report_sandbox_configuration( } if reported == ConfigurationAdmissionState::Accepted { let mut config_request = Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace.clone())), }); @@ -4863,6 +4878,7 @@ pub(super) async fn handle_report_sandbox_configuration( // Runtime error strings may contain parser payloads. Only gateway-authored // diagnostics may be exposed verbatim through public sandbox status. let mut config_request = Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), name: sandbox_name, }); @@ -8394,6 +8410,7 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "admission".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -8581,6 +8598,7 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "image-admission".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -8613,6 +8631,7 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "image-admission".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -8880,6 +8899,7 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_id.clone(), workspace_scope: None, }), @@ -8928,6 +8948,7 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_id.to_string(), workspace_scope: None, }), @@ -8983,6 +9004,7 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_id.clone(), workspace_scope: None, }), @@ -9130,6 +9152,7 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_id.to_string(), workspace_scope: None, }), @@ -9241,6 +9264,7 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_id.to_string(), workspace_scope: None, }), @@ -9298,6 +9322,7 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_id.to_string(), workspace_scope: None, }), @@ -11147,6 +11172,7 @@ mod tests { } let req = with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "sb-b".to_string(), workspace_scope: None, }), @@ -11163,6 +11189,7 @@ mod tests { let state = test_server_state().await; let req = with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "missing-sandbox".to_string(), workspace_scope: None, }), @@ -11201,6 +11228,7 @@ mod tests { state.store.put_message(&sandbox).await.unwrap(); let req = with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "self".to_string(), workspace_scope: None, }), @@ -11216,6 +11244,7 @@ mod tests { let state = test_server_state().await; let req = with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "self".to_string(), workspace_scope: Some(openshell_core::proto::all_workspaces_selector()), }), @@ -11396,6 +11425,7 @@ mod tests { sandbox.set_phase(SandboxPhase::Provisioning as i32); state.store.put_message(&sandbox).await.unwrap(); let req = with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "x".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -11625,6 +11655,7 @@ mod tests { handle_get_sandbox_config( state, with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox.object_name().to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( (sandbox.object_workspace()).to_string(), @@ -11742,6 +11773,7 @@ mod tests { let first = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "snapshot-consistency".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -11767,6 +11799,7 @@ mod tests { let second = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "snapshot-consistency".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -12314,6 +12347,78 @@ mod tests { ); } + #[tokio::test] + async fn cedar_config_requires_supervisor_support_on_every_fetch() { + let state = test_server_state().await; + let policy = ProtoSandboxPolicy { + version: 1, + cedar_policy_source: "permit(principal, action == Sandbox::Action::\"NetworkConnect\", resource == Sandbox::NetworkEndpoint::\"api.github.com:443\");".to_string(), + ..Default::default() + }; + state + .store + .put_message(&test_sandbox( + "sb-cedar-compat", + "cedar-compat", + policy, + vec![], + )) + .await + .unwrap(); + for supported in [false, true, false] { + let result = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + name: "cedar-compat".to_string(), + supports_cedar_policy: supported, + ..Default::default() + }), + "sb-cedar-compat", + ), + ) + .await; + if supported { + assert!( + !result + .unwrap() + .into_inner() + .policy + .unwrap() + .cedar_policy_source + .is_empty() + ); + } else { + assert_eq!(result.unwrap_err().code(), Code::Unimplemented); + } + } + state + .store + .put_message(&test_sandbox( + "sb-yaml-compat", + "yaml-compat", + test_policy_with_rule("base", "api.example.com"), + vec![], + )) + .await + .unwrap(); + let legacy_yaml = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + name: "yaml-compat".to_string(), + supports_cedar_policy: false, + ..Default::default() + }), + "sb-yaml-compat", + ), + ) + .await + .unwrap() + .into_inner(); + assert!(legacy_yaml.policy.unwrap().cedar_policy_source.is_empty()); + } + #[tokio::test] async fn sandbox_config_delivers_provider_rules_separately_for_a_cedar_policy() { let state = test_server_state().await; @@ -12423,6 +12528,7 @@ mod tests { let response = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "mcp-default-composed".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -13138,6 +13244,7 @@ mod tests { let error = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "invalid-composed-policy".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -13847,6 +13954,7 @@ mod tests { let config = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "policy-binding".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -13972,6 +14080,7 @@ mod tests { let next_config = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "policy-binding".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -14038,6 +14147,7 @@ mod tests { let unbound_config = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "policy-binding".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -15213,6 +15323,7 @@ mod tests { let response = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "global-profile-sandbox".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -22973,6 +23084,7 @@ mod tests { let alpha_req = with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "work".to_string(), workspace_scope: None, }), @@ -22986,6 +23098,7 @@ mod tests { let beta_req = with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "work".to_string(), workspace_scope: None, }), @@ -24724,6 +24837,7 @@ mod tests { let err = handle_get_sandbox_config( &state, non_member_request(GetSandboxConfigRequest { + supports_cedar_policy: true, name: "other".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( "other-workspace".to_string(), diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 146169d7ba..4c4a1e3632 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -116,6 +116,7 @@ impl OpenShell for TestOpenShell { _request: tonic::Request, ) -> Result, Status> { Ok(Response::new(HealthResponse { + supports_cedar_policy: false, status: ServiceStatus::Healthy.into(), version: "test".to_string(), })) diff --git a/crates/openshell-supervisor-process/src/debug_rpc.rs b/crates/openshell-supervisor-process/src/debug_rpc.rs index 04cd1e9ba7..9040c7157c 100644 --- a/crates/openshell-supervisor-process/src/debug_rpc.rs +++ b/crates/openshell-supervisor-process/src/debug_rpc.rs @@ -76,6 +76,7 @@ async fn run_get_sandbox_config(args: &[String]) -> Result { let mut client = open_client().await?; let resp = client .get_sandbox_config(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_name.to_string(), workspace_scope: None, }) diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 86529c2e74..7c1396609e 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -894,6 +894,7 @@ async fn fetch_sandbox_detail(app: &mut App) { // Step 2: Fetch the current live policy (includes updates since creation). if found { let policy_req = openshell_core::proto::GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_name, workspace_scope: Some(openshell_core::proto::workspace_selector( app.selected_sandbox_workspace(), @@ -2900,6 +2901,7 @@ async fn refresh_sandbox_policy(app: &mut App) { let workspace = app.selected_sandbox_workspace(); let policy_req = openshell_core::proto::GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_name, workspace_scope: Some(openshell_core::proto::workspace_selector(workspace.clone())), }; diff --git a/proto/openshell.proto b/proto/openshell.proto index 1f25704a78..738cf6d2f9 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -885,6 +885,10 @@ message HealthResponse { // Service version. string version = 2; + + // Clients must check this before sending Cedar policies. Older gateways + // silently discard Cedar fields and would interpret them as empty YAML. + bool supports_cedar_policy = 3; } // Current-user request. The identity comes from the authenticated request. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 29b0377a0a..5dbebaf383 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -389,6 +389,9 @@ message GetSandboxConfigRequest { // Workspace scope. Select one named workspace; authenticated sandbox callers may omit it during bootstrap. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; string name = 1; + // Required for sandbox-authenticated callers receiving a Cedar policy. + // Omitted by older supervisors, which cannot safely interpret Cedar fields. + bool supports_cedar_policy = 4; } // Request to get gateway-global settings. diff --git a/sdk/go/openshell/v1/cedar_compatibility_test.go b/sdk/go/openshell/v1/cedar_compatibility_test.go new file mode 100644 index 0000000000..016eae1a5e --- /dev/null +++ b/sdk/go/openshell/v1/cedar_compatibility_test.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type cedarCompatibilityClient struct { + pb.OpenShellClient + supported bool + checks int + mutations int +} + +func (c *cedarCompatibilityClient) Health(context.Context, *pb.HealthRequest, ...grpc.CallOption) (*pb.HealthResponse, error) { + c.checks++ + return &pb.HealthResponse{SupportsCedarPolicy: c.supported}, nil +} +func (c *cedarCompatibilityClient) CreateSandbox(context.Context, *pb.CreateSandboxRequest, ...grpc.CallOption) (*pb.SandboxResponse, error) { + c.mutations++ + return &pb.SandboxResponse{}, nil +} +func (c *cedarCompatibilityClient) UpdateConfig(context.Context, *pb.UpdateConfigRequest, ...grpc.CallOption) (*pb.UpdateConfigResponse, error) { + c.mutations++ + return &pb.UpdateConfigResponse{}, nil +} +func TestCedarChecksBeforeMutations(t *testing.T) { + for _, supported := range []bool{false, true} { + wire := &cedarCompatibilityClient{supported: supported} + sandboxes := &sandboxClient{client: wire} + config := &configClient{client: wire} + policy := &SandboxPolicy{CedarPolicySource: `permit(principal, action, resource);`} + calls := []func() error{ + func() error { + _, err := sandboxes.Create(context.Background(), "default", "cedar", &SandboxSpec{Policy: policy}, nil) + return err + }, + func() error { + _, err := sandboxes.CreateFromTemplate(context.Background(), "default", "cedar", "python", &SandboxSpec{Policy: policy}, nil) + return err + }, + func() error { + _, err := config.Update(context.Background(), "default", &ConfigUpdate{Policy: policy}) + return err + }, + } + for _, call := range calls { + if supported { + require.NoError(t, call()) + } else { + require.ErrorContains(t, call(), "Cedar policy support") + } + } + require.Equal(t, len(calls), wire.checks) + if supported { + require.Equal(t, len(calls), wire.mutations) + } else { + require.Zero(t, wire.mutations) + } + require.NoError(t, requireCedarPolicySupport(context.Background(), wire, &sbv1.SandboxPolicy{})) + require.Equal(t, len(calls), wire.checks, "YAML must not require capability negotiation") + } +} diff --git a/sdk/go/openshell/v1/config_client.go b/sdk/go/openshell/v1/config_client.go index c8f7763344..c46cf1b5c2 100644 --- a/sdk/go/openshell/v1/config_client.go +++ b/sdk/go/openshell/v1/config_client.go @@ -29,8 +29,9 @@ func (c *configClient) GetSandbox(ctx context.Context, workspace, sandboxName st return nil, err } resp, err := c.client.GetSandboxConfig(ctx, &sbv1.GetSandboxConfigRequest{ - Name: sandboxName, - WorkspaceScope: namedWorkspaceScope(workspace), + SupportsCedarPolicy: true, + Name: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -60,9 +61,27 @@ func (c *configClient) Update(ctx context.Context, workspace string, update *Con if !req.GetGlobal() { req.WorkspaceScope = namedWorkspaceScope(workspace) } + if err := requireCedarPolicySupport(ctx, c.client, req.GetPolicy()); err != nil { + return nil, err + } resp, err := c.client.UpdateConfig(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } return converter.ConfigUpdateResultFromProto(resp), nil } + +// requireCedarPolicySupport prevents old gateways from decoding Cedar as empty YAML. +func requireCedarPolicySupport(ctx context.Context, client pb.OpenShellClient, policy *sbv1.SandboxPolicy) error { + if policy.GetCedarPolicySource() == "" { + return nil + } + info, err := client.Health(ctx, &pb.HealthRequest{}) + if err != nil { + return converter.FromGRPCError(err) + } + if !info.GetSupportsCedarPolicy() { + return &StatusError{Code: ErrorUnimplemented, Message: "gateway does not advertise Cedar policy support; upgrade the gateway before sending Cedar policies"} + } + return nil +} diff --git a/sdk/go/openshell/v1/config_client_test.go b/sdk/go/openshell/v1/config_client_test.go index ab915ae7a3..f45369ddce 100644 --- a/sdk/go/openshell/v1/config_client_test.go +++ b/sdk/go/openshell/v1/config_client_test.go @@ -144,6 +144,7 @@ func TestConfigGetSandbox(t *testing.T) { // Verify request was forwarded with resolved ID (stubSandboxResolver returns "sb-"). mock.mu.Lock() assert.Equal(t, "my-sandbox", mock.lastSandboxReq.GetName()) + assert.True(t, mock.lastSandboxReq.GetSupportsCedarPolicy()) mock.mu.Unlock() // Scalar fields. @@ -236,6 +237,7 @@ func TestConfigGetSandbox_UsesName(t *testing.T) { // stubSandboxResolver returns ID "sb-" — verify the proto has the resolved ID, not the name. mock.mu.Lock() assert.Equal(t, "my-sandbox", mock.lastSandboxReq.GetName()) + assert.True(t, mock.lastSandboxReq.GetSupportsCedarPolicy()) mock.mu.Unlock() } diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go index f8b8290ca1..061a818129 100644 --- a/sdk/go/openshell/v1/sandbox_client.go +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -43,6 +43,9 @@ func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec req.Annotations = converter.CopyStringMap(opts[0].Annotations) req.ServiceExposures = serviceExposuresToProto(opts[0].ServiceExposures) } + if err := requireCedarPolicySupport(ctx, s.client, protoSpec.GetPolicy()); err != nil { + return nil, err + } resp, err := s.client.CreateSandbox(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) @@ -76,6 +79,9 @@ func (s *sandboxClient) CreateFromTemplate(ctx context.Context, workspace, name, req.Annotations = converter.CopyStringMap(opts[0].Annotations) req.ServiceExposures = serviceExposuresToProto(opts[0].ServiceExposures) } + if err := requireCedarPolicySupport(ctx, s.client, protoSpec.GetPolicy()); err != nil { + return nil, err + } resp, err := s.client.CreateSandbox(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index d8afec9b3a..d71509f3cf 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1597,9 +1597,12 @@ type HealthResponse struct { // Service status. Status ServiceStatus `protobuf:"varint,1,opt,name=status,proto3,enum=openshell.v1.ServiceStatus" json:"status,omitempty"` // Service version. - Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + // Clients must check this before sending Cedar policies. Older gateways + // silently discard Cedar fields and would interpret them as empty YAML. + SupportsCedarPolicy bool `protobuf:"varint,3,opt,name=supports_cedar_policy,json=supportsCedarPolicy,proto3" json:"supports_cedar_policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *HealthResponse) Reset() { @@ -1646,6 +1649,13 @@ func (x *HealthResponse) GetVersion() string { return "" } +func (x *HealthResponse) GetSupportsCedarPolicy() bool { + if x != nil { + return x.SupportsCedarPolicy + } + return false +} + // Current-user request. The identity comes from the authenticated request. type GetCurrentUserRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -18574,10 +18584,11 @@ const file_openshell_proto_rawDesc = "" + "\n" + "session_id\x18\x06 \x01(\tR\tsessionId\x12)\n" + "\x10credential_epoch\x18\a \x01(\x04R\x0fcredentialEpochJ\x04\b\x02\x10\x03J\x04\b\x05\x10\x06R\rexpires_at_msR\x15sandbox_expires_at_ms\"\x0f\n" + - "\rHealthRequest\"_\n" + + "\rHealthRequest\"\x93\x01\n" + "\x0eHealthResponse\x123\n" + "\x06status\x18\x01 \x01(\x0e2\x1b.openshell.v1.ServiceStatusR\x06status\x12\x18\n" + - "\aversion\x18\x02 \x01(\tR\aversion\"\x17\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x122\n" + + "\x15supports_cedar_policy\x18\x03 \x01(\bR\x13supportsCedarPolicy\"\x17\n" + "\x15GetCurrentUserRequest\"\xb0\x01\n" + "\x16GetCurrentUserResponse\x12\x18\n" + "\asubject\x18\x01 \x01(\tR\asubject\x12!\n" + diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 2a871052b6..9d145dae8f 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -1722,8 +1722,11 @@ type GetSandboxConfigRequest struct { // Workspace scope. Select one named workspace; authenticated sandbox callers may omit it during bootstrap. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Required for sandbox-authenticated callers receiving a Cedar policy. + // Omitted by older supervisors, which cannot safely interpret Cedar fields. + SupportsCedarPolicy bool `protobuf:"varint,4,opt,name=supports_cedar_policy,json=supportsCedarPolicy,proto3" json:"supports_cedar_policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSandboxConfigRequest) Reset() { @@ -1770,6 +1773,13 @@ func (x *GetSandboxConfigRequest) GetName() string { return "" } +func (x *GetSandboxConfigRequest) GetSupportsCedarPolicy() bool { + if x != nil { + return x.SupportsCedarPolicy + } + return false +} + // Request to get gateway-global settings. type GetGatewayConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2471,10 +2481,11 @@ const file_sandbox_proto_rawDesc = "" + "\x04glob\x18\x01 \x01(\tR\x04glob\x12\x10\n" + "\x03any\x18\x02 \x03(\tR\x03any\"2\n" + "\rNetworkBinary\x12\x12\n" + - "\x04path\x18\x01 \x01(\tR\x04pathJ\x04\b\x02\x10\x03R\aharness\"\x81\x01\n" + + "\x04path\x18\x01 \x01(\tR\x04pathJ\x04\b\x02\x10\x03R\aharness\"\xb5\x01\n" + "\x17GetSandboxConfigRequest\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"\x19\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x122\n" + + "\x15supports_cedar_policy\x18\x04 \x01(\bR\x13supportsCedarPolicy\"\x19\n" + "\x17GetGatewayConfigRequest\"\x82\x02\n" + "\x18GetGatewayConfigResponse\x12X\n" + "\bsettings\x18\x01 \x03(\v2<.openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntryR\bsettings\x12+\n" + diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 84272562d1..4d0cf40a8d 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -2524,3 +2524,43 @@ describe('raw escape hatch', () => { expect(sandbox.transport).toBeDefined(); }); }); + +describe('Cedar gateway compatibility', () => { + it.each([false, true])('checks support before every Cedar mutation (support=%s)', async (supported) => { + let mutations = 0; + const c = client({ + health: () => ({ supportsCedarPolicy: supported }), + createSandbox: () => { + mutations++; + return readySandbox('cedar', 'id'); + }, + updateConfig: () => { + mutations++; + return {}; + }, + }); + const policy = { cedarPolicySource: 'permit(principal, action, resource);' }; + const calls = [ + () => c.create({ policy }), + () => c.create({ rawSpec: { policy } }), + () => c.createFromTemplate({ workloadTemplate: 'python', policy }), + () => c.setPolicy('cedar', policy), + ]; + for (const call of calls) { + if (supported) await call(); + else await expect(call()).rejects.toThrow('Cedar policy support'); + } + expect(mutations).toBe(supported ? calls.length : 0); + }); +}); + +it('advertises Cedar support when reading sandbox-authenticated configuration', async () => { + const c = client({ + getSandbox: () => readySandbox('cedar', 'id'), + getSandboxConfig: (request) => { + expect(request.supportsCedarPolicy).toBe(true); + return { policy: { cedarPolicySource: 'permit(principal, action, resource);' } }; + }, + }); + expect((await c.getConfig('cedar')).policy?.cedarPolicySource).toContain('permit'); +}); diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 74ade2297d..928c563a37 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -1184,6 +1184,7 @@ export class SandboxClient { * Advanced escape hatch: a generated client for every gateway RPC, including * surface the curated methods do not wrap yet. Request/response types are the * generated wire messages (import them from '@nvidia/openshell-sdk/raw'). + * Call requireCedarPolicySupport before sending Cedar through this escape hatch. */ readonly raw: Client; /** The shared Connect transport, for building extra clients over the same connection. */ @@ -1206,6 +1207,17 @@ export class SandboxClient { return new SandboxClient(buildTransport(options)); } + /** Check before sending Cedar through either the curated or raw client. */ + async requireCedarPolicySupport(policy?: MessageInitShape): Promise { + if (!policy?.cedarPolicySource) return; + const info = await this.grpc.health({}); + if (!info.supportsCedarPolicy) + throw new SdkError( + 'invalid_config', + 'gateway does not advertise Cedar policy support; upgrade the gateway before sending Cedar policies', + ); + } + async create(spec: SandboxSpec): Promise { try { // Curated fields build the base spec; rawSpec then shallow-overrides at @@ -1224,6 +1236,7 @@ export class SandboxClient { }; if (spec.rawSpec) Object.assign(specInit, spec.rawSpec); + await this.requireCedarPolicySupport(specInit.policy); const resp = await this.grpc.createSandbox({ workspaceScope: workspaceScope(spec), name: spec.name ?? '', @@ -1245,6 +1258,7 @@ export class SandboxClient { async createFromTemplate(spec: SandboxFromTemplateSpec): Promise { if (spec.workloadTemplate.trim() === '') throw new SdkError('invalid_config', 'workloadTemplate is required'); try { + await this.requireCedarPolicySupport(spec.policy); const resp = await this.grpc.createSandbox({ workspaceScope: workspaceScope(spec), name: spec.name ?? '', @@ -1987,7 +2001,10 @@ export class SandboxClient { async getConfig(name: string, options?: SandboxCallOptions | null): Promise { try { await this.get(name, options); - const resp = await this.grpc.getSandboxConfig({ ...namedTarget(name, options) }, requestCallOptions(options)); + const resp = await this.grpc.getSandboxConfig( + { ...namedTarget(name, options), supportsCedarPolicy: true }, + requestCallOptions(options), + ); return sandboxConfig(resp); } catch (e) { throw e instanceof SdkError ? e : fromConnect(e); @@ -2004,6 +2021,7 @@ export class SandboxClient { options?: SetPolicyOptions | null, ): Promise { try { + await this.requireCedarPolicySupport(policy); const resp = await this.grpc.updateConfig({ ...sandboxTarget(name, options), policy, From efae0b36f8be8daa76b3e02ad2df2ab213717799 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 18:08:33 +0000 Subject: [PATCH 05/19] fix(cedar): share websocket limits and configured MCP identities Keep WebSocket assembly admission shared across tunnels and policy reloads. Derive configured MCP descriptors once for routing and passive endpoint reporting, preserving wildcard selectors, provider ports, and scoped paths. Correct wildcard L7 parity coverage and assert allow/deny behavior on a second host through the relay. Signed-off-by: Johnny Greco --- .../openshell-policy/src/endpoint_status.rs | 226 ++++++++++++++++++ crates/openshell-policy/src/lib.rs | 1 + .../src/grpc/policy/endpoint_status.rs | 13 +- .../src/grpc/policy/endpoint_status_tests.rs | 30 +++ .../src/cedar_only.rs | 224 ++++++++++++++++- .../src/cedar_parity_tests/ported_opa_b.rs | 26 +- .../src/policy_engine.rs | 4 +- .../src/endpoint_status.rs | 38 ++- 8 files changed, 540 insertions(+), 22 deletions(-) create mode 100644 crates/openshell-policy/src/endpoint_status.rs diff --git a/crates/openshell-policy/src/endpoint_status.rs b/crates/openshell-policy/src/endpoint_status.rs new file mode 100644 index 0000000000..9fe55518b2 --- /dev/null +++ b/crates/openshell-policy/src/endpoint_status.rs @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Configured MCP selectors shared by routing and passive endpoint reporting. + +use miette::Result; +use openshell_core::proto::{NetworkEndpoint, SandboxPolicy}; + +/// Collect configured MCP endpoints, without substituting observed hosts for +/// configured globs. Callers validate the policy before installing this inventory. +/// +/// # Errors +/// +/// Returns an error if the Cedar policy cannot be compiled or Cedar support is +/// unavailable in this build. +pub fn configured_mcp_endpoints(policy: &SandboxPolicy) -> Result> { + if policy.cedar_policy_source.is_empty() { + return Ok(policy + .network_policies + .values() + .flat_map(|rule| &rule.endpoints) + .filter(|endpoint| openshell_core::mcp::is_mcp_protocol(&endpoint.protocol)) + .cloned() + .collect()); + } + #[cfg(feature = "cedar")] + { + let cedar = + openshell_policy_cedar::CedarEngine::from_policy_str(&policy.cedar_policy_source) + .map_err(|error| miette::miette!("{error}"))?; + Ok(cedar_mcp_endpoints(policy, &cedar)) + } + #[cfg(not(feature = "cedar"))] + Err(miette::miette!("Cedar policy support is unavailable")) +} + +/// Derive the inventory from the same compiled Cedar policy used for routing. +#[cfg(feature = "cedar")] +#[must_use] +pub fn cedar_mcp_endpoints( + policy: &SandboxPolicy, + cedar: &openshell_policy_cedar::CedarEngine, +) -> Vec { + use openshell_policy_cedar::L7Protocol; + let mut endpoints = Vec::new(); + for (host, port, path, inspection) in cedar.configured_l7_endpoints() { + if inspection.protocol == L7Protocol::Mcp { + endpoints.push(NetworkEndpoint { + host, + port: u32::from(port), + path, + protocol: "mcp".to_string(), + ..NetworkEndpoint::default() + }); + } + } + // Providers and middleware settings can introduce more specific paths and + // selectors. Their protocol comes from Cedar, never from a provider rule. + for endpoint in policy + .provider_credential_rules + .values() + .flat_map(|rule| &rule.endpoints) + .chain(policy.endpoint_settings.iter()) + { + let ports = if endpoint.ports.is_empty() { + vec![endpoint.port] + } else { + endpoint.ports.clone() + }; + let inspected = ports + .into_iter() + .filter_map(|port| u16::try_from(port).ok()) + .any(|port| { + cedar + .configured_l7_endpoints() + .filter(|(host, declared_port, _, _)| { + *declared_port == port + && openshell_core::host_pattern::host_patterns_overlap( + host, + &endpoint.host, + ) + .unwrap_or(false) + }) + .any(|(host, _, _, _)| { + // A literal provider selector uses the complete merged + // inspection of that host, including more specific REST + // paths. For a provider glob, consider every overlapping + // configured Cedar selector, including exact hosts. + let selecting_host = if endpoint.host.contains('*') { + &host + } else { + &endpoint.host + }; + cedar + .l7_endpoints(selecting_host, port) + .filter(|(path, _)| { + openshell_core::endpoint_path::matches(path, &endpoint.path) + }) + .max_by_key(|(path, _)| { + ( + path == &endpoint.path, + path.chars().filter(|c| *c != '*').count(), + ) + }) + .is_some_and(|(_, inspection)| inspection.protocol == L7Protocol::Mcp) + }) + }); + if inspected { + let mut descriptor = endpoint.clone(); + descriptor.protocol = "mcp".to_string(); + endpoints.push(descriptor); + } + } + endpoints +} + +#[cfg(all(test, feature = "cedar"))] +mod tests { + use super::*; + use openshell_core::endpoint_status::initial_endpoint_status; + use openshell_core::proto::NetworkPolicyRule; + + #[test] + fn cedar_inventory_preserves_globs_provider_ports_and_settings_paths() { + let source = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +@protocol("mcp") @path("/mcp/**") +permit (principal, action == Sandbox::Action::"HttpRequest", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +"#; + let provider = NetworkEndpoint { + host: "*.example.com".to_string(), + ports: vec![8443, 443, 443], + path: "/mcp/provider/**".to_string(), + provider_credentialed: true, + ..Default::default() + }; + let setting = NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/mcp/scoped".to_string(), + ..Default::default() + }; + let policy = SandboxPolicy { + cedar_policy_source: source.to_string(), + provider_credential_rules: std::collections::HashMap::from([( + "provider".to_string(), + NetworkPolicyRule { + endpoints: vec![provider.clone()], + ..Default::default() + }, + )]), + endpoint_settings: vec![setting.clone()], + ..Default::default() + }; + let descriptors = configured_mcp_endpoints(&policy).expect("descriptors"); + assert_eq!(descriptors.len(), 3); + let provider_status = initial_endpoint_status( + descriptors + .iter() + .find(|endpoint| endpoint.provider_credentialed) + .expect("provider"), + ); + assert_eq!(provider_status, initial_endpoint_status(&provider)); + assert_eq!(provider_status.host, "*.example.com"); + assert_eq!(provider_status.ports, [443, 8443]); + assert!( + descriptors + .iter() + .any(|endpoint| initial_endpoint_status(endpoint) + == initial_endpoint_status(&setting)) + ); + assert!( + descriptors + .iter() + .filter(|endpoint| endpoint.path == "/mcp/**") + .all(|endpoint| endpoint.host == "*.example.com") + ); + } + + #[test] + fn provider_glob_covers_exact_cedar_host_and_rest_override_is_not_mcp() { + let source = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource); +@protocol("mcp") @path("/mcp/**") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +@protocol("rest") @path("/mcp/private/**") +permit (principal, action == Sandbox::Action::"HttpRequest", + resource == Sandbox::NetworkEndpoint::"api.example.com:443"); +"#; + let mcp = NetworkEndpoint { + host: "*.example.com".to_string(), + port: 443, + path: "/mcp/tools".to_string(), + provider_credentialed: true, + ..Default::default() + }; + let rest = NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/mcp/private/tools".to_string(), + provider_credentialed: true, + ..Default::default() + }; + let policy = SandboxPolicy { + cedar_policy_source: source.to_string(), + provider_credential_rules: std::collections::HashMap::from([( + "provider".to_string(), + NetworkPolicyRule { + endpoints: vec![mcp.clone(), rest.clone()], + ..Default::default() + }, + )]), + ..Default::default() + }; + let descriptors = configured_mcp_endpoints(&policy).expect("descriptors"); + assert!( + descriptors + .iter() + .any(|endpoint| initial_endpoint_status(endpoint) == initial_endpoint_status(&mcp)) + ); + assert!(!descriptors.iter().any(|endpoint| initial_endpoint_status(endpoint) == initial_endpoint_status(&rest))); + } +} diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index a0df9dd137..94d1f720a5 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -10,6 +10,7 @@ //! runtime protobuf model and owns runtime-dependent validation. mod compose; +pub mod endpoint_status; mod l7_validate; mod merge; mod middleware; diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status.rs b/crates/openshell-server/src/grpc/policy/endpoint_status.rs index 88f21ae626..cedad6998f 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status.rs @@ -22,7 +22,6 @@ use futures::TryStreamExt; use openshell_core::GetResourceVersion; use openshell_core::endpoint_failure::validate_endpoint_failure; use openshell_core::endpoint_status::{initial_endpoint_status, valid_token_grant_destination}; -use openshell_core::mcp::is_mcp_protocol; use openshell_core::proto::{ EndpointResult, EndpointStatus, PolicySource, ReportEndpointStatusRequest, ReportEndpointStatusResponse, Sandbox, SandboxPolicy as ProtoSandboxPolicy, SandboxStatus, @@ -777,13 +776,13 @@ pub fn invalidate_endpoint_status_without_session(sandbox: &mut Sandbox) { fn expected_endpoint_statuses(policy: &ProtoSandboxPolicy) -> BTreeMap { let mut endpoints = BTreeMap::new(); - for endpoint in policy - .network_policies - .values() - .flat_map(|rule| &rule.endpoints) - .filter(|endpoint| is_mcp_protocol(&endpoint.protocol)) + for endpoint in openshell_policy::endpoint_status::configured_mcp_endpoints(policy) + .unwrap_or_else(|_| { + tracing::warn!("invalid policy has no endpoint status inventory"); + Vec::new() + }) { - let status = initial_endpoint_status(endpoint); + let status = initial_endpoint_status(&endpoint); endpoints .entry(status.endpoint_id.clone()) .and_modify(|expected: &mut ExpectedEndpoint| { 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 a4cf01b32b..e07b75f3e5 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs @@ -486,6 +486,34 @@ async fn report_endpoint_status_does_not_wait_for_unrelated_sandbox_guard() { drop(unrelated_guard); } +#[test] +fn cedar_endpoint_statuses_accept_configured_wildcard_observation() { + let policy = ProtoSandboxPolicy { + cedar_policy_source: r#" +@protocol("mcp") @path("/mcp/**") +permit (principal, action == Sandbox::Action::"HttpRequest", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +"# + .to_string(), + ..Default::default() + }; + let expected = expected_endpoint_statuses(&policy); + assert_eq!(expected.len(), 1); + let descriptor = initial_endpoint_status(&NetworkEndpoint { + host: "*.example.com".to_string(), + port: 443, + path: "/mcp/**".to_string(), + ..Default::default() + }); + assert_eq!(expected[&descriptor.endpoint_id].status, descriptor); + let observations = vec![EndpointObservation { + endpoint_id: descriptor.endpoint_id.clone(), + result: EndpointResult::HttpResponseReceived.into(), + failure: None, + }]; + assert!(validate_endpoint_snapshot(&observations, &[], &expected, &[]).is_ok()); +} + #[test] fn expected_endpoint_statuses_canonicalize_identity_and_distinguish_paths() { let policy = ProtoSandboxPolicy { @@ -566,6 +594,7 @@ async fn report_endpoint_status_rejects_stale_configuration_epoch() { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_id.to_string(), workspace_scope: None, }), @@ -2009,6 +2038,7 @@ async fn report_endpoint_status_is_session_bound_and_retry_idempotent() { &state, with_sandbox( Request::new(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_id.to_string(), workspace_scope: None, }), diff --git a/crates/openshell-supervisor-network/src/cedar_only.rs b/crates/openshell-supervisor-network/src/cedar_only.rs index c4e17a48f5..3eae0d50cb 100644 --- a/crates/openshell-supervisor-network/src/cedar_only.rs +++ b/crates/openshell-supervisor-network/src/cedar_only.rs @@ -158,6 +158,7 @@ fn network_request_from_input(input: &NetworkInput) -> NetworkConnectRequest { /// Swapped as one unit on reload so a decision never pairs one revision's /// Cedar policy with another revision's provider settings. struct LoadedPolicy { + mcp_endpoints: Vec, /// Shared with the [`CedarDestination`] of each decision it makes. cedar: Arc, /// Hash of the canonical policy, stamped on every endpoint config so @@ -231,6 +232,7 @@ impl LoadedPolicy { entrypoint_pid, )?; Ok(Self { + mcp_endpoints: openshell_policy::endpoint_status::cedar_mcp_endpoints(policy, &cedar), binary_aliases: BinaryAliases::resolve(cedar.binary_alias_paths(), entrypoint_pid), binary_globs: BinaryGlobs::compile(&cedar, token_grants.as_ref()), cedar, @@ -273,6 +275,7 @@ impl LoadedPolicy { } let token_grants = crate::cedar_provider_rules::compile(rules, entrypoint_pid)?; Ok(Self { + mcp_endpoints: self.mcp_endpoints.clone(), cedar: Arc::clone(&self.cedar), policy_hash: self.policy_hash.clone(), providers: self.providers.clone(), @@ -359,6 +362,9 @@ impl LoadedPolicy { routes, settings, policy_hash: &self.policy_hash, + host, + port, + mcp_endpoints: &self.mcp_endpoints, }; } @@ -388,6 +394,9 @@ impl LoadedPolicy { routes, settings, policy_hash: &self.policy_hash, + host, + port, + mcp_endpoints: &self.mcp_endpoints, } } @@ -452,6 +461,9 @@ const UNMATCHED_PATH: &str = "no L7 endpoint path matched request"; /// The endpoint configs of one allowed connection, before they are built. struct RoutePlan<'a> { + host: String, + port: u16, + mcp_endpoints: &'a [openshell_core::proto::NetworkEndpoint], routes: Vec>, /// The endpoint settings matching the connection, in order. settings: Vec<&'a EndpointSetting>, @@ -522,6 +534,42 @@ impl RoutePlan<'_> { .collect(); apply_settings(&mut config, &scoped, protocol); } + if protocol == Some(L7Protocol::Mcp) { + // A provider keeps its configured selector and complete effective + // port set. Other routes select a configured Cedar/settings + // descriptor; an observed host must never replace a host glob. + config.endpoint_id = route.provider.map_or_else( + || { + self.mcp_endpoints + .iter() + .filter(|endpoint| endpoint.path == route.path) + .filter(|endpoint| { + let ports = if endpoint.ports.is_empty() { + std::slice::from_ref(&endpoint.port) + } else { + &endpoint.ports + }; + ports.contains(&u32::from(self.port)) + && openshell_core::host_pattern::host_matches( + &endpoint.host, + &self.host, + ) + .unwrap_or(false) + }) + .max_by_key(|endpoint| { + ( + endpoint.host == self.host, + endpoint.host.chars().filter(|c| *c != '*').count(), + ) + }) + .map(openshell_core::endpoint_status::endpoint_id) + .unwrap_or_default() + }, + |endpoint| endpoint.config.endpoint_id.clone(), + ); + } else { + config.endpoint_id.clear(); + } config } } @@ -1009,7 +1057,7 @@ fn apply_settings( /// /// Settings that describe access, per-request rules, or inspection mode /// (`allowed_ips`, `protocol`, `enforcement`, `access`, rules, persisted -/// queries, MCP options, and MCP identity) are left unset: on a Cedar +/// queries and MCP options) are left unset: on a Cedar /// sandbox, Cedar's `HttpRequest` policies are the per-request rules and /// decide inspection, and the proxy checks addresses with the connection's /// [`CedarDestination`], which carries provider `allowed_ips` separately so @@ -1024,6 +1072,7 @@ fn provider_config(endpoint: &openshell_core::proto::NetworkEndpoint) -> Endpoin endpoint.ports.clone() }; EndpointConfig { + endpoint_id: openshell_core::endpoint_status::endpoint_id(endpoint), host: endpoint.host.clone(), ports: ports .into_iter() @@ -1114,6 +1163,9 @@ fn policy_from_source(policy_src: &str) -> ProtoSandboxPolicy { /// decision, chosen once at policy load. #[derive(Debug)] pub struct CedarOnlyEngine { + /// Sandbox-wide admission survives policy reloads and is shared by forward + /// requests and CONNECT tunnels. + websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget, /// Shared with every [`CedarL7TunnelEngine`] handed out by /// [`Self::l7_handle`]. engine: Arc>, @@ -1155,6 +1207,7 @@ impl CedarOnlyEngine { let loaded = LoadedPolicy::from_proto(&policy, 0)?; let (generation_tx, _) = watch::channel(0); Ok(Self { + websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget::default(), engine: Arc::new(RwLock::new(loaded)), inputs: RwLock::new(PolicyInputs::from_proto(&policy)), generation: Arc::new(AtomicU64::new(0)), @@ -1179,6 +1232,12 @@ impl CedarOnlyEngine { self.generation.load(Ordering::Acquire) } + pub(crate) fn websocket_assembly_budget( + &self, + ) -> crate::l7::websocket::WebSocketAssemblyBudget { + self.websocket_assembly_budget.clone() + } + /// Returns the sandbox's middleware configs, services, and generation. pub(crate) fn middleware(&self) -> &MiddlewareState { &self.middleware @@ -2531,6 +2590,169 @@ when { context.binary_path == "/usr/bin/curl" }; assert!(allowed, "Cedar must decide the request"); } + #[tokio::test] + async fn cedar_mcp_routes_observe_the_configured_inventory_identity() { + use openshell_core::endpoint_status::{ + EndpointConfigVersion, EndpointInventoryEntry, EndpointResult, endpoint_id, + endpoint_status_channel, + }; + use openshell_core::proto::NetworkEndpoint; + let source = r#" +permit (principal, action == Sandbox::Action::"NetworkConnect", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +@protocol("mcp") @path("/mcp/**") +permit (principal, action == Sandbox::Action::"HttpRequest", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +@protocol("rest") @path("/rest/**") +permit (principal, action == Sandbox::Action::"HttpRequest", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +"#; + let provider = NetworkEndpoint { + host: "*.example.com".to_string(), + ports: vec![8443, 443], + path: "/mcp/provider/**".to_string(), + provider_credentialed: true, + ..Default::default() + }; + let setting = NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/mcp/scoped".to_string(), + allow_encoded_slash: true, + ..Default::default() + }; + let mut policy = provider_policy(source, vec![provider.clone()]); + policy.endpoint_settings.push(setting.clone()); + let policy = openshell_policy::validate_and_canonicalize_sandbox_policy(policy) + .expect("canonical policy"); + let engine = CedarOnlyEngine::from_proto(&policy).expect("policy loads"); + let descriptors = openshell_policy::endpoint_status::configured_mcp_endpoints(&policy) + .expect("inventory"); + let (sender, mut receiver) = endpoint_status_channel(); + let mut tracker = receiver.tracker(); + sender + .reset( + EndpointConfigVersion { + policy_hash: deterministic_policy_hash(&policy), + provider_env_revision: 1, + }, + descriptors + .iter() + .map(|endpoint| EndpointInventoryEntry { + endpoint_id: endpoint_id(endpoint), + uses_provider_credentials: endpoint.provider_credentialed, + }) + .collect(), + ) + .await + .expect("reset"); + assert!(tracker.apply(receiver.recv().await.expect("reset received"))); + let base = NetworkEndpoint { + host: "*.example.com".to_string(), + port: 443, + path: "/mcp/**".to_string(), + ..Default::default() + }; + for (host, path, expected) in [ + ("api.example.com", "/mcp/item", &base), + ("other.example.com", "/mcp/item", &base), + ("api.example.com", "/mcp/provider/item", &provider), + ("other.example.com", "/mcp/provider/item", &provider), + ("api.example.com", "/mcp/scoped", &setting), + ] { + let loaded = engine.read_engine().expect("lock"); + let config = loaded.request_config(host, 443, path).expect("route"); + let selected = crate::l7::parse_l7_config(&config).expect("MCP config"); + assert_eq!(selected.endpoint_id, endpoint_id(expected), "{host}{path}"); + let observer = crate::l7::EndpointObserver::begin(Some(&sender), &selected) + .expect("observation starts"); + observer.observe(EndpointResult::HttpResponseReceived); + } + let rest = engine + .read_engine() + .expect("lock") + .request_config("api.example.com", 443, "/rest/item") + .expect("REST route"); + let rest = crate::l7::parse_l7_config(&rest).expect("REST config"); + assert!(crate::l7::EndpointObserver::begin(Some(&sender), &rest).is_none()); + while let Ok(command) = receiver.try_recv() { + tracker.apply(command); + } + let snapshot = tracker.snapshot().expect("snapshot"); + for expected in [&base, &provider, &setting] { + assert!( + snapshot + .endpoints + .iter() + .any(|endpoint| endpoint.endpoint_id == endpoint_id(expected) + && endpoint.result == EndpointResult::HttpResponseReceived) + ); + } + } + + #[tokio::test] + async fn websocket_admission_is_shared_across_forward_tunnels_and_reload() { + use crate::l7::websocket::{WebSocketAssemblyAdmissionOutcome, WebSocketAssemblyBudget}; + let mut cedar = CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses"); + cedar.websocket_assembly_budget = WebSocketAssemblyBudget::new(1, 1); + let cedar = Arc::new(cedar); + let engine = crate::policy_engine::PolicyEngine::from(Arc::clone(&cedar)); + let first = engine + .tunnel_engine(cedar.current_generation()) + .expect("first tunnel"); + let second = engine + .tunnel_engine(cedar.current_generation()) + .expect("second tunnel"); + let active = first + .websocket_assembly_budget() + .reserve() + .await + .expect("reserve"); + assert!(matches!( + active, + WebSocketAssemblyAdmissionOutcome::Admitted(_) + )); + let waiting_budget = second.websocket_assembly_budget(); + let waiting = waiting_budget.reserve(); + tokio::pin!(waiting); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(10), &mut waiting) + .await + .is_err() + ); + assert!(matches!( + engine + .websocket_assembly_budget() + .reserve() + .await + .expect("forward admission"), + WebSocketAssemblyAdmissionOutcome::QueueExhausted + )); + cedar + .reload_from_policy_str(&POLICY.replace("/v1/status", "/v2/status")) + .expect("reload"); + assert!( + first.generation_guard().is_stale(), + "the reload changed policy" + ); + let reloaded = engine + .tunnel_engine(cedar.current_generation()) + .expect("new tunnel"); + assert!(matches!( + reloaded + .websocket_assembly_budget() + .reserve() + .await + .expect("reloaded admission"), + WebSocketAssemblyAdmissionOutcome::QueueExhausted + )); + drop(active); + assert!(matches!( + waiting.await.expect("waiter admitted"), + WebSocketAssemblyAdmissionOutcome::Admitted(_) + )); + } + #[test] fn tunnel_engine_tracks_the_cedar_generation() { let cedar = Arc::new(CedarOnlyEngine::from_policy_str(POLICY).expect("policy parses")); diff --git a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_b.rs b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_b.rs index b03c818eb5..fecfa80f5a 100644 --- a/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_b.rs +++ b/crates/openshell-supervisor-network/src/cedar_parity_tests/ported_opa_b.rs @@ -931,9 +931,7 @@ when { )); } -/// Cedar rejects an `HttpRequest` policy that does not name one endpoint in -/// its scope, so a YAML L7 rule on a wildcard host can only be translated -/// for the hosts the case uses. +/// A wildcard selector must inspect every matching host at L7. const WILDCARD_L7_CEDAR_CONNECT: &str = r#" permit (principal, action == Sandbox::Action::"NetworkConnect", resource) when { @@ -949,9 +947,11 @@ fn ported_wildcard_host_l7_rules_apply() { + r#" @protocol("rest") permit (principal, action == Sandbox::Action::"HttpRequest", - resource == Sandbox::NetworkEndpoint::"api.example.com:8080") + resource is Sandbox::NetworkEndpoint) when { - (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) + resource.host like("*.example.com", ".") + && resource.port == 8080 + && (context.binary_path == "/usr/bin/curl" || context.ancestors.contains("/usr/bin/curl")) && ["GET", "HEAD"].contains(context.method) && context.path like("/api/**", "/") }; @@ -985,6 +985,22 @@ network_policies: "opa.rs::wildcard_host_l7_rules_apply / DELETE", request_in(ctx(), rest("DELETE", "/api/foo")), ), + same_probe("second wildcard host relays GET", Probe::Relay(Box::new(RelayExchange { + ctx: l7_ctx("other.example.com", 8080, BINARY), + route_selected: false, + request: "GET /api/foo HTTP/1.1\r\nHost: other.example.com:8080\r\nConnection: close\r\n\r\n".to_string(), + upstream_response: NO_CONTENT.to_string(), + marker: None, + })), + ).asserting_yaml("204/forwarded"), + same_probe("second wildcard host blocks DELETE", Probe::Relay(Box::new(RelayExchange { + ctx: l7_ctx("other.example.com", 8080, BINARY), + route_selected: false, + request: "DELETE /api/foo HTTP/1.1\r\nHost: other.example.com:8080\r\nConnection: close\r\n\r\n".to_string(), + upstream_response: NO_CONTENT.to_string(), + marker: None, + })), + ).asserting_yaml("403/blocked"), ], )); } diff --git a/crates/openshell-supervisor-network/src/policy_engine.rs b/crates/openshell-supervisor-network/src/policy_engine.rs index 3c0249abf4..9f0bcdf011 100644 --- a/crates/openshell-supervisor-network/src/policy_engine.rs +++ b/crates/openshell-supervisor-network/src/policy_engine.rs @@ -130,7 +130,7 @@ impl PolicyEngine { pub fn websocket_assembly_budget(&self) -> WebSocketAssemblyBudget { match self { Self::Opa(engine) => engine.websocket_assembly_budget(), - Self::Cedar(_) => WebSocketAssemblyBudget::default(), + Self::Cedar(engine) => engine.websocket_assembly_budget(), } } @@ -291,7 +291,7 @@ impl PolicyEngine { Ok(TunnelPolicyEngine { generation_guard, middleware_runner: middleware.runner, - websocket_assembly_budget: WebSocketAssemblyBudget::default(), + websocket_assembly_budget: engine.websocket_assembly_budget(), evaluator: TunnelEvaluator::Cedar { l7: engine.l7_handle(expected_generation), middleware: middleware.configs, diff --git a/crates/openshell-supervisor/src/endpoint_status.rs b/crates/openshell-supervisor/src/endpoint_status.rs index d95b9b8a72..33166aefc7 100644 --- a/crates/openshell-supervisor/src/endpoint_status.rs +++ b/crates/openshell-supervisor/src/endpoint_status.rs @@ -13,7 +13,6 @@ use openshell_core::endpoint_status::{ EndpointConfigVersion, EndpointInventoryEntry, EndpointObservationSender, EndpointStatusCommand, EndpointStatusReceiver, EndpointStatusTracker, endpoint_id, }; -use openshell_core::mcp::is_mcp_protocol; use openshell_core::proto::SandboxPolicy; use tokio::time::timeout; use tracing::warn; @@ -355,14 +354,14 @@ fn apply_command( /// Collect the MCP endpoints whose HTTP exchanges provide status observations. pub fn mcp_endpoint_inventory(policy: &SandboxPolicy) -> Vec { let mut endpoints = BTreeMap::new(); - for endpoint in policy - .network_policies - .values() - .flat_map(|rule| &rule.endpoints) - .filter(|endpoint| is_mcp_protocol(&endpoint.protocol)) + for endpoint in openshell_policy::endpoint_status::configured_mcp_endpoints(policy) + .unwrap_or_else(|_| { + warn!("invalid policy has no endpoint status inventory"); + Vec::new() + }) { endpoints - .entry(endpoint_id(endpoint)) + .entry(endpoint_id(&endpoint)) .and_modify(|credentialed| *credentialed |= endpoint.provider_credentialed) .or_insert(endpoint.provider_credentialed); } @@ -1195,4 +1194,29 @@ mod tests { }] ); } + + #[test] + fn cedar_mcp_inventory_uses_configured_wildcard_identity() { + let policy = SandboxPolicy { + cedar_policy_source: r#" +@protocol("mcp") @path("/mcp/**") +permit (principal, action == Sandbox::Action::"HttpRequest", resource) +when { resource.host like("*.example.com", ".") && resource.port == 443 }; +"# + .to_string(), + ..Default::default() + }; + assert_eq!( + mcp_endpoint_inventory(&policy), + vec![EndpointInventoryEntry { + endpoint_id: endpoint_id(&NetworkEndpoint { + host: "*.example.com".to_string(), + port: 443, + path: "/mcp/**".to_string(), + ..Default::default() + }), + uses_provider_credentials: false, + }] + ); + } } From 713cd9a109fa00db1a11abcb1ff2cc5d6181da67 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 18:10:25 +0000 Subject: [PATCH 06/19] refactor(cedar): simplify staged evaluation result handling Signed-off-by: Johnny Greco --- crates/openshell-policy-cedar/src/lib.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/openshell-policy-cedar/src/lib.rs b/crates/openshell-policy-cedar/src/lib.rs index 746c895cb2..95e0ac9a92 100644 --- a/crates/openshell-policy-cedar/src/lib.rs +++ b/crates/openshell-policy-cedar/src/lib.rs @@ -694,16 +694,18 @@ impl CedarEngine { // by default, so a set of only staged `forbid`s denies every request // whether or not one of them matched. let staged = if endpoint.is_some_and(|endpoint| endpoint.staged) { - match QueryCombinations::new(&request.query, &self.analysis.staged_query_keys) + QueryCombinations::new(&request.query, &self.analysis.staged_query_keys) .and_then(|query| self.decide_l7(all, request, protocol, &query)) - { - Ok(staged) => (staged.is_allow() != decision.is_allow()).then_some(staged), - Err(_) => { - // Never include authored expressions or request query values. - tracing::debug!("staged Cedar evaluation failed; retaining enforced decision"); - None - } - } + .map_or_else( + |_| { + // Never include authored expressions or request query values. + tracing::debug!( + "staged Cedar evaluation failed; retaining enforced decision" + ); + None + }, + |staged| (staged.is_allow() != decision.is_allow()).then_some(staged), + ) } else { None }; From b592854ad94efa03e8ff784b32111efab2d2eb15 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 18:16:08 +0000 Subject: [PATCH 07/19] test(cedar): verify provider credentials through the gateway Signed-off-by: Johnny Greco --- e2e/cedar/README.md | 13 +++- e2e/cedar/echo-server.py | 36 +++++++++- e2e/cedar/test.sh | 142 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 184 insertions(+), 7 deletions(-) diff --git a/e2e/cedar/README.md b/e2e/cedar/README.md index ecd0b4ca83..5bc234a995 100644 --- a/e2e/cedar/README.md +++ b/e2e/cedar/README.md @@ -10,6 +10,7 @@ layer enforces it: |---|---|---| | Network | `policy.template.cedar` | Permitted `GET` reaches the upstream; other paths, a path one segment deeper than a delimited glob allows, other methods, a `forbid`, another binary, and an unlisted host are denied. A delimited host glob (`*.github.com`) admits `api.github.com` through policy DNS but not `github.com`. A `@enforcement("staged")` `forbid` lets its request through and is logged. | | Policy update | `policy set --wait` with an extended `policy.template.cedar` | The running sandbox loads a changed Cedar policy, with the middleware file passed again: a path the first policy denied is then permitted, and a path it permitted is then forbidden. | +| Providers | Temporary profile/provider attached through the gateway | Attachment alone grants no request access. An explicit scoped Cedar permit allows a request whose issued placeholder resolves to the initial dummy credential upstream. A credential update is observed upstream with bounded polling; path and method denials remain in effect. Denied credential-bearing requests never reach the upstream. | | Middleware | `middleware.template.yaml` via `--middleware` | The `openshell/regex` middleware redacts a secret in the request body before it reaches the upstream. A non-2xx upstream response is retried up to three times and then reported as an upstream failure, not an OpenShell decision. | | Filesystem | `policy.template.cedar` | A `WriteFile` grant makes `/sandbox` writable, a `ReadFile` grant makes `/opt` readable, and `/srv` stays unreadable. | | Syscalls | sandbox runtime | `memfd_create`, `ptrace`, user-namespace `unshare`, `mount`, `io_uring_setup`, raw netlink sockets, and direct TCP connects that bypass the proxy return `EPERM`. | @@ -36,7 +37,17 @@ CEDAR_E2E_UPSTREAM_URL=https://httpbin.org/anything bash e2e/cedar/test.sh ``` The test substitutes that URL's host, port, and path into the Cedar policy and -the middleware selector. +the middleware selector. Provider assertions require the local echo fixture's +credential-generation labels and request counts. The default Docker CI suite +runs them; a remote upstream override explicitly skips them because that +service cannot provide trusted credential observations. The fixture uses dummy +credentials and reports only generation labels, never credential values. + +When a local gateway maps `host.openshell.internal` to loopback, set +`CEDAR_E2E_LOCAL_UPSTREAM_HOST` to a reachable private address on the host, +such as the Docker bridge address. This keeps the local echo server and all +provider assertions active. Cedar blocks loopback destinations even when the +policy names them explicitly. ## Run it diff --git a/e2e/cedar/echo-server.py b/e2e/cedar/echo-server.py index 5c1fc70858..a6ab0d7bd7 100644 --- a/e2e/cedar/echo-server.py +++ b/e2e/cedar/echo-server.py @@ -11,15 +11,40 @@ import json import sys +import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +STATE_LOCK = threading.Lock() +STATE = {"paths": {}} +STATE_PATH = None class EchoHandler(BaseHTTPRequestHandler): def _echo(self) -> None: length = int(self.headers.get("Content-Length") or 0) body = self.rfile.read(length).decode("utf-8", "replace") if length else "" + # Synthetic fixture credentials stay in this server. Responses and + # observations contain only generation labels, never header values. + authorization = self.headers.get("Authorization") + generation = { + "Bearer cedar-e2e-dummy-initial": "initial", + "Bearer cedar-e2e-dummy-rotated": "rotated", + }.get(authorization, "missing" if authorization is None else "unknown") + with STATE_LOCK: + STATE["paths"][self.path] = STATE["paths"].get(self.path, 0) + 1 + if STATE_PATH: + temporary = STATE_PATH.with_suffix(".tmp") + with temporary.open("w", encoding="utf-8") as state_file: + json.dump(STATE, state_file) + temporary.replace(STATE_PATH) payload = json.dumps( - {"method": self.command, "path": self.path, "body": body} + { + "method": self.command, + "path": self.path, + "body": body, + "credential_generation": generation, + } ).encode() self.send_response(200) self.send_header("Content-Type", "application/json") @@ -32,13 +57,18 @@ def _echo(self) -> None: do_PUT = _echo do_DELETE = _echo - def log_message(self, format: str, *args: object) -> None: + def log_message(self, _format: str, *_args: object) -> None: return def main() -> None: + global STATE_PATH + STATE_PATH = Path(sys.argv[2]) if len(sys.argv) > 2 else None + if STATE_PATH: + with STATE_PATH.open("w", encoding="utf-8") as state_file: + json.dump(STATE, state_file) server = ThreadingHTTPServer(("0.0.0.0", 0), EchoHandler) - with open(sys.argv[1], "w", encoding="utf-8") as port_file: + with Path(sys.argv[1]).open("w", encoding="utf-8") as port_file: port_file.write(str(server.server_address[1])) server.serve_forever() diff --git a/e2e/cedar/test.sh b/e2e/cedar/test.sh index 7073de90f2..fada0d1dac 100755 --- a/e2e/cedar/test.sh +++ b/e2e/cedar/test.sh @@ -23,11 +23,24 @@ RUN_ID="${RUN_ID:-$(date +%H%M%S)}" SANDBOX="${SANDBOX:-cedar-e2e-${RUN_ID}}" TMP_DIR="$(mktemp -d)" SERVER_PID="" +PROVIDER_NAME="cedar-e2e-${RUN_ID}" +PROFILE_ID="cedar-e2e-${RUN_ID}" +PROVIDER_CREATED=0 FAILURES=0 cleanup() { if [[ "${CEDAR_E2E_KEEP_SANDBOX:-0}" != "1" ]]; then "$OPENSHELL_BIN" sandbox delete "$SANDBOX" >/dev/null 2>&1 || true + # Sandbox deletion drains asynchronously; wait for references to clear. + if [[ "$PROVIDER_CREATED" == "1" ]]; then + for _ in $(seq 1 40); do + if "$OPENSHELL_BIN" provider delete "$PROVIDER_NAME" >/dev/null 2>&1; then + break + fi + sleep 0.5 + done + fi + "$OPENSHELL_BIN" profile delete "$PROFILE_ID" >/dev/null 2>&1 || true fi if [[ -n "$SERVER_PID" ]]; then kill "$SERVER_PID" >/dev/null 2>&1 || true @@ -88,13 +101,13 @@ fi if [[ -n "${CEDAR_E2E_UPSTREAM_URL:-}" ]]; then ECHO_URL="${CEDAR_E2E_UPSTREAM_URL%/}" else - python3 "${SCRIPT_DIR}/echo-server.py" "${TMP_DIR}/port" & + python3 "${SCRIPT_DIR}/echo-server.py" "${TMP_DIR}/port" "${TMP_DIR}/upstream-state.json" & SERVER_PID=$! for _ in $(seq 1 50); do [[ -s "${TMP_DIR}/port" ]] && break sleep 0.1 done - ECHO_URL="http://host.openshell.internal:$(cat "${TMP_DIR}/port")" + ECHO_URL="http://${CEDAR_E2E_LOCAL_UPSTREAM_HOST:-host.openshell.internal}:$(cat "${TMP_DIR}/port")" fi read -r UP_HOST UP_PORT UP_PREFIX < <(python3 - "$ECHO_URL" <<'PY' import sys @@ -264,6 +277,128 @@ else pass "middleware: secret never reaches upstream" fi +# --------------------------------------------------------------------------- +# Providers: real attachment, credential injection, denial, and rotation +# --------------------------------------------------------------------------- + +provider_probe() { + capture_in_sandbox "curl -s --max-time 5 -H \"Authorization: Bearer \${CEDAR_E2E_TOKEN}\" \ + -w '\n%{http_code}' '${ECHO_URL}$1'" | tail -2 +} + +upstream_hits() { + python3 - "${TMP_DIR}/upstream-state.json" "$1" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as state_file: + print(json.load(state_file)["paths"].get(sys.argv[2], 0)) +PY +} + +provider_checks() { + if [[ -z "$SERVER_PID" ]]; then + echo "SKIP providers: remote upstream has no trusted credential/count observer" + return + fi + cat > "${TMP_DIR}/provider-profile.yaml" </dev/null 2>&1; then + fail "providers: import fixture profile" + return + fi + # Dummy credentials only. CLI output is intentionally not printed on failure. + if ! CEDAR_E2E_TOKEN=cedar-e2e-dummy-initial "$OPENSHELL_BIN" provider create \ + --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential CEDAR_E2E_TOKEN >/dev/null 2>&1; then + fail "providers: create fixture provider" + return + fi + PROVIDER_CREATED=1 + if ! "$OPENSHELL_BIN" sandbox provider attach "$SANDBOX" "$PROVIDER_NAME" --wait >/dev/null 2>&1; then + fail "providers: attach fixture provider and activate configuration" + return + fi + + local before response + before="$(upstream_hits /provider/allowed)" + response="$(provider_probe /provider/allowed)" + expect_eq "providers: attachment alone grants no request access" "${response##*$'\n'}" "403" + expect_eq "providers: attachment-only denial never reaches upstream" "$(upstream_hits /provider/allowed)" "$before" + + cp "${TMP_DIR}/policy-updated.cedar" "${TMP_DIR}/policy-provider.cedar" + cat >> "${TMP_DIR}/policy-provider.cedar" </dev/null 2>&1; then + fail "providers: activate explicit scoped Cedar permit" + return + fi + response="$(provider_probe /provider/allowed)" + expect_eq "providers: explicit permit reaches upstream" "${response##*$'\n'}" "200" + if [[ "$response" == *'"credential_generation": "initial"'* ]]; then + pass "providers: issued placeholder resolves to initial credential upstream" + else + fail "providers: issued placeholder resolves to initial credential upstream" + fi + before="$(upstream_hits /provider/denied)" + response="$(provider_probe /provider/denied)" + expect_eq "providers: attached profile cannot widen the Cedar path" "${response##*$'\n'}" "403" + expect_eq "providers: denied credential-bearing request never reaches upstream" "$(upstream_hits /provider/denied)" "$before" + + if ! CEDAR_E2E_TOKEN=cedar-e2e-dummy-rotated "$OPENSHELL_BIN" provider update \ + "$PROVIDER_NAME" --credential CEDAR_E2E_TOKEN >/dev/null 2>&1; then + fail "providers: rotate fixture credential" + return + fi + # A successful update saves the change; observe actual injection rather + # than assuming a delay proves the supervisor installed the credential. + local deadline=$((SECONDS + 60)) rotated=0 + while (( SECONDS < deadline )); do + response="$(provider_probe /provider/allowed)" + if [[ "${response##*$'\n'}" == "200" && "$response" == *'"credential_generation": "rotated"'* ]]; then + rotated=1 + break + fi + sleep 0.5 + done + expect_eq "providers: rotation reaches the real upstream with the new credential" "$rotated" "1" + before="$(upstream_hits /provider/denied)" + response="$(provider_probe /provider/denied)" + expect_eq "providers: rotation preserves Cedar path denial" "${response##*$'\n'}" "403" + expect_eq "providers: rotated denied request never reaches upstream" "$(upstream_hits /provider/denied)" "$before" + expect_eq "providers: rotation preserves Cedar method denial" "$(status '-X POST' /provider/allowed)" "403" +} + +provider_checks + # --------------------------------------------------------------------------- # Filesystem: Landlock grants derived from the Cedar policy # --------------------------------------------------------------------------- @@ -369,7 +504,8 @@ fi # Observability and policy view # --------------------------------------------------------------------------- -sandbox_logs="$("$OPENSHELL_BIN" logs "$SANDBOX" 2>&1 || true)" +# Provider mutations add log entries after the staged request above. +sandbox_logs="$("$OPENSHELL_BIN" logs "$SANDBOX" -n 1000 2>&1 || true)" expect_contains "logs: Cedar is recorded as the deciding engine" "$sandbox_logs" "engine:cedar" expect_contains "logs: a staged audit forbid is logged" "$sandbox_logs" "cedar_audit:staged_deny" From bba62a2f11e592b47c9c9a813550cca627f27063 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 18:21:07 +0000 Subject: [PATCH 08/19] fix(cedar): satisfy scoped lint checks Signed-off-by: Johnny Greco --- crates/openshell-cli/src/run.rs | 6 ++++-- .../src/grpc/policy/endpoint_status_tests.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index c59f0002ab..321007f9cb 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -570,8 +570,9 @@ pub async fn sandbox_create( let effective_server = server.to_string(); let effective_tls = tls.clone(); - let policy = load_sandbox_policy_with_middleware(policy, middleware)?; - require_cedar_gateway_support(&mut client, policy.as_ref()).await?; + // Keep the early-loaded policy off the provisioning future's stack. + let policy = Box::new(load_sandbox_policy_with_middleware(policy, middleware)?); + require_cedar_gateway_support(&mut client, policy.as_ref().as_ref()).await?; // Provider profiles are import-only, so the catalog lives on the gateway. // Its only consumer is the credential warning, which is advisory: an @@ -620,6 +621,7 @@ pub async fn sandbox_create( ensure_required_providers(&mut client, providers, auto_providers_override, workspace) .await?; + let policy = *policy; let resource_limits = if template.is_none() { build_sandbox_resource_limits(cpu, memory)? } else { 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 e07b75f3e5..8088124b02 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs @@ -507,7 +507,7 @@ when { resource.host like("*.example.com", ".") && resource.port == 443 }; }); assert_eq!(expected[&descriptor.endpoint_id].status, descriptor); let observations = vec![EndpointObservation { - endpoint_id: descriptor.endpoint_id.clone(), + endpoint_id: descriptor.endpoint_id, result: EndpointResult::HttpResponseReceived.into(), failure: None, }]; From 1a4c36c1bbcbde4e915e0fa766c7fd1b9c8d6e63 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 18:25:46 +0000 Subject: [PATCH 09/19] fix(python): negotiate Cedar support before sandbox creation Signed-off-by: Johnny Greco --- python/openshell/sandbox.py | 9 +++++ python/openshell/sandbox_test.py | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index e9c840e481..2fc4d0b5d4 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -846,6 +846,13 @@ def __exit__(self, *args: object) -> None: def health(self) -> openshell_pb2.HealthResponse: return self._stub.Health(openshell_pb2.HealthRequest(), timeout=self._timeout) + def _require_cedar_policy_support(self, spec: openshell_pb2.SandboxSpec) -> None: + if spec.policy.cedar_policy_source and not self.health().supports_cedar_policy: + raise SandboxError( + "gateway does not advertise Cedar policy support; " + "upgrade the gateway before sending Cedar policies" + ) + def create( self, *, @@ -856,6 +863,7 @@ def create( service_exposures: Sequence[ServiceExposure] | None = None, ) -> SandboxRef: request_spec = spec if spec is not None else _default_spec() + self._require_cedar_policy_support(request_spec) response = self._stub.CreateSandbox( openshell_pb2.CreateSandboxRequest( workspace_scope=_workspace_scope(workspace), @@ -884,6 +892,7 @@ 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() + self._require_cedar_policy_support(request_spec) response = self._stub.CreateSandbox( openshell_pb2.CreateSandboxRequest( workspace_scope=_workspace_scope(workspace), diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 019d3cc674..899dcf78f7 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -2198,6 +2198,66 @@ def wait_ready( ) +@pytest.mark.parametrize( + "method", + [ + "create", + "create_from_template", + "create_session", + "create_session_from_template", + ], +) +@pytest.mark.parametrize("policy_kind", ["cedar", "yaml", "default"]) +@pytest.mark.parametrize("support", ["legacy", "modern", "error"]) +def test_create_negotiates_cedar_before_mutation( + method: str, policy_kind: str, support: str +) -> None: + health_calls: list[openshell_pb2.HealthRequest] = [] + health_error = RuntimeError("health unavailable") + + class Stub(_FakeSandboxStub): + def Health( + self, request: openshell_pb2.HealthRequest, timeout: float | None = None + ) -> openshell_pb2.HealthResponse: + health_calls.append(request) + assert timeout == 30.0 + if support == "error": + raise health_error + if support == "modern": + return openshell_pb2.HealthResponse(supports_cedar_policy=True) + return openshell_pb2.HealthResponse() + + stub = Stub() + client = _client_with_fake_stub(stub) + spec = None + if policy_kind != "default": + spec = openshell_pb2.SandboxSpec(policy={"version": 1}, providers=["github"]) + if policy_kind == "cedar": + spec.policy.cedar_policy_source = "permit(principal, action, resource);" + kwargs: dict[str, Any] = {"workspace": "default", "name": "cedar", "spec": spec} + if "from_template" in method: + kwargs["workload_template"] = "python" + create = getattr(client, method) + + if policy_kind == "cedar" and support != "modern": + if support == "error": + with pytest.raises(RuntimeError, match="health unavailable") as error: + create(**kwargs) + assert error.value is health_error + else: + with pytest.raises(SandboxError, match="Cedar policy support"): + create(**kwargs) + assert stub.create_request is None + else: + create(**kwargs) + assert stub.create_request is not None + assert stub.create_request.spec == (spec or openshell_pb2.SandboxSpec()) + assert stub.create_request.workload_template == ( + "python" if "from_template" in method else "" + ) + assert len(health_calls) == (1 if policy_kind == "cedar" else 0) + + def test_create_forwards_name_and_labels() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) From 7a3fd24848ae96ac257df2e567890c1eb5aef119 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 18:44:16 +0000 Subject: [PATCH 10/19] docs(cedar): clarify staged evaluation isolation Signed-off-by: Johnny Greco --- crates/openshell-policy-cedar/README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/openshell-policy-cedar/README.md b/crates/openshell-policy-cedar/README.md index 7ac3750aa1..d68d2efc9c 100644 --- a/crates/openshell-policy-cedar/README.md +++ b/crates/openshell-policy-cedar/README.md @@ -14,8 +14,9 @@ schema in `openshell-policy-cedar-schema`. It then derives three artifacts that Cedar does not decide at request time: Landlock grants, which endpoints the proxy inspects per request, and policy DNS eligibility. It accepts only policy shapes whose meaning those artifacts can enforce exactly. The CLI, the gateway, -and the supervisor reject everything else. Cedar evaluation errors fail the -request instead of skipping the erroring policy. +and the supervisor reject everything else. Errors evaluating deciding policies +fail the request instead of skipping the erroring policy. Errors in staged +comparisons preserve the enforcing decision and skip the comparison. Policy DNS eligibility comes from `NetworkConnect` permit scopes, and from `when` conditions that require both `resource.host` and `resource.port`. A host @@ -180,11 +181,15 @@ Its Lean specification and proofs are in `ai-openshell/cedar-spec`. is among the determining policies of every evaluation. That reproduces YAML query matchers: an allow rule matches every value of each key, and a deny rule fires on one matching value per key. Analysis collects the tag - keys policies name literally; other keys are left out of the entity, so - their values do not multiply evaluations, unless a policy computes a key. - More than `MAX_QUERY_COMBINATIONS` (4096) combinations is an error, which - the supervisor turns into a denial with that reason. Audit paths and - staged policies use the same combined decision. + keys separately for deciding policies (all but staged) and for the staged + comparison (all policies). Only keys the relevant policies name literally + enter the entity, so other keys' values do not multiply evaluations, unless + a policy computes a key. More than `MAX_QUERY_COMBINATIONS` (4096) + combinations for deciding policies is an error, which the supervisor turns + into a denial with that reason. Audit paths use the same combined decision. + Staged comparisons use a separate combination budget; exceeding it or + encountering an evaluation error skips the comparison and preserves the + enforcing decision. - `CedarOnlyEngine` attaches a `CedarDestination` to each allowed connection's `EgressAuthorization`. It replaces the YAML destination modes below the host gateway aliases: every resolved address must avoid the From cd26841bd3e69ce9b994b87e1a64d4421946f0df Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 18:44:32 +0000 Subject: [PATCH 11/19] fix(sdk): keep Cedar capability check input structural Signed-off-by: Johnny Greco --- sdk/typescript/src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 928c563a37..c63b2b9975 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -1208,7 +1208,7 @@ export class SandboxClient { } /** Check before sending Cedar through either the curated or raw client. */ - async requireCedarPolicySupport(policy?: MessageInitShape): Promise { + async requireCedarPolicySupport(policy?: { cedarPolicySource?: string }): Promise { if (!policy?.cedarPolicySource) return; const info = await this.grpc.health({}); if (!info.supportsCedarPolicy) From 32c4ff59cedb658132220ec9ba41a2226ca8df94 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 19:18:10 +0000 Subject: [PATCH 12/19] fix(examples): advertise Cedar support in governance smoke client Signed-off-by: Johnny Greco --- examples/governance-interceptor/src/smoke_client.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/governance-interceptor/src/smoke_client.rs b/examples/governance-interceptor/src/smoke_client.rs index eaa4c20961..834a2fc34d 100644 --- a/examples/governance-interceptor/src/smoke_client.rs +++ b/examples/governance-interceptor/src/smoke_client.rs @@ -58,6 +58,7 @@ async fn main() -> Result<(), Box> { let before = client .get_sandbox_config(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_name.clone(), workspace_scope: Some(default_workspace()), }) @@ -145,6 +146,7 @@ async fn main() -> Result<(), Box> { let after = client .get_sandbox_config(GetSandboxConfigRequest { + supports_cedar_policy: true, name: sandbox_name, workspace_scope: Some(default_workspace()), }) From 65b93c5c06144afd8ee92b51cb1b423b17e335a8 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 19:34:32 +0000 Subject: [PATCH 13/19] fix(cli): box sandbox subcommands to satisfy size lint Signed-off-by: Johnny Greco --- crates/openshell-cli/src/main.rs | 526 ++++++++++++++++++------------- 1 file changed, 309 insertions(+), 217 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index facc31cd16..a655d92327 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -512,7 +512,7 @@ enum Commands { #[command(alias = "sb", after_help = SANDBOX_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Sandbox { #[command(subcommand)] - command: Option, + command: Option>, }, /// Manage port forwarding to a sandbox. @@ -3378,7 +3378,7 @@ async fn run_async() -> Result<()> { Some(Commands::Sandbox { command: Some(command), }) => { - match command { + match *command { SandboxCommands::Create { name, template, @@ -4454,11 +4454,13 @@ mod tests { .expect("sandbox provider attach should parse"); let Some(Commands::Sandbox { - command: - Some(SandboxCommands::Provider(SandboxProviderCommands::Attach { - name, provider, .. - })), + command: Some(command), }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + let SandboxCommands::Provider(SandboxProviderCommands::Attach { name, provider, .. }) = + *command else { panic!("expected sandbox provider attach command"); }; @@ -4477,15 +4479,18 @@ mod tests { argv.extend(args); let cli = Cli::try_parse_from(argv).map_err(|e| e.kind())?; let Some(Commands::Sandbox { - command: - Some(SandboxCommands::Exec { - sandbox, - name, - command, - tty, - .. - }), + command: Some(command), }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + let SandboxCommands::Exec { + sandbox, + name, + command, + tty, + .. + } = *command else { panic!("expected sandbox exec command"); }; @@ -4568,9 +4573,12 @@ mod tests { ]) .expect("readiness flags should parse"); let Some(Commands::Sandbox { - command: Some(SandboxCommands::Provider(command)), + command: Some(command), }) = cli.command else { + panic!("expected sandbox subcommand"); + }; + let SandboxCommands::Provider(command) = *command else { panic!("expected provider command") }; let (SandboxProviderCommands::Attach { readiness, .. } @@ -5385,13 +5393,17 @@ mod tests { fn remaining_list_commands_default_output_to_table() { let sandbox_provider = Cli::try_parse_from(["openshell", "sandbox", "provider", "list"]) .expect("sandbox provider list should parse"); + let Some(Commands::Sandbox { + command: Some(command), + }) = sandbox_provider.command + else { + panic!("expected sandbox subcommand"); + }; assert!(matches!( - sandbox_provider.command, - Some(Commands::Sandbox { - command: Some(SandboxCommands::Provider(SandboxProviderCommands::List { - output: OutputFormat::Table, - .. - })), + *command, + SandboxCommands::Provider(SandboxProviderCommands::List { + output: OutputFormat::Table, + .. }) )); @@ -5446,9 +5458,12 @@ mod tests { Cli::try_parse_from(["openshell", "sandbox", "provider", "list", "-o", format]) .unwrap_or_else(|error| panic!("sandbox provider list -o {format}: {error}")); let Some(Commands::Sandbox { - command: - Some(SandboxCommands::Provider(SandboxProviderCommands::List { output, .. })), + command: Some(command), }) = sandbox_provider.command + else { + panic!("expected sandbox subcommand"); + }; + let SandboxCommands::Provider(SandboxProviderCommands::List { output, .. }) = *command else { panic!("expected sandbox provider list command"); }; @@ -5501,14 +5516,18 @@ mod tests { let cli = Cli::try_parse_from(["openshell", "sandbox", "list"]) .expect("sandbox list should parse"); + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; assert!(matches!( - cli.command, - Some(Commands::Sandbox { - command: Some(SandboxCommands::List { - output: OutputFormat::Table, - .. - }) - }) + *command, + SandboxCommands::List { + output: OutputFormat::Table, + .. + } )); } @@ -5516,21 +5535,26 @@ mod tests { fn sandbox_stop_and_start_accept_optional_names() { let stop = Cli::try_parse_from(["openshell", "sandbox", "stop", "demo"]) .expect("stop command should parse"); + let Some(Commands::Sandbox { + command: Some(command), + }) = stop.command + else { + panic!("expected sandbox subcommand"); + }; assert!(matches!( - stop.command, - Some(Commands::Sandbox { - command: Some(SandboxCommands::Stop { name: Some(ref name) }), - }) if name == "demo" + *command, + SandboxCommands::Stop { name: Some(ref name) } if name == "demo" )); let start = Cli::try_parse_from(["openshell", "sandbox", "start"]) .expect("start command should parse"); - assert!(matches!( - start.command, - Some(Commands::Sandbox { - command: Some(SandboxCommands::Start { name: None }), - }) - )); + let Some(Commands::Sandbox { + command: Some(command), + }) = start.command + else { + panic!("expected sandbox subcommand"); + }; + assert!(matches!(*command, SandboxCommands::Start { name: None })); } #[test] @@ -5538,14 +5562,18 @@ mod tests { let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json"]) .expect("sandbox list -o json should parse"); + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; assert!(matches!( - cli.command, - Some(Commands::Sandbox { - command: Some(SandboxCommands::List { - output: OutputFormat::Json, - .. - }) - }) + *command, + SandboxCommands::List { + output: OutputFormat::Json, + .. + } )); } @@ -5554,14 +5582,18 @@ mod tests { let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "yaml"]) .expect("sandbox list -o yaml should parse"); + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; assert!(matches!( - cli.command, - Some(Commands::Sandbox { - command: Some(SandboxCommands::List { - output: OutputFormat::Yaml, - .. - }) - }) + *command, + SandboxCommands::List { + output: OutputFormat::Yaml, + .. + } )); } @@ -6194,11 +6226,13 @@ mod tests { result.err() ); if let Ok(cli) = result { - if let Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { name, .. }), - .. + let Some(Commands::Sandbox { + command: Some(command), }) = cli.command - { + else { + panic!("expected sandbox subcommand"); + }; + if let SandboxCommands::Create { name, .. } = *command { assert_eq!(name.as_deref(), Some("my-sb")); } else { panic!("expected SandboxCommands::Create"); @@ -6217,11 +6251,13 @@ mod tests { result.err() ); if let Ok(cli) = result { - if let Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { name, .. }), - .. + let Some(Commands::Sandbox { + command: Some(command), }) = cli.command - { + else { + panic!("expected sandbox subcommand"); + }; + if let SandboxCommands::Create { name, .. } = *command { assert_eq!(name, None); } else { panic!("expected SandboxCommands::Create"); @@ -6241,11 +6277,14 @@ mod tests { "owner=platform", ]) .unwrap(); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { annotations, .. }), - .. - }) => assert_eq!( + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { annotations, .. } => assert_eq!( annotations, vec![ "policy-source-file=/home/you/my policy.yaml".to_string(), @@ -6259,11 +6298,14 @@ mod tests { #[test] fn sandbox_create_restart_policy_defaults_to_never() { let cli = Cli::try_parse_from(["openshell", "sandbox", "create"]).unwrap(); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { restart_policy, .. }), - .. - }) => assert_eq!(restart_policy, "never"), + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { restart_policy, .. } => assert_eq!(restart_policy, "never"), other => panic!("expected SandboxCommands::Create, got: {other:?}"), } } @@ -6278,11 +6320,16 @@ mod tests { "on-failure", ]) .unwrap(); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { restart_policy, .. }), - .. - }) => assert_eq!(restart_policy, "on-failure"), + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { restart_policy, .. } => { + assert_eq!(restart_policy, "on-failure"); + } other => panic!("expected SandboxCommands::Create, got: {other:?}"), } } @@ -6314,14 +6361,16 @@ mod tests { ]) .expect("sandbox create --detach should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Create { - detach, command, .. - }), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { + detach, command, .. + } => { assert!(detach); assert_eq!(command, ["worker", "--serve"]); } @@ -6342,17 +6391,19 @@ mod tests { ]) .expect("detached ephemeral sandbox should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Create { - detach, - no_keep, - command, - .. - }), + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { + detach, + no_keep, + command, .. - }) => { + } => { assert!(detach); assert!(no_keep); assert_eq!(command, ["worker"]); @@ -6383,11 +6434,14 @@ mod tests { fn sandbox_create_approval_mode_defaults_to_manual() { let cli = Cli::try_parse_from(["openshell", "sandbox", "create"]) .expect("sandbox create with no flags should parse"); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { approval_mode, .. }), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { approval_mode, .. } => { assert_eq!(approval_mode, "manual"); } other => panic!("expected SandboxCommands::Create, got: {other:?}"), @@ -6400,11 +6454,14 @@ mod tests { let cli = Cli::try_parse_from(["openshell", "sandbox", "create", "--approval-mode", "auto"]) .expect("--approval-mode auto should parse"); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { approval_mode, .. }), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { approval_mode, .. } => { assert_eq!(approval_mode, "auto"); } other => panic!("expected SandboxCommands::Create, got: {other:?}"), @@ -6478,17 +6535,19 @@ mod tests { ]) .expect("sandbox create resource flags should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Create { - cpu, - memory, - command, - .. - }), + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { + cpu, + memory, + command, .. - }) => { + } => { assert_eq!(cpu.as_deref(), Some("500m")); assert_eq!(memory.as_deref(), Some("2Gi")); assert_eq!(command, vec!["claude".to_string()]); @@ -6509,14 +6568,16 @@ mod tests { ]) .expect("sandbox create driver config JSON flag should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Create { - driver_config_json, .. - }), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { + driver_config_json, .. + } => { assert_eq!(driver_config_json.as_deref(), Some(json)); } other => panic!("expected SandboxCommands::Create, got: {other:?}"), @@ -6536,16 +6597,18 @@ mod tests { ]) .expect("sandbox create template flag should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Create { - template, - providers, - .. - }), + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { + template, + providers, .. - }) => { + } => { assert_eq!(template.as_deref(), Some("gpu-kata")); assert_eq!(providers, vec!["github".to_string()]); } @@ -6609,25 +6672,27 @@ mod tests { ]) .expect("sandbox template create should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Template(SandboxTemplateCommands::Create { - name, - image, - cpu, - memory, - gpu, - driver_config_json, - ready_within, - max_burst, - labels, - annotations, - envs, - no_credential_warnings, - output, - })), - .. + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Template(SandboxTemplateCommands::Create { + name, + image, + cpu, + memory, + gpu, + driver_config_json, + ready_within, + max_burst, + labels, + annotations, + envs, + no_credential_warnings, + output, }) => { assert_eq!(name, "gpu-kata"); assert_eq!(image.as_deref(), Some("registry.example.com/agent:latest")); @@ -6665,17 +6730,19 @@ mod tests { ]) .expect("sandbox template list should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Template(SandboxTemplateCommands::List { - page_size, - page_token, - label_selector, - names, - all_workspaces, - .. - })), + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Template(SandboxTemplateCommands::List { + page_size, + page_token, + label_selector, + names, + all_workspaces, .. }) => { assert_eq!(page_size, 25); @@ -6707,12 +6774,14 @@ mod tests { let cli = Cli::try_parse_from(["openshell", "sandbox", "template", "create", "base"]) .expect("sandbox template create without --image should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Template(SandboxTemplateCommands::Create { image, .. })), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Template(SandboxTemplateCommands::Create { image, .. }) => { assert_eq!(image, None); } other => panic!("expected SandboxTemplateCommands::Create, got: {other:?}"), @@ -6731,12 +6800,14 @@ mod tests { ]) .expect("sandbox template create --gpu should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Template(SandboxTemplateCommands::Create { gpu, .. })), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Template(SandboxTemplateCommands::Create { gpu, .. }) => { assert_eq!(gpu, Some(GpuCliRequest::DriverDefault)); } other => panic!("expected SandboxTemplateCommands::Create, got: {other:?}"), @@ -6748,11 +6819,14 @@ mod tests { let cli = Cli::try_parse_from(["openshell", "sandbox", "create", "--gpu"]) .expect("sandbox create --gpu should parse"); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { gpu, .. }), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { gpu, .. } => { assert_eq!(gpu, Some(GpuCliRequest::DriverDefault)); } other => panic!("expected SandboxCommands::Create, got: {other:?}"), @@ -6764,11 +6838,14 @@ mod tests { let cli = Cli::try_parse_from(["openshell", "sandbox", "create", "--gpu", "2"]) .expect("sandbox create --gpu 2 should parse"); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { gpu, .. }), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { gpu, .. } => { assert_eq!(gpu, Some(GpuCliRequest::Count(2))); } other => panic!("expected SandboxCommands::Create, got: {other:?}"), @@ -6780,11 +6857,14 @@ mod tests { let cli = Cli::try_parse_from(["openshell", "sandbox", "create", "--gpu", "--", "claude"]) .expect("sandbox create --gpu -- claude should parse"); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { gpu, command, .. }), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { gpu, command, .. } => { assert_eq!(gpu, Some(GpuCliRequest::DriverDefault)); assert_eq!(command, vec!["claude".to_string()]); } @@ -6805,11 +6885,14 @@ mod tests { ]) .expect("sandbox create --gpu 2 -- claude should parse"); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { gpu, command, .. }), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { gpu, command, .. } => { assert_eq!(gpu, Some(GpuCliRequest::Count(2))); assert_eq!(command, vec!["claude".to_string()]); } @@ -6829,11 +6912,14 @@ mod tests { let cli = Cli::try_parse_from(["openshell", "sandbox", "create", "--gpu=2"]) .expect("sandbox create --gpu=2 should parse"); - match cli.command { - Some(Commands::Sandbox { - command: Some(SandboxCommands::Create { gpu, .. }), - .. - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { gpu, .. } => { assert_eq!(gpu, Some(GpuCliRequest::Count(2))); } other => panic!("expected SandboxCommands::Create, got: {other:?}"), @@ -6862,16 +6948,19 @@ mod tests { ]) .expect("sandbox create --expose should parse"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Create { - expose, - expose_authorization_mode, - detach, - .. - }), - }) => { + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { + expose, + expose_authorization_mode, + detach, + .. + } => { assert_eq!(expose, Some(4500)); assert_eq!( expose_authorization_mode, @@ -6916,14 +7005,17 @@ mod tests { "bearer-passthrough", ]) .expect("create-time authorization mode should parse with --expose"); - match cli.command { - Some(Commands::Sandbox { - command: - Some(SandboxCommands::Create { - expose_authorization_mode, - .. - }), - }) => assert_eq!( + let Some(Commands::Sandbox { + command: Some(command), + }) = cli.command + else { + panic!("expected sandbox subcommand"); + }; + match *command { + SandboxCommands::Create { + expose_authorization_mode, + .. + } => assert_eq!( expose_authorization_mode, CliServiceAuthorizationMode::BearerPassthrough ), From 90610b9e4e257281dc90514a30bbaa1b9cf38a73 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 19:35:55 +0000 Subject: [PATCH 14/19] fix(server): box oversized replay and reconciliation futures Signed-off-by: Johnny Greco --- crates/openshell-server/src/compute/mod.rs | 24 +++++++------------ crates/openshell-server/src/grpc/mod.rs | 2 +- .../grpc/mutation_replay/ordinary/tests.rs | 6 ++--- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index e618ebd2d2..02c6df5747 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -9276,8 +9276,7 @@ mod tests { .unwrap(); assert_eq!(retained.phase(), SandboxPhase::Deleting as i32); assert_eq!(credentials.stored_credential_count(), Some(1)); - runtime - .reconcile_store_with_backend(Duration::ZERO) + Box::pin(runtime.reconcile_store_with_backend(Duration::ZERO)) .await .unwrap(); assert!( @@ -9375,8 +9374,7 @@ mod tests { assert_eq!(retained.phase(), SandboxPhase::Deleting as i32); assert_eq!(credentials.stored_credential_count(), Some(1)); driver.set_get_outcome(ControlledGetOutcome::Missing); - runtime - .reconcile_store_with_backend(Duration::ZERO) + Box::pin(runtime.reconcile_store_with_backend(Duration::ZERO)) .await .unwrap(); assert!( @@ -14430,8 +14428,7 @@ mod tests { let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); runtime.store.put_message(&sandbox).await.unwrap(); - runtime - .reconcile_store_with_backend(Duration::ZERO) + Box::pin(runtime.reconcile_store_with_backend(Duration::ZERO)) .await .unwrap(); @@ -15839,8 +15836,7 @@ mod tests { runtime.sandbox_index.update_from_sandbox(&sandbox); register_test_supervisor_session(&runtime, "sb-1"); - runtime - .reconcile_store_with_backend(Duration::ZERO) + Box::pin(runtime.reconcile_store_with_backend(Duration::ZERO)) .await .unwrap(); @@ -15908,8 +15904,7 @@ mod tests { runtime.sandbox_index.update_from_sandbox(&sandbox); let traced = test_exporter::install_traced(); - runtime - .reconcile_store_with_backend(Duration::ZERO) + Box::pin(runtime.reconcile_store_with_backend(Duration::ZERO)) .await .unwrap(); @@ -15987,8 +15982,7 @@ mod tests { })) .await; - runtime - .reconcile_store_with_backend(Duration::ZERO) + Box::pin(runtime.reconcile_store_with_backend(Duration::ZERO)) .await .unwrap(); @@ -16036,8 +16030,7 @@ mod tests { runtime.sandbox_index.update_from_sandbox(&sandbox); register_test_supervisor_session(&runtime, "sb-1"); - runtime - .reconcile_store_with_backend(Duration::ZERO) + Box::pin(runtime.reconcile_store_with_backend(Duration::ZERO)) .await .unwrap(); @@ -16100,8 +16093,7 @@ mod tests { let mut watch_rx = runtime.sandbox_watch_bus.subscribe(sandbox.object_id()); - runtime - .reconcile_store_with_backend(Duration::ZERO) + Box::pin(runtime.reconcile_store_with_backend(Duration::ZERO)) .await .unwrap(); diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index c5eb13e857..6a7e0af70f 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -732,7 +732,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - mutation_replay::run(&self.state, request).await + Box::pin(mutation_replay::run(&self.state, request)).await } async fn reject_draft_chunk( 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 f0867a97e0..e821bda9d1 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs @@ -1087,11 +1087,11 @@ async fn draft_receipts_replay_after_chunk_state_and_review_tokens_change() { )), request_id: id(), }; - let approved = run(&state, authed_request(approve.clone())) + let approved = Box::pin(run(&state, authed_request(approve.clone()))) .await .unwrap() .into_inner(); - assert_eq!(replay(&state, approve.clone()).await, approved); + assert_eq!(Box::pin(replay(&state, approve.clone())).await, approved); replay(&state, edit).await; let undo = UndoDraftChunkRequest { sandbox: name.into(), @@ -1106,7 +1106,7 @@ async fn draft_receipts_replay_after_chunk_state_and_review_tokens_change() { .unwrap() .into_inner(); assert_eq!(replay(&state, undo).await, undone); - assert_eq!(replay(&state, approve).await, approved); + assert_eq!(Box::pin(replay(&state, approve)).await, approved); let reject = RejectDraftChunkRequest { sandbox: name.into(), chunk_id: submitted.accepted_chunk_ids[1].clone(), From 3253a33302541311cb6381f4714a3b890b2e8e31 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 19:36:05 +0000 Subject: [PATCH 15/19] chore(network): format mediated socket test fixture Signed-off-by: Johnny Greco --- crates/openshell-supervisor-network/src/proxy.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 849be576e1..0c570575e0 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -878,11 +878,13 @@ pub(crate) async fn stage_transparent_open_for_test( cmdline_paths: Vec::new(), }), destination, - socket: Some(openshell_isolation_interface::contract::NetworkSocketMetadata { - socket_cookie: 7, - nonblocking: false, - process_generation: 1, - }), + socket: Some( + openshell_isolation_interface::contract::NetworkSocketMetadata { + socket_cookie: 7, + nonblocking: false, + process_generation: 1, + }, + ), policy_generation: engine.current_generation(), timing: MediationTiming::default(), decision, From b81bd1e019f8c1ad4c1bb88ca7415eaedc6f98d8 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 19:41:58 +0000 Subject: [PATCH 16/19] fix(supervisor): box network proxy launch future Signed-off-by: Johnny Greco --- crates/openshell-supervisor/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openshell-supervisor/src/main.rs b/crates/openshell-supervisor/src/main.rs index 081526eddd..f537442766 100644 --- a/crates/openshell-supervisor/src/main.rs +++ b/crates/openshell-supervisor/src/main.rs @@ -545,14 +545,14 @@ fn main() -> Result<()> { "network-proxy role started without validated policy files" )); }; - openshell_supervisor::run_network_proxy( + Box::pin(openshell_supervisor::run_network_proxy( listen, policy_rules, policy_data, args.tls_dir, upstream_proxy_args, args.additional_ca_bundle, - ) + )) .await } } From 0801f923ac876a15d132614475ac6e1954d6f67f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 20:15:40 +0000 Subject: [PATCH 17/19] test: use portable isolation endpoint fixtures Signed-off-by: Johnny Greco --- crates/openshell-core/src/isolation_registration.rs | 2 +- crates/openshell-server/src/cli.rs | 2 +- crates/openshell-server/src/config_file.rs | 2 +- crates/openshell-supervisor/src/backend_setup/tests.rs | 2 +- crates/openshell-supervisor/src/main.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/openshell-core/src/isolation_registration.rs b/crates/openshell-core/src/isolation_registration.rs index 1633dbb742..9e5aa7c4b1 100644 --- a/crates/openshell-core/src/isolation_registration.rs +++ b/crates/openshell-core/src/isolation_registration.rs @@ -178,7 +178,7 @@ mod tests { fn operator_routes_survive_compute_protocol_and_supervisor_json() { for endpoint in [ SandboxTransport::Unix { - socket_path: "/run/backend.sock".into(), + socket_path: std::env::temp_dir().join("backend.sock"), }, SandboxTransport::Tcp { authority: "backend.example:443".into(), diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 5b2ce01c13..36c53e9c3e 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -2078,7 +2078,7 @@ mod tests { isolation_backend = "agent-substrate" [[openshell.supervisor.isolation_backends]] name = "agent-substrate" - endpoint = { kind = "unix", socket_path = "/run/isolation.sock" } + endpoint = { kind = "tcp", authority = "backend.example:443", addresses = ["127.0.0.1:47000"] } "#, ) .unwrap(); diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index cba890bff2..245c48bdf3 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -953,7 +953,7 @@ name = "agent-substrate" endpoint = { kind = "tcp", authority = "backend.example:443", addresses = ["127.0.0.1:47000"] } [[openshell.supervisor.isolation_backends]] name = "other-backend" -endpoint = { kind = "unix", socket_path = "/run/other.sock" } +endpoint = { kind = "tcp", authority = "other.example:443", addresses = ["127.0.0.1:47001"] } "#, ); let file = load(tmp.path()).unwrap(); diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index 1025860f7e..013e3368e7 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -44,7 +44,7 @@ fn registered_backend_selects_operator_endpoint_and_preserves_opaque_payload() { ) .unwrap(); let registrations: BackendRegistrations = serde_json::from_str( - r#"{"backends":[{"name":"external","endpoint":{"kind":"unix","socket_path":"/trusted/backend.sock"}}]}"#, + r#"{"backends":[{"name":"external","endpoint":{"kind":"tcp","authority":"backend.example:443","addresses":["127.0.0.1:47000"]}}]}"#, ).unwrap(); let launch = DelegatedLaunch { backend_name: "external".into(), diff --git a/crates/openshell-supervisor/src/main.rs b/crates/openshell-supervisor/src/main.rs index f537442766..c76e86b723 100644 --- a/crates/openshell-supervisor/src/main.rs +++ b/crates/openshell-supervisor/src/main.rs @@ -899,7 +899,7 @@ mod tests { ); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("backends.json"); - std::fs::write(&path, r#"{"backends":[{"name":"external","endpoint":{"kind":"unix","socket_path":"/trusted/backend.sock"}}]}"#).unwrap(); + std::fs::write(&path, r#"{"backends":[{"name":"external","endpoint":{"kind":"tcp","authority":"backend.example:443","addresses":["127.0.0.1:47000"]}}]}"#).unwrap(); let args = Args::try_parse_from([ "openshell-supervisor", "--isolation-backends-file", From e683b071d25175f2642ec9275dd130602a2d97f9 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 20:16:07 +0000 Subject: [PATCH 18/19] test(supervisor): gate Unix baseline path scenarios Signed-off-by: Johnny Greco --- crates/openshell-supervisor/src/lib.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 0555d76d28..81210b5428 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -5536,6 +5536,8 @@ network_policies: .await; } + // This scenario requires the Unix filesystem paths used by proxy baselines. + #[cfg(unix)] #[tokio::test(start_paused = true)] async fn startup_policy_write_refusal_baseline_write_back_waits_for_repair() { // The gateway policy has network rules but lacks the proxy baseline @@ -5665,6 +5667,8 @@ network_policies: .await; } + // This scenario requires the Unix filesystem paths used by proxy baselines. + #[cfg(unix)] #[tokio::test(start_paused = true)] async fn startup_policy_write_refusal_baseline_repair_before_report_refetches() { let mut repaired = proto_tcp_policy_fixture(); @@ -5808,6 +5812,8 @@ network_policies: .await; } + // This scenario requires the Unix filesystem paths used by proxy baselines. + #[cfg(unix)] #[tokio::test(start_paused = true)] async fn startup_policy_write_refusal_write_back_outlasts_the_attempt_limit() { // The same limit applies to a refused baseline-path write-back. @@ -5895,6 +5901,8 @@ network_policies: ); } + // This scenario requires the Unix filesystem paths used by proxy baselines. + #[cfg(unix)] #[tokio::test(start_paused = true)] async fn startup_policy_write_refusal_global_policy_skips_baseline_write_back() { use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; @@ -5977,21 +5985,26 @@ network_policies: #[tokio::test(start_paused = true)] async fn startup_policy_write_refusal_permanent_codes_still_exit() { - use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; + use openshell_core::proto::ConfigurationAdmissionState; // No policy or provider change repairs these refusals, so startup // keeps exiting instead of holding the sandbox in its repair window. - // Both writes are checked: the image policy upload to a gateway with - // no policy, and the write-back of a stored policy that lacks the - // proxy baseline paths. + // Check image policy uploads to a gateway with no policy. On Unix, + // also check write-back of a stored policy missing proxy baseline paths. let writes = [ ( "image policy upload", unset_policy_snapshot(), ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), ), + // Baseline enrichment requires existing Unix filesystem paths. + #[cfg(unix)] ( "baseline write-back", - settings_poll_result(Some(proto_tcp_policy_fixture()), 1, PolicySource::Sandbox), + settings_poll_result( + Some(proto_tcp_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ), ImagePolicyDiscovery::Missing, ), ]; From 2995fb5390b094f02745c2d1ee63d10cd9a2116d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sat, 17 Oct 2026 20:19:15 +0000 Subject: [PATCH 19/19] test(server): stabilize concurrent stress scheduling Signed-off-by: Johnny Greco --- .../src/compute/mutation_guard.rs | 27 ++++++++++-------- .../openshell-server/src/persistence/tests.rs | 28 ++++++++++--------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/crates/openshell-server/src/compute/mutation_guard.rs b/crates/openshell-server/src/compute/mutation_guard.rs index e8733015d6..ccffc934a9 100644 --- a/crates/openshell-server/src/compute/mutation_guard.rs +++ b/crates/openshell-server/src/compute/mutation_guard.rs @@ -1156,15 +1156,19 @@ mod tests { } } - /// Enter `op`'s keys, keep them for `hold`, then leave them in reverse + /// Enter `op`'s keys, yield `yield_count` times, then leave them in reverse /// order. `local` is the occupancy of the replica that runs `op`; mutation /// guards also enter `fleet`. The caller holds `op`'s guards throughout. - async fn occupy(local: &Occupancy, fleet: &Occupancy, op: MixOp, hold: Duration) { + async fn occupy(local: &Occupancy, fleet: &Occupancy, op: MixOp, yield_count: u64) { local.enter(op); if op.is_distributed() { fleet.enter(op); } - tokio::time::sleep(hold).await; + // Exercise scheduling under contention without depending on the + // platform's timer resolution for thousands of lock-held sleeps. + for _ in 0..yield_count { + tokio::task::yield_now().await; + } if op.is_distributed() { fleet.leave(op); } @@ -1172,7 +1176,7 @@ mod tests { } /// Run `tasks` tasks of `iterations` random operations each, spread - /// round-robin over `replicas`, holding each operation's locks for 0-2 ms. + /// round-robin over `replicas`, yielding 1-3 times under each operation's locks. /// Every operation must acquire its locks and finish `within`. async fn run_random_scope_mix( replicas: &[ComputeRuntime], @@ -1190,7 +1194,7 @@ mod tests { let plan: Vec<(MixOp, u64)> = (0..iterations) .map(|_| { let op = MixOp::random(&mut rng); - (op, rng.random_range(0..=2)) + (op, rng.random_range(1..=3)) }) .collect(); let replica = task % replicas.len(); @@ -1199,20 +1203,19 @@ mod tests { let fleet = Arc::clone(&fleet); let local = Arc::clone(&locals[replica]); handles.push(tokio::spawn(async move { - for (op, hold_ms) in plan { - let hold = Duration::from_millis(hold_ms); + for (op, yield_count) in plan { match op { MixOp::Global => { let _guard = runtime .mutation_guard(MutationScope::Global) .await .expect("guard"); - occupy(&local, &fleet, op, hold).await; + occupy(&local, &fleet, op, yield_count).await; } MixOp::Workspace(workspace) => { let scope = MutationScope::Workspace(&names.workspaces[workspace]); let _guard = runtime.mutation_guard(scope).await.expect("guard"); - occupy(&local, &fleet, op, hold).await; + occupy(&local, &fleet, op, yield_count).await; } MixOp::Sandbox(workspace, sandbox) => { let scope = MutationScope::sandbox( @@ -1220,12 +1223,12 @@ mod tests { &names.sandboxes[sandbox], ); let _guard = runtime.mutation_guard(scope).await.expect("guard"); - occupy(&local, &fleet, op, hold).await; + occupy(&local, &fleet, op, yield_count).await; } MixOp::Lifecycle(sandbox) => { let _guard = runtime.lock_sandbox_local(&names.sandboxes[sandbox]).await; - occupy(&local, &fleet, op, hold).await; + occupy(&local, &fleet, op, yield_count).await; } MixOp::GatedLifecycle(sandbox) => { let gate = runtime @@ -1233,7 +1236,7 @@ mod tests { .lock_for(&names.sandboxes[sandbox]) .await; let _guard = runtime.lock_sandbox_for_lifecycle(&gate).await; - occupy(&local, &fleet, op, hold).await; + occupy(&local, &fleet, op, yield_count).await; } } } diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index be86441245..1e97074ddc 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -2388,11 +2388,14 @@ async fn sqlite_concurrent_writes_return_their_own_resource_version() { .await .unwrap(); - let writers = (0..WRITERS).map(|writer| { - let store = Arc::clone(&store); - tokio::spawn(async move { - let mut versions = Vec::new(); - for write in 0..WRITES { + let mut seen = BTreeSet::new(); + // Race a fresh cohort each round so fast writers cannot run many rounds + // ahead while another connection waits for SQLite's single writer lock. + for write in 0..WRITES { + let writers = (0..WRITERS).map(|writer| { + let store = Arc::clone(&store); + tokio::spawn(async move { + let mut versions = Vec::new(); let payload = format!("{writer}-{write}"); let unconditional = put_sandbox_1(&store, &payload, WriteCondition::Unconditional) .await @@ -2412,15 +2415,14 @@ async fn sqlite_concurrent_writes_return_their_own_resource_version() { Err(err) => panic!("unexpected error: {err}"), } } - } - versions - }) - }); + versions + }) + }); - let mut seen = BTreeSet::new(); - for handle in futures::future::join_all(writers).await { - for version in handle.unwrap() { - assert!(seen.insert(version), "version {version} returned twice"); + for handle in futures::future::join_all(writers).await { + for version in handle.unwrap() { + assert!(seen.insert(version), "version {version} returned twice"); + } } }