mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
fix(policy): validate raw OPA settings and redact startup errors (#3788)
* test(policy): reproduce raw OPA loading gaps against the typed schema The supervisor loads a sandbox policy in two ways: through the typed schema (parse_sandbox_policy, then from_proto) or directly into OPA (from_strings and from_files). The raw path fills in defaults where the typed schema is strict, so the same policy text can produce a different sandbox configuration, or load when it should be rejected. Add two regression tests that fail on the current code: - An empty filesystem_policy loads with include_workdir true through raw OPA and false through the typed schema. An absent stanza gives true on both paths and must keep doing so. - Raw OPA accepts a string include_workdir, a non-string read_only entry, an unknown Landlock compatibility and an explicit null json_rpc, with or without a version key. The typed schema rejects each. Every case has a valid twin that both paths must accept. A follow-up change makes raw loading apply the typed schema's rules. Refs #3092. Signed-off-by: Shiju <shiju@nvidia.com> * fix(policy): align raw OPA loading with typed settings Validate raw filesystem, Landlock, and process settings with the canonical authored schema before normalization. Preserve the absent filesystem default while applying the present-stanza default, and canonicalize valid Landlock enum representations before runtime evaluation. Reject explicit null JSON-RPC options through the shared parser. Preserve versionless and runtime OPA data, and keep rejected reloads from replacing the active policy or advancing its generation. Add raw-versus-typed, file-loader, and rejected-reload regressions and document the local loading contract. Refs #3092. Signed-off-by: Shiju <shiju@nvidia.com> * fix(policy): validate raw OPA settings and redact startup errors Validate raw network fields through the authored schema before normalization. Preserve custom Rego data and supported runtime forms. Apply the shared filesystem path checks and non-root identity predicate to raw static settings. Discard authored Rego source and nested errors from static configuration evaluation. Cover malformed inputs, valid controls, file loading, and rejected reloads retaining active decisions and generation. Refs #3092. Signed-off-by: Shiju <shiju@nvidia.com> * test(policy): satisfy unit-returning assertion lint Terminate the two error-assertion match arms with semicolons, as required by Clippy. Preserve the existing checks and runtime behavior. Refs #3092. Signed-off-by: Shiju <shiju@nvidia.com> --------- Signed-off-by: Shiju <shiju@nvidia.com>
This commit is contained in:
Generated
+1
@@ -4841,6 +4841,7 @@ dependencies = [
|
||||
"openshell-isolation-interface",
|
||||
"openshell-ocsf",
|
||||
"openshell-policy",
|
||||
"openshell-policy-schema",
|
||||
"openshell-supervisor-middleware",
|
||||
"openshell-supervisor-middleware-builtins",
|
||||
"prost-types",
|
||||
|
||||
@@ -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<PolicyViolation>> {
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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::<AuthoredProcessPolicy>(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();
|
||||
|
||||
@@ -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::<NetworkPolicyRule>(fields, "invalid network policy settings")?;
|
||||
|
||||
for binary in array_entries(policy, "binaries") {
|
||||
validate_known::<NetworkBinary>(
|
||||
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::<NetworkEndpoint>(fields, diagnostic)?;
|
||||
|
||||
validate_renamed::<JsonRpcConfig>(
|
||||
endpoint,
|
||||
&[("json_rpc_max_body_bytes", "max_body_bytes")],
|
||||
diagnostic,
|
||||
)?;
|
||||
validate_renamed::<McpConfig>(
|
||||
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::<L7Allow>(fields, diagnostic)?;
|
||||
}
|
||||
for rule in array_entries(endpoint, "deny_rules") {
|
||||
let mut fields = object_copy(rule)?;
|
||||
adapt_matchers(&mut fields);
|
||||
validate_known::<L7DenyRule>(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<Map<String, Value>> {
|
||||
value.as_object().cloned().ok_or_else(|| {
|
||||
miette::miette!("L7 policy validation failed: invalid L7 policy configuration")
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_renamed<T: DeserializeOwned>(
|
||||
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::<T>(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<String, Value>) {
|
||||
// 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<T: DeserializeOwned>(
|
||||
fields: Map<String, Value>,
|
||||
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<V: serde::de::Visitor<'de>>(
|
||||
self,
|
||||
visitor: V,
|
||||
) -> std::result::Result<V::Value, Self::Error> {
|
||||
self.0.deserialize_any(visitor)
|
||||
}
|
||||
|
||||
fn deserialize_struct<V: serde::de::Visitor<'de>>(
|
||||
mut self,
|
||||
_name: &'static str,
|
||||
fields: &'static [&'static str],
|
||||
visitor: V,
|
||||
) -> std::result::Result<V::Value, Self::Error> {
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user