diff --git a/Cargo.lock b/Cargo.lock index 4c63554b9..dace79547 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4841,6 +4841,7 @@ dependencies = [ "openshell-isolation-interface", "openshell-ocsf", "openshell-policy", + "openshell-policy-schema", "openshell-supervisor-middleware", "openshell-supervisor-middleware-builtins", "prost-types", diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 148f632aa..6d924b1f1 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -1348,6 +1348,61 @@ pub fn validate_sandbox_policy( validate_sandbox_policy_with_mcp_presence(policy, McpVersionPresence::RequireMaterialized) } +/// Validate filesystem paths shared by typed policies and raw OPA data. +/// +/// Paths must be absolute, contain no parent traversal, and fit the path count +/// and byte limits. Read-write access to the filesystem root is forbidden. +/// Callers that expose errors outside trusted authoring tools must redact the +/// path values carried by the returned violations. +pub fn validate_filesystem_paths( + read_only: &[String], + read_write: &[String], +) -> std::result::Result<(), Vec> { + let mut violations = Vec::new(); + let total_paths = read_only.len() + read_write.len(); + if total_paths > MAX_FILESYSTEM_PATHS { + violations.push(PolicyViolation::TooManyPaths { count: total_paths }); + } + + for path_str in read_only.iter().chain(read_write.iter()) { + if path_str.len() > MAX_PATH_LENGTH { + violations.push(PolicyViolation::FieldTooLong { + path: truncate_for_display(path_str), + length: path_str.len(), + }); + continue; + } + let path = Path::new(path_str); + if !path.has_root() { + violations.push(PolicyViolation::RelativePath { + path: path_str.clone(), + }); + } + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + violations.push(PolicyViolation::PathTraversal { + path: path_str.clone(), + }); + } + } + + for path_str in read_write { + // Repeated separators still designate the filesystem root. + if path_str.trim_end_matches('/').is_empty() { + violations.push(PolicyViolation::OverlyBroadPath { + path: path_str.clone(), + }); + } + } + if violations.is_empty() { + Ok(()) + } else { + Err(violations) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum McpVersionPresence { RequireMaterialized, @@ -1389,50 +1444,10 @@ fn validate_sandbox_policy_with_mcp_presence( }); } - // Check filesystem paths - if let Some(ref fs) = policy.filesystem { - let total_paths = fs.read_only.len() + fs.read_write.len(); - if total_paths > MAX_FILESYSTEM_PATHS { - violations.push(PolicyViolation::TooManyPaths { count: total_paths }); - } - - for path_str in fs.read_only.iter().chain(fs.read_write.iter()) { - if path_str.len() > MAX_PATH_LENGTH { - violations.push(PolicyViolation::FieldTooLong { - path: truncate_for_display(path_str), - length: path_str.len(), - }); - continue; - } - - let path = Path::new(path_str); - - if !path.has_root() { - violations.push(PolicyViolation::RelativePath { - path: path_str.clone(), - }); - } - - if path - .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) - { - violations.push(PolicyViolation::PathTraversal { - path: path_str.clone(), - }); - } - } - - // Only reject "/" as read-write (overly broad) - for path_str in &fs.read_write { - let normalized = path_str.trim_end_matches('/'); - if normalized.is_empty() { - // Path is "/" or "///" etc. - violations.push(PolicyViolation::OverlyBroadPath { - path: path_str.clone(), - }); - } - } + if let Some(ref fs) = policy.filesystem + && let Err(errors) = validate_filesystem_paths(&fs.read_only, &fs.read_write) + { + violations.extend(errors); } // Protobuf maps do not preserve iteration order. Sort rule keys so callers diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index d4aa9d1ee..300db03ac 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-schema = { path = "../openshell-policy-schema" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } async-trait = "0.1" diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 851125c41..74a14466d 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -16,6 +16,10 @@ use openshell_core::policy::{ 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_schema::{ + FilesystemPolicy as AuthoredFilesystemPolicy, LandlockPolicy as AuthoredLandlockPolicy, + ProcessPolicy as AuthoredProcessPolicy, +}; use openshell_supervisor_middleware::{ChainEntry, ChainRunner, MiddlewareRegistry}; use std::path::{Path, PathBuf}; use std::sync::{ @@ -25,6 +29,8 @@ use std::sync::{ use tokio::sync::watch; use tracing::info; +mod raw_schema; + /// Baked-in rego rules for OPA policy evaluation. /// These rules define the network access decision logic and static config /// passthroughs. They reference `data.sandbox.*` for policy data. @@ -925,22 +931,24 @@ impl OpaEngine { .lock() .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; - // Query filesystem policy + // Evaluation errors can include authored Rego source. Regorus may + // evaluate other rules while resolving any one query, so report the + // static-settings operation without attributing it to a single rule. let fs_val = engine .eval_rule("data.openshell.sandbox.filesystem_policy".into()) - .map_err(|e| miette::miette!("{e}"))?; + .map_err(|_| miette::miette!("failed to evaluate static sandbox settings"))?; let filesystem = parse_filesystem_policy(&fs_val); // Query landlock policy let ll_val = engine .eval_rule("data.openshell.sandbox.landlock_policy".into()) - .map_err(|e| miette::miette!("{e}"))?; + .map_err(|_| miette::miette!("failed to evaluate static sandbox settings"))?; let landlock = parse_landlock_policy(&ll_val); // Query process policy let proc_val = engine .eval_rule("data.openshell.sandbox.process_policy".into()) - .map_err(|e| miette::miette!("{e}"))?; + .map_err(|_| miette::miette!("failed to evaluate static sandbox settings"))?; let process = parse_process_policy(&proc_val); Ok(SandboxConfig { @@ -1482,6 +1490,48 @@ fn validate_opa_object_array<'a>( Ok(entries) } +/// Validate static sections with the authored types while retaining OPA-only data. +/// Keep schema errors and their source chains out of load diagnostics because +/// they can contain authored keys, paths, or values. +fn validate_opa_static_settings(data: &mut serde_json::Value) -> Result<()> { + if let Some(filesystem) = data.get_mut("filesystem_policy") { + let settings: AuthoredFilesystemPolicy = serde_json::from_value(filesystem.clone()) + .map_err(|_| miette::miette!("invalid filesystem policy settings"))?; + openshell_policy::validate_filesystem_paths(&settings.read_only, &settings.read_write) + .map_err(|violations| { + miette::miette!(render_bounded_validation_diagnostics( + "invalid filesystem policy settings", + violations.iter().map(redacted_policy_violation_category), + )) + })?; + // A present stanza defaults to false; an absent stanza must stay absent + // so Rego's undefined result retains the runtime workdir default. + filesystem["include_workdir"] = settings.include_workdir.into(); + } + if let Some(landlock) = data.get_mut("landlock") { + let settings: AuthoredLandlockPolicy = serde_json::from_value(landlock.clone()) + .map_err(|_| miette::miette!("invalid Landlock policy settings"))?; + // Serde also accepts a map for a unit enum variant. Runtime consumers + // read a string, so retain the validated meaning in canonical form. + *landlock = serde_json::to_value(settings) + .map_err(|_| miette::miette!("failed to serialize Landlock policy settings"))?; + } + if let Some(process) = data.get("process") { + let settings = serde_json::from_value::(process.clone()) + .map_err(|_| miette::miette!("invalid process policy settings"))?; + // Omitted identities are resolved by the compute runtime. Explicit + // values follow the same non-root identity contract as typed policy. + for identity in [&settings.run_as_user, &settings.run_as_group] { + if !identity.is_empty() && !openshell_policy::is_valid_sandbox_identity(identity) { + return Err(miette::miette!( + "invalid process policy settings: invalid process identity" + )); + } + } + } + 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. @@ -1597,6 +1647,8 @@ fn preprocess_yaml_data( ) })?; validate_opa_data_structure(&data)?; + raw_schema::validate_network_settings(&data)?; + validate_opa_static_settings(&mut data)?; inject_runtime_policy_data(&mut data, require_binary_identity); normalize_endpoint_protocols(&mut data); @@ -1811,13 +1863,8 @@ fn normalize_l7_config_alias( let Some(config) = ep.get(key).cloned() else { return; }; - if config.is_null() { - ep.remove(key); - if stanza == L7ConfigStanza::Mcp { - errors.push(format!("{loc}.{key}: mcp config must be an object")); - } - return; - } + // Explicit null must reach the canonical parser: it is invalid authored + // configuration, while an omitted stanza may select protocol defaults. match openshell_policy::l7_config_alias_runtime_fields(stanza, config) { Ok(fields) => { ep.remove(key); @@ -4256,6 +4303,442 @@ process: ); } + /// The typed schema gives an absent `filesystem_policy` the platform + /// default, `include_workdir: true`, and gives a present but empty stanza + /// `include_workdir: false`. Loading the same YAML directly into OPA must + /// agree in both cases, and versionless OPA data must follow the same rule + /// for a present empty stanza. + #[test] + fn yaml_and_proto_filesystem_policy_have_include_workdir_parity() { + for (data, expected) in [ + ("version: 1\n", true), + ("version: 1\nfilesystem_policy: {}\n", false), + ] { + let proto = openshell_policy::parse_sandbox_policy(data) + .expect("fixture must parse into the typed schema"); + let proto_config = OpaEngine::from_proto(&proto) + .expect("engine from protobuf") + .query_sandbox_config() + .expect("config from protobuf"); + let yaml_config = OpaEngine::from_strings(TEST_POLICY, data) + .expect("engine from YAML") + .query_sandbox_config() + .expect("config from YAML"); + assert_eq!( + proto_config.filesystem.include_workdir, expected, + "typed contract for {data:?}" + ); + assert_eq!( + yaml_config.filesystem.include_workdir, expected, + "raw OPA loading for {data:?}" + ); + } + + let versionless = OpaEngine::from_strings(TEST_POLICY, "filesystem_policy: {}\n") + .expect("versionless OPA data must load") + .query_sandbox_config() + .expect("config from versionless OPA data"); + assert!( + !versionless.filesystem.include_workdir, + "raw OPA loading of a versionless empty filesystem stanza" + ); + } + + #[test] + fn yaml_and_proto_landlock_policy_have_compatibility_parity() { + for (stanza, hard_requirement) in [ + ("{}", false), + ("{compatibility: best_effort}", false), + ("{compatibility: hard_requirement}", true), + ("{compatibility: {best_effort: null}}", false), + ("{compatibility: {hard_requirement: null}}", true), + ] { + let versionless = format!("landlock: {stanza}\n"); + let versioned = format!("version: 1\n{versionless}"); + let proto = openshell_policy::parse_sandbox_policy(&versioned) + .expect("valid typed Landlock representation"); + let typed = OpaEngine::from_proto(&proto) + .expect("typed engine") + .query_sandbox_config() + .expect("typed sandbox config"); + assert_eq!( + matches!( + typed.landlock.compatibility, + LandlockCompatibility::HardRequirement + ), + hard_requirement, + "typed contract for {stanza}" + ); + for data in [&versioned, &versionless] { + let raw = OpaEngine::from_strings(TEST_POLICY, data) + .expect("valid raw Landlock representation") + .query_sandbox_config() + .expect("raw sandbox config"); + assert_eq!( + matches!( + raw.landlock.compatibility, + LandlockCompatibility::HardRequirement + ), + hard_requirement, + "raw OPA loading for {data}" + ); + } + } + } + + /// Nested values that the typed schema rejects must also be rejected when + /// the same policy is loaded directly into OPA, with or without a + /// `version` key, instead of being replaced by defaults + /// (`include_workdir: true`, best-effort Landlock) or dropped. Each case + /// has a valid twin that both paths accept, so the test cannot pass by + /// rejecting valid input. + #[test] + fn raw_opa_loading_rejects_nested_values_the_typed_schema_rejects() { + const JSON_RPC_ENDPOINT: &str = r"network_policies: + rpc: + name: rpc + endpoints: + - host: jsonrpc.parity.test + port: 443 + path: /rpc + protocol: json-rpc + enforcement: enforce + json_rpc: JSON_RPC_OPTIONS + rules: + - allow: { method: status.get } + binaries: + - { path: /usr/bin/curl } +"; + // Each case is (name, invalid body, valid twin body). Bodies omit + // `version` so each invalid body is also loaded as versionless OPA data. + let cases = [ + ( + "string include_workdir", + "filesystem_policy: {include_workdir: \"false\"}\n".to_owned(), + "filesystem_policy: {include_workdir: false}\n".to_owned(), + ), + ( + "non-string read_only entry", + "filesystem_policy: {read_only: [7]}\n".to_owned(), + "filesystem_policy: {read_only: [\"/usr\"]}\n".to_owned(), + ), + ( + "non-string read_write entry", + "filesystem_policy: {read_write: [false]}\n".to_owned(), + "filesystem_policy: {read_write: [\"/tmp\"]}\n".to_owned(), + ), + ( + "unknown filesystem field", + "filesystem_policy: {private_typo: false}\n".to_owned(), + "filesystem_policy: {}\n".to_owned(), + ), + ( + "unknown Landlock compatibility", + "landlock: {compatibility: required}\n".to_owned(), + "landlock: {compatibility: hard_requirement}\n".to_owned(), + ), + ( + "unknown Landlock field", + "landlock: {private_typo: true}\n".to_owned(), + "landlock: {}\n".to_owned(), + ), + ( + "non-string process identity", + "process: {run_as_user: 7}\n".to_owned(), + "process: {run_as_user: sandbox}\n".to_owned(), + ), + ( + "null process group", + "process: {run_as_group: null}\n".to_owned(), + "process: {run_as_group: sandbox}\n".to_owned(), + ), + ( + "unknown process field", + "process: {private_typo: sandbox}\n".to_owned(), + "process: {}\n".to_owned(), + ), + ( + "explicit null json_rpc options", + JSON_RPC_ENDPOINT.replace("JSON_RPC_OPTIONS", "null"), + JSON_RPC_ENDPOINT.replace("JSON_RPC_OPTIONS", "{ max_body_bytes: 32768 }"), + ), + ]; + + // Check every fixture before failing, so one run reports each input the + // raw loader accepts instead of stopping at the first. + let mut accepted_by_raw_opa = Vec::new(); + for (case, invalid, valid) in &cases { + let valid = format!("version: 1\n{valid}"); + assert!( + openshell_policy::parse_sandbox_policy(&valid).is_ok(), + "typed schema must accept the valid {case} twin" + ); + assert!( + OpaEngine::from_strings(TEST_POLICY, &valid).is_ok(), + "raw OPA loading must accept the valid {case} twin" + ); + + let versioned = format!("version: 1\n{invalid}"); + assert!( + openshell_policy::parse_sandbox_policy(&versioned).is_err(), + "typed schema must reject the {case} fixture" + ); + for (form, data) in [("versioned", versioned.as_str()), ("versionless", invalid)] { + match OpaEngine::from_strings(TEST_POLICY, data) { + Ok(_) => accepted_by_raw_opa.push(format!("{case} ({form})")), + Err(error) => assert_safe_load_error(&error, &["private_typo"]), + } + } + } + + let hard_requirement = OpaEngine::from_strings( + TEST_POLICY, + "version: 1\nlandlock: {compatibility: hard_requirement}\n", + ) + .expect("valid Landlock twin must load") + .query_sandbox_config() + .expect("config from valid Landlock twin"); + assert!(matches!( + hard_requirement.landlock.compatibility, + LandlockCompatibility::HardRequirement + )); + + assert!( + accepted_by_raw_opa.is_empty(), + "raw OPA loading accepted fixtures that the typed schema rejects: {accepted_by_raw_opa:?}" + ); + } + + #[test] + fn raw_opa_network_leaves_reject_typed_schema_errors() { + let valid = opa_container_policy(); + let mut accepted = Vec::new(); + for (field, invalid, control) in [ + ( + "allow_encoded_slash", + serde_json::json!("true"), + serde_json::json!(true), + ), + ( + "websocket_credential_rewrite", + serde_json::json!("true"), + serde_json::json!(true), + ), + ("port", serde_json::json!("443"), serde_json::json!(443)), + ( + "deny_rules", + serde_json::json!([{"method": ["DELETE"], "path": "/admin/**"}]), + serde_json::json!([{"method": "DELETE", "path": "/admin/**"}]), + ), + ] { + let mut policy = valid.clone(); + policy["version"] = 1.into(); + policy["network_policies"]["admin"]["endpoints"][0][field] = control; + openshell_policy::parse_sandbox_policy(&policy.to_string()).expect("typed valid twin"); + OpaEngine::from_strings(TEST_POLICY, &policy.to_string()).expect("raw valid twin"); + policy["network_policies"]["admin"]["endpoints"][0][field] = invalid; + assert!( + openshell_policy::parse_sandbox_policy(&policy.to_string()).is_err(), + "typed {field}" + ); + for versioned in [true, false] { + if !versioned { + policy.as_object_mut().expect("object").remove("version"); + } + match OpaEngine::from_strings(TEST_POLICY, &policy.to_string()) { + Ok(_) => accepted.push(format!("{field} (versioned={versioned})")), + Err(error) => { + assert_safe_load_error(&error, &["admin.example.test", "/admin/**"]); + } + } + } + } + assert!( + accepted.is_empty(), + "raw loader accepted invalid network fields: {accepted:?}" + ); + } + + #[test] + fn raw_opa_static_semantics_match_typed_validation() { + let mut accepted = Vec::new(); + for invalid in [ + serde_json::json!({"process": {"run_as_user": "root"}}), + serde_json::json!({"process": {"run_as_group": "0"}}), + serde_json::json!({"process": {"run_as_user": "4294967295"}}), + serde_json::json!({"filesystem_policy": {"read_write": ["/"]}}), + serde_json::json!({"filesystem_policy": {"read_write": ["///"]}}), + serde_json::json!({"filesystem_policy": {"read_only": ["relative-private-path"]}}), + serde_json::json!({"filesystem_policy": {"read_only": ["/tmp/../private-path"]}}), + serde_json::json!({"filesystem_policy": {"read_only": [format!("/{}", "p".repeat(4096))]}}), + serde_json::json!({"filesystem_policy": {"read_only": vec!["/usr"; 257]}}), + ] { + let mut versioned = invalid.clone(); + versioned["version"] = 1.into(); + assert!( + openshell_policy::parse_sandbox_policy(&versioned.to_string()).map_or( + true, + |policy| openshell_policy::validate_sandbox_policy(&policy).is_err() + ), + "typed invalid static settings" + ); + for policy in [&invalid, &versioned] { + match OpaEngine::from_strings(TEST_POLICY, &policy.to_string()) { + Ok(_) => accepted.push(invalid.clone()), + Err(error) => { + assert_safe_load_error(&error, &["root", "private-path", "4294967295"]); + } + } + } + } + for valid in [ + serde_json::json!({"process": {}}), + serde_json::json!({"process": {"run_as_user": "sandbox", "run_as_group": "1"}}), + serde_json::json!({"process": {"run_as_user": "4294967294", "run_as_group": "sandbox"}}), + serde_json::json!({"filesystem_policy": {"read_only": ["/"], "read_write": ["/tmp"]}}), + serde_json::json!({"filesystem_policy": {"read_only": vec!["/usr"; 256]}}), + serde_json::json!({"filesystem_policy": {"read_only": [format!("/{}", "p".repeat(4095))]}}), + ] { + let mut versioned = valid.clone(); + versioned["version"] = 1.into(); + let typed = openshell_policy::parse_sandbox_policy(&versioned.to_string()) + .expect("typed static control"); + openshell_policy::validate_sandbox_policy(&typed).expect("valid typed static settings"); + for policy in [&valid, &versioned] { + OpaEngine::from_strings(TEST_POLICY, &policy.to_string()) + .expect("raw static control") + .query_sandbox_config() + .expect("static config"); + } + } + assert!( + accepted.is_empty(), + "raw loader accepted {} invalid static policies", + accepted.len() + ); + } + + #[test] + fn startup_evaluation_errors_discard_authored_rego_and_sources() { + let directory = tempfile::tempdir().expect("temporary policy directory"); + let rules_path = directory.path().join("private-rules.rego"); + let data_path = directory.path().join("private-data.yaml"); + std::fs::write(&data_path, "{}").expect("write data"); + for rule in ["filesystem_policy", "landlock_policy", "process_policy"] { + for repetitions in [1, 20] { + let marker = "private-eval-marker-".repeat(repetitions); + let rules = format!( + "package openshell.sandbox\n{rule} := {{\"value\": \"{marker}one\"}}\n{rule} := {{\"value\": \"{marker}two\"}}\n" + ); + std::fs::write(&rules_path, &rules).expect("write conflicting rules"); + for engine in [ + OpaEngine::from_strings(&rules, "{}").expect("conflict is an evaluation error"), + OpaEngine::from_files(&rules_path, &data_path).expect("file rules compile"), + ] { + let error = engine + .query_sandbox_config() + .err() + .expect("conflicting complete rules must fail"); + assert_safe_load_error( + &error, + &["private-eval-marker", "private-rules.rego", "value"], + ); + assert_eq!( + error.to_string(), + "failed to evaluate static sandbox settings" + ); + } + } + } + } + + #[test] + fn raw_opa_nested_validation_preserves_file_loads_and_rejected_reload() { + let directory = tempfile::tempdir().expect("temporary policy directory"); + let rules_path = directory.path().join("policy.rego"); + let data_path = directory.path().join("policy.yaml"); + std::fs::write(&rules_path, TEST_POLICY).expect("write policy rules"); + let valid = opa_container_policy(); + std::fs::write(&data_path, valid.to_string()).expect("write valid policy"); + let engine = OpaEngine::from_files(&rules_path, &data_path).expect("valid file policy"); + let proxy = OpaEngine::from_files_for_endpoint_only_proxy(&rules_path, &data_path, None) + .expect("valid endpoint-only policy"); + for loaded in [&engine, &proxy] { + assert!( + !loaded + .query_sandbox_config() + .expect("filesystem config") + .filesystem + .include_workdir, + "file loaders must retain the typed default for an empty filesystem stanza" + ); + } + let allowed = l7_input("admin.example.test", 443, "GET", "/public"); + let denied = l7_input("admin.example.test", 443, "DELETE", "/admin/users"); + let generation = engine.current_generation(); + assert!(eval_l7(&engine, &allowed)); + assert!(!eval_l7(&engine, &denied)); + + // Malformed nested settings must fail before replacing any installed + // decisions or notifying consumers of a new generation. + for (pointer, replacement) in [ + ( + "/filesystem_policy", + serde_json::json!({"include_workdir": "private-value-秘密".repeat(1024)}), + ), + ( + "/landlock", + serde_json::json!({"compatibility": "private-value-秘密".repeat(1024)}), + ), + ("/process", serde_json::json!({"run_as_user": 7})), + ("/process", serde_json::json!({"run_as_user": "root"})), + ( + "/filesystem_policy", + serde_json::json!({"read_write": ["/"]}), + ), + ( + "/network_policies/admin/endpoints/0/deny_rules/0/method", + serde_json::json!(["DELETE"]), + ), + ( + "/network_policies/admin/binaries/0/path", + serde_json::json!(["/usr/bin/curl"]), + ), + ] { + let mut malformed = valid.clone(); + *malformed + .pointer_mut(pointer) + .expect("existing static section") = replacement; + let data = malformed.to_string(); + std::fs::write(&data_path, &data).expect("write malformed policy"); + for error in [ + OpaEngine::from_files(&rules_path, &data_path) + .err() + .expect("file loader must reject malformed nested values"), + OpaEngine::from_files_for_endpoint_only_proxy(&rules_path, &data_path, None) + .err() + .expect("endpoint-only file loader must reject malformed nested values"), + engine + .reload(TEST_POLICY, &data) + .expect_err("reload must reject malformed nested values"), + ] { + assert_safe_load_error(&error, &["private-value", "秘密", "admin.example.test"]); + } + assert_eq!(engine.current_generation(), generation); + assert!(eval_l7(&engine, &allowed)); + assert!(!eval_l7(&engine, &denied)); + } + + let mut repaired = valid; + repaired["network_policies"]["admin"]["endpoints"][0]["deny_rules"][0]["path"] = + "/other/**".into(); + engine + .reload(TEST_POLICY, &repaired.to_string()) + .expect("valid replacement after rejected reloads"); + assert_eq!(engine.current_generation(), generation + 1); + assert!(eval_l7(&engine, &denied)); + } + #[test] fn query_sandbox_config_extracts_filesystem() { let engine = test_engine(); diff --git a/crates/openshell-supervisor-network/src/opa/raw_schema.rs b/crates/openshell-supervisor-network/src/opa/raw_schema.rs new file mode 100644 index 000000000..909026550 --- /dev/null +++ b/crates/openshell-supervisor-network/src/opa/raw_schema.rs @@ -0,0 +1,438 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Validate raw network fields before normalization can erase malformed values. +//! +//! Raw OPA data can contain application data and lowered protocol options that +//! are not authored-policy fields. Validation uses the authored DTOs on copies; +//! the original objects retain their extra fields for Rego evaluation. + +use miette::Result; +use openshell_policy_schema::{ + JsonRpcConfig, L7Allow, L7DenyRule, McpConfig, NetworkBinary, NetworkEndpoint, + NetworkPolicyRule, +}; +use serde::{Deserializer, de::DeserializeOwned}; +use serde_json::{Map, Value}; + +/// Validate consumed fields after the parent has checked collection shapes. +/// +/// Shared fields use the authored DTO types. Only the raw runtime field names +/// and matcher representation need adaptation; existing L7 validators still +/// own protocol combinations and matcher semantics. +pub(super) fn validate_network_settings(data: &Value) -> Result<()> { + let Some(policies) = data.get("network_policies").and_then(Value::as_object) else { + return Ok(()); + }; + for policy in policies.values() { + let mut fields = object_copy(policy)?; + fields.remove("endpoints"); + fields.remove("binaries"); + validate_known::(fields, "invalid network policy settings")?; + + for binary in array_entries(policy, "binaries") { + validate_known::( + object_copy(binary)?, + "invalid network binary settings", + )?; + } + for endpoint in array_entries(policy, "endpoints") { + validate_endpoint(endpoint)?; + } + } + Ok(()) +} + +fn validate_endpoint(endpoint: &Value) -> Result<()> { + let diagnostic = "L7 policy validation failed: invalid L7 policy configuration"; + let mut fields = object_copy(endpoint)?; + // Rules are validated separately because raw matchers accept an explicit + // `glob` object and allow rules also have a flattened OPA representation. + fields.remove("rules"); + fields.remove("deny_rules"); + // The following alias-normalization stage already parses these stanzas + // through the canonical config DTOs and owns their diagnostic category. + fields.remove("mcp"); + fields.remove("json_rpc"); + validate_known::(fields, diagnostic)?; + + validate_renamed::( + endpoint, + &[("json_rpc_max_body_bytes", "max_body_bytes")], + diagnostic, + )?; + validate_renamed::( + endpoint, + &[ + ("mcp_versions", "versions"), + ("mcp_strict_tool_names", "strict_tool_names"), + ( + "mcp_allow_all_known_mcp_methods", + "allow_all_known_mcp_methods", + ), + ], + diagnostic, + )?; + + // These values are emitted by protobuf lowering and read by network/L7 + // consumers. They have no authored DTO field, so check their wire types + // explicitly rather than letting failed reads become runtime defaults. + for field in ["provider_credentialed", "advisor_proposed"] { + if endpoint.get(field).is_some_and(|value| !value.is_boolean()) { + return Err(miette::miette!(diagnostic)); + } + } + for field in ["endpoint_id", "policy_hash"] { + if endpoint.get(field).is_some_and(|value| !value.is_string()) { + return Err(miette::miette!(diagnostic)); + } + } + + for rule in array_entries(endpoint, "rules") { + let mut fields = object_copy(rule.get("allow").unwrap_or(rule))?; + adapt_matchers(&mut fields); + validate_known::(fields, diagnostic)?; + } + for rule in array_entries(endpoint, "deny_rules") { + let mut fields = object_copy(rule)?; + adapt_matchers(&mut fields); + validate_known::(fields, diagnostic)?; + } + Ok(()) +} + +fn array_entries<'a>(object: &'a Value, field: &str) -> &'a [Value] { + // The parent shape validator rejects malformed present arrays before this + // traversal. Missing arrays retain the raw loader's empty default. + object + .get(field) + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice) +} + +fn object_copy(value: &Value) -> Result> { + value.as_object().cloned().ok_or_else(|| { + miette::miette!("L7 policy validation failed: invalid L7 policy configuration") + }) +} + +fn validate_renamed( + value: &Value, + names: &[(&str, &str)], + diagnostic: &'static str, +) -> Result<()> { + let fields = names + .iter() + .filter_map(|(raw, authored)| { + value + .get(*raw) + .map(|value| ((*authored).to_string(), value.clone())) + }) + .collect(); + validate_known::(fields, diagnostic) +} + +/// Adapt only validation copies of runtime matcher leaves. +/// +/// An explicit `{glob: string}` has the same type as the authored scalar +/// matcher. Leave malformed objects intact so the canonical DTO rejects them. +/// In particular, a mixed `glob`/`any` object must not lose either selector. +fn adapt_matchers(rule: &mut Map) { + // The raw query validator treats null as omission. Preserve that OPA-only + // form in installed data while omitting it from this authored-type check. + if rule.get("query").is_some_and(Value::is_null) { + rule.remove("query"); + } + for field in ["query", "params"] { + if let Some(matchers) = rule.get_mut(field).and_then(Value::as_object_mut) { + for matcher in matchers.values_mut() { + adapt_matcher(matcher); + } + } + } + if let Some(tool) = rule.get_mut("tool") { + adapt_matcher(tool); + } +} + +fn adapt_matcher(matcher: &mut Value) { + if let Some(fields) = matcher.as_object() + && fields.len() == 1 + && let Some(glob) = fields.get("glob").filter(|value| value.is_string()) + { + *matcher = glob.clone(); + } +} + +fn validate_known( + fields: Map, + diagnostic: &'static str, +) -> Result<()> { + T::deserialize(KnownFields(Value::Object(fields))) + .map(|_| ()) + // Serde errors include authored values and keys. Do not retain their + // Display, Debug, or source chain at the policy loading boundary. + .map_err(|_| miette::miette!(diagnostic)) +} + +/// Use the canonical DTO's declared field list instead of duplicating it. +/// +/// Unknown fields at this object belong to raw OPA data and remain untouched +/// in the installed document. Nested authored configuration still uses its +/// normal deserializer, including its own unknown-field restrictions. +struct KnownFields(Value); + +impl<'de> Deserializer<'de> for KnownFields { + type Error = serde_json::Error; + + fn deserialize_any>( + self, + visitor: V, + ) -> std::result::Result { + self.0.deserialize_any(visitor) + } + + fn deserialize_struct>( + mut self, + _name: &'static str, + fields: &'static [&'static str], + visitor: V, + ) -> std::result::Result { + if let Value::Object(object) = &mut self.0 { + object.retain(|name, _| fields.contains(&name.as_str())); + } + self.0.deserialize_any(visitor) + } + + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 char str string bytes + byte_buf option unit unit_struct newtype_struct seq tuple tuple_struct + map enum identifier ignored_any + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opa::{BAKED_POLICY_RULES, NetworkInput, OpaEngine}; + + fn valid_policy() -> Value { + serde_json::json!({ + "version": 1, + "network_policies": {"private-policy-name": { + "name": "private-policy-name", + "endpoints": [{ + "host": "private-policy-host.test", "port": 443, + "protocol": "rest", "enforcement": "enforce", "access": "full", + "deny_rules": [{"method": "DELETE", "path": "/**"}] + }], + "binaries": [{"path": "/usr/bin/curl"}] + }} + }) + } + + fn 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 raw_network_leaf_types_reject_before_startup_file_load_and_reload() { + let valid = valid_policy(); + let source = valid.to_string(); + openshell_policy::parse_sandbox_policy(&source).expect("valid typed control"); + let active = OpaEngine::from_strings(BAKED_POLICY_RULES, &source).unwrap(); + let allowed = input("private-policy-host.test"); + let denied = input("unlisted.test"); + let generation = active.current_generation(); + let directory = tempfile::tempdir().unwrap(); + let rego_path = directory.path().join("policy.rego"); + let data_path = directory.path().join("policy.yaml"); + std::fs::write(®o_path, BAKED_POLICY_RULES).unwrap(); + + for (pointer, value) in [ + ( + "/endpoints/0/allow_encoded_slash", + serde_json::json!("true"), + ), + ( + "/endpoints/0/websocket_credential_rewrite", + serde_json::json!("true"), + ), + ( + "/endpoints/0/request_body_credential_rewrite", + serde_json::json!("true"), + ), + ( + "/endpoints/0/allow_uninspected_credentials", + serde_json::json!("true"), + ), + ("/endpoints/0/host", serde_json::json!(["private-value"])), + ("/endpoints/0/port", serde_json::json!("443")), + ("/endpoints/0/ports", serde_json::json!([443, "8443"])), + ("/endpoints/0/tls", serde_json::json!(true)), + ("/endpoints/0/protocol", serde_json::json!(false)), + ("/endpoints/0/allowed_ips", serde_json::json!([7])), + ( + "/endpoints/0/deny_rules/0/method", + serde_json::json!(["DELETE"]), + ), + ( + "/endpoints/0/deny_rules/0/path", + serde_json::json!(["/private-value"]), + ), + ("/endpoints/0/graphql_max_body_bytes", serde_json::json!(-1)), + ("/binaries/0/path", serde_json::json!(["/private-value"])), + ("/name", serde_json::json!(["private-value"])), + ] { + let mut candidate = valid.clone(); + let policy = &mut candidate["network_policies"]["private-policy-name"]; + let (parent, field) = pointer.rsplit_once('/').unwrap(); + policy.pointer_mut(parent).unwrap()[field] = value; + let source = candidate.to_string(); + assert!( + openshell_policy::parse_sandbox_policy(&source).is_err(), + "{pointer}" + ); + let startup = OpaEngine::from_strings(BAKED_POLICY_RULES, &source) + .err() + .expect("malformed leaf must reject at startup"); + std::fs::write(&data_path, &source).unwrap(); + let file = OpaEngine::from_files(®o_path, &data_path) + .err() + .expect("malformed leaf must reject from files"); + let reload = active + .reload(BAKED_POLICY_RULES, &source) + .expect_err("malformed leaf must reject on reload"); + for error in [startup, file, reload] { + for rendered in [ + error.to_string(), + format!("{error:?}"), + format!("{error:#}"), + ] { + assert!(rendered.len() <= 512, "{pointer}: {rendered}"); + assert!(!rendered.contains("private-"), "{pointer}: {rendered}"); + } + } + assert_eq!(active.current_generation(), generation, "{pointer}"); + assert!( + active.evaluate_network(&allowed).unwrap().allowed, + "{pointer}" + ); + assert!( + !active.evaluate_network(&denied).unwrap().allowed, + "{pointer}" + ); + } + } + + #[test] + fn raw_network_validation_retains_runtime_forms_and_custom_data() { + let mut data = valid_policy(); + data.as_object_mut().unwrap().remove("version"); + data["custom_rego_data"] = serde_json::json!({"private": [1, 2]}); + let endpoint = &mut data["network_policies"]["private-policy-name"]["endpoints"][0]; + endpoint["allow_encoded_slash"] = true.into(); + endpoint["provider_credentialed"] = true.into(); + endpoint["advisor_proposed"] = true.into(); + endpoint["policy_hash"] = "private-hash".into(); + endpoint["endpoint_id"] = "private-endpoint".into(); + endpoint["custom_metadata"] = serde_json::json!([null, {"extra": true}]); + endpoint["deny_rules"][0]["query"] = serde_json::json!({ + "repo": {"glob": "private/*"}, "name": "", "org": {"any": ["one", "two"]} + }); + let original = data.clone(); + validate_network_settings(&data).unwrap(); + 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")) + .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"); + assert_eq!( + endpoint["custom_metadata"], + original["network_policies"]["private-policy-name"]["endpoints"][0]["custom_metadata"] + ); + + let mut null_query = valid_policy(); + null_query["network_policies"]["private-policy-name"]["endpoints"][0]["deny_rules"][0]["query"] = + Value::Null; + OpaEngine::from_strings(BAKED_POLICY_RULES, &null_query.to_string()).unwrap(); + + for protocol in ["json-rpc", "mcp"] { + let mut data = valid_policy(); + let endpoint = &mut data["network_policies"]["private-policy-name"]["endpoints"][0]; + endpoint["protocol"] = protocol.into(); + endpoint.as_object_mut().unwrap().remove("access"); + endpoint.as_object_mut().unwrap().remove("deny_rules"); + endpoint["json_rpc_max_body_bytes"] = 4096.into(); + endpoint["rules"] = serde_json::json!([{"allow": {"method": "tools/call"}}]); + if protocol == "mcp" { + endpoint["mcp_versions"] = serde_json::json!(["2025-11-25"]); + endpoint["mcp_strict_tool_names"] = false.into(); + endpoint["mcp_allow_all_known_mcp_methods"] = true.into(); + // Non-strict MCP names require an exact tool selector; glob + // syntax is permitted only when strict names are enabled. + endpoint["rules"][0]["allow"]["params"] = + serde_json::json!({"name": {"glob": "read_tool"}}); + } + OpaEngine::from_strings(BAKED_POLICY_RULES, &data.to_string()).unwrap(); + } + } + + #[test] + fn raw_network_runtime_fields_and_rule_scalars_reject_malformed_values() { + for (field, value) in [ + ("json_rpc_max_body_bytes", serde_json::json!("4096")), + ("mcp_strict_tool_names", serde_json::json!("false")), + ("mcp_allow_all_known_mcp_methods", Value::Null), + ("mcp_versions", serde_json::json!([42])), + ("provider_credentialed", serde_json::json!("false")), + ("advisor_proposed", serde_json::json!(1)), + ("endpoint_id", serde_json::json!(["id"])), + ("policy_hash", serde_json::json!(false)), + ] { + let mut data = valid_policy(); + data["network_policies"]["private-policy-name"]["endpoints"][0][field] = value; + assert!( + OpaEngine::from_strings(BAKED_POLICY_RULES, &data.to_string()).is_err(), + "{field}" + ); + } + for nested in [true, false] { + for (field, value) in [ + ("method", serde_json::json!(["GET"])), + ("path", serde_json::json!(["/**"])), + ("command", serde_json::json!(true)), + ("operation_name", serde_json::json!(42)), + ("fields", serde_json::json!([42])), + ("query", serde_json::json!({"name": {"glob": 1}})), + ] { + let mut data = valid_policy(); + let endpoint = &mut data["network_policies"]["private-policy-name"]["endpoints"][0]; + endpoint.as_object_mut().unwrap().remove("access"); + let mut rule = serde_json::json!({"method": "GET", "path": "/**"}); + rule[field] = value; + endpoint["rules"] = if nested { + serde_json::json!([{"allow": rule}]) + } else { + serde_json::json!([rule]) + }; + assert!( + OpaEngine::from_strings(BAKED_POLICY_RULES, &data.to_string()).is_err(), + "{nested}: {field}" + ); + } + } + } +} diff --git a/docs/how-it-works/policies/schema.mdx b/docs/how-it-works/policies/schema.mdx index 2dea2b082..8296abbc8 100644 --- a/docs/how-it-works/policies/schema.mdx +++ b/docs/how-it-works/policies/schema.mdx @@ -50,6 +50,8 @@ that are not listed are inaccessible. When the effective policy has at least one network rule, OpenShell also adds the baseline paths described in [Default Policy](/how-it-works/policies/default-policy#baseline-filesystem-paths). +These defaults and the filesystem path restrictions below also apply when the supervisor or standalone proxy loads local policy files directly. Local loading enforces the same non-root process identities as typed policy. It rejects invalid filesystem, Landlock, and process field types, unknown fields in those sections, and explicit `null` JSON-RPC or MCP options. Network settings must also use the declared types: for example, `allow_encoded_slash: "true"` and an array-valued deny-rule `method` are rejected. A rejected reload keeps the active OPA policy and its generation unchanged. + Each path must be absolute, must not contain `..`, and must not exceed 4096 bytes. `read_write` cannot contain `/`. A policy can list at most 256 paths.