Files
OpenShell/crates/openshell-policy/src/lib.rs
T
Andrew White 2d108818f8 fix(policy): avoid panic truncating multi-byte UTF-8 paths for display (#2448)
truncate_for_display sliced at a fixed byte index (&s[..77]), which
panics when the index is not a char boundary. A policy with an
over-long filesystem path containing multi-byte characters crashed
the sandbox supervisor / OPA policy loader instead of producing the
intended FieldTooLong violation.

Back off to the nearest char boundary before slicing, and add
regression tests.

Signed-off-by: Andrew White <andrewh@cdw.com>
2026-07-27 14:40:26 +00:00

3476 lines
120 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Shared sandbox policy parsing and defaults for `OpenShell`.
//!
//! Provides bidirectional YAML↔proto conversion for sandbox policies.
//!
//! The serde types here are the **single canonical representation** of the YAML
//! policy schema. Both parsing (YAML→proto) and serialization (proto→YAML) use
//! these types, ensuring round-trip fidelity.
mod compose;
mod l7_validate;
mod merge;
mod middleware;
use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::path::Path;
use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::proto::{
FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule,
LandlockPolicy, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy,
SandboxPolicy,
};
use serde::{Deserialize, Serialize};
pub use compose::{
PROVIDER_RULE_NAME_PREFIX, ProviderPolicyLayer, compose_effective_policy,
is_provider_rule_name, provider_rule_name, strip_provider_rule_names,
};
pub use l7_validate::{L7EndpointFields, L7Protocol, validate_l7_endpoint_semantics};
pub use merge::{
PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, generated_rule_name,
merge_policy, policy_covers_rule,
};
pub use middleware::middleware_host_matches;
pub use middleware::validate_json as validate_network_middleware_json;
pub use middleware::validate_json_with_config as validate_network_middleware_json_with_config;
// ---------------------------------------------------------------------------
// YAML serde types (canonical — used for both parsing and serialization)
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PolicyFile {
version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
filesystem_policy: Option<FilesystemDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
landlock: Option<LandlockDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
process: Option<ProcessDef>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
network_policies: BTreeMap<String, NetworkPolicyRuleDef>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
network_middlewares: BTreeMap<String, middleware::NetworkMiddlewareConfigDef>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FilesystemDef {
#[serde(default)]
include_workdir: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
read_only: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
read_write: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct LandlockDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
compatibility: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProcessDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
run_as_user: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
run_as_group: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkPolicyRuleDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
endpoints: Vec<NetworkEndpointDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
binaries: Vec<NetworkBinaryDef>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkEndpointDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
host: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
path: String,
/// Single port (backwards compat). Mutually exclusive with `ports`.
/// Uses `u16` to reject invalid values >65535 at parse time.
#[serde(default, skip_serializing_if = "is_zero")]
port: u16,
/// Multiple ports. When non-empty, this endpoint covers all listed ports.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
ports: Vec<u16>,
#[serde(default, skip_serializing_if = "String::is_empty")]
protocol: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
tls: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
enforcement: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
access: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
rules: Vec<L7RuleDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
allowed_ips: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
deny_rules: Vec<L7DenyRuleDef>,
/// When true, percent-encoded `/` (`%2F`) is preserved in path segments
/// rather than rejected by the L7 path canonicalizer. Required for
/// upstreams like GitLab that embed `%2F` in namespaced resource paths.
/// Defaults to false (strict).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
allow_encoded_slash: bool,
/// When true, client-to-server WebSocket text messages on this REST
/// endpoint rewrite credential placeholders after an allowed 101 upgrade.
/// Defaults to false.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
websocket_credential_rewrite: bool,
/// When true, supported textual REST request bodies rewrite credential
/// placeholders before forwarding upstream. Defaults to false.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
request_body_credential_rewrite: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
persisted_queries: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
graphql_persisted_queries: BTreeMap<String, GraphqlOperationDef>,
#[serde(default, skip_serializing_if = "is_zero_u32")]
graphql_max_body_bytes: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
credential_signing: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
signing_service: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
signing_region: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
json_rpc: Option<JsonRpcConfigDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mcp: Option<McpConfigDef>,
}
// Signature dictated by serde's `skip_serializing_if`, which requires `&T`.
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero(v: &u16) -> bool {
*v == 0
}
// Signature dictated by serde's `skip_serializing_if`, which requires `&T`.
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonRpcConfigDef {
#[serde(default, skip_serializing_if = "is_zero_u32")]
max_body_bytes: u32,
}
fn json_rpc_config_from_proto(max_body_bytes: u32) -> Option<JsonRpcConfigDef> {
(max_body_bytes > 0).then_some(JsonRpcConfigDef { max_body_bytes })
}
// MCP rides the same HTTP/JSON-RPC inspection machinery at runtime, but it
// gets its own policy stanza so user-authored YAML can name the primary
// protocol instead of treating MCP as generic JSON-RPC.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct McpConfigDef {
#[serde(default, skip_serializing_if = "is_zero_u32")]
max_body_bytes: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
strict_tool_names: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
allow_all_known_mcp_methods: Option<bool>,
}
fn mcp_config_from_proto(max_body_bytes: u32, mcp: Option<&McpOptions>) -> Option<McpConfigDef> {
let strict_tool_names = mcp.and_then(|config| config.strict_tool_names);
let allow_all_known_mcp_methods = mcp.and_then(|config| config.allow_all_known_mcp_methods);
(max_body_bytes > 0 || strict_tool_names.is_some() || allow_all_known_mcp_methods.is_some())
.then_some(McpConfigDef {
max_body_bytes,
strict_tool_names,
allow_all_known_mcp_methods,
})
}
/// Nested L7 config stanzas accepted by the YAML policy schema.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum L7ConfigStanza {
JsonRpc,
Mcp,
}
impl L7ConfigStanza {
pub const ALL: [Self; 2] = [Self::JsonRpc, Self::Mcp];
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::JsonRpc => "json_rpc",
Self::Mcp => "mcp",
}
}
}
/// Parse an L7 nested config stanza and return the flattened runtime fields
/// consumed by the supervisor policy engine.
///
/// The stanza schema stays tied to this crate's canonical serde definitions, so
/// adding a new supported field requires updating this conversion next to the
/// type that parses it.
pub fn l7_config_alias_runtime_fields(
stanza: L7ConfigStanza,
value: serde_json::Value,
) -> Result<Vec<(&'static str, serde_json::Value)>> {
match stanza {
L7ConfigStanza::JsonRpc => {
let JsonRpcConfigDef { max_body_bytes } = serde_json::from_value(value)
.map_err(|error| miette::miette!("invalid json_rpc config: {error}"))?;
let mut fields = Vec::new();
if max_body_bytes > 0 {
fields.push(("json_rpc_max_body_bytes", serde_json::json!(max_body_bytes)));
}
Ok(fields)
}
L7ConfigStanza::Mcp => {
let McpConfigDef {
max_body_bytes,
strict_tool_names,
allow_all_known_mcp_methods,
} = serde_json::from_value(value)
.map_err(|error| miette::miette!("invalid mcp config: {error}"))?;
let mut fields = Vec::new();
if max_body_bytes > 0 {
fields.push(("json_rpc_max_body_bytes", serde_json::json!(max_body_bytes)));
}
if let Some(strict_tool_names) = strict_tool_names {
fields.push((
"mcp_strict_tool_names",
serde_json::json!(strict_tool_names),
));
}
if let Some(allow_all_known_mcp_methods) = allow_all_known_mcp_methods {
fields.push((
"mcp_allow_all_known_mcp_methods",
serde_json::json!(allow_all_known_mcp_methods),
));
}
Ok(fields)
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct GraphqlOperationDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
operation_type: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
operation_name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
fields: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct L7RuleDef {
allow: L7AllowDef,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct L7AllowDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
method: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
path: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
command: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
query: BTreeMap<String, QueryMatcherDef>,
#[serde(default, skip_serializing_if = "String::is_empty")]
operation_type: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
operation_name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
fields: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<QueryMatcherDef>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
params: BTreeMap<String, ParamMatcherDef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum QueryMatcherDef {
// Short form: `query: { repo: "NVIDIA/*" }`.
Glob(String),
// Expanded form: `query: { repo: { any: ["NVIDIA/*", "openai/*"] } }`.
Any(QueryAnyDef),
}
// MCP params can be authored as nested maps in YAML, but the runtime matcher
// map remains flat so the Rego policy can share query-param matching.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum ParamMatcherDef {
Matcher(QueryMatcherDef),
Object(BTreeMap<String, Self>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct QueryAnyDef {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
any: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct L7DenyRuleDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
method: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
path: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
command: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
query: BTreeMap<String, QueryMatcherDef>,
#[serde(default, skip_serializing_if = "String::is_empty")]
operation_type: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
operation_name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
fields: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<QueryMatcherDef>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
params: BTreeMap<String, ParamMatcherDef>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkBinaryDef {
path: String,
/// Deprecated: ignored. Kept for backward compat with existing YAML files.
#[serde(default, skip_serializing)]
#[allow(dead_code)]
harness: bool,
}
// ---------------------------------------------------------------------------
// YAML → proto conversion
// ---------------------------------------------------------------------------
fn matcher_def_to_proto(matcher: QueryMatcherDef) -> L7QueryMatcher {
match matcher {
QueryMatcherDef::Glob(glob) => L7QueryMatcher { glob, any: vec![] },
QueryMatcherDef::Any(any) => L7QueryMatcher {
glob: String::new(),
any: any.any,
},
}
}
fn matcher_proto_to_def(matcher: L7QueryMatcher) -> QueryMatcherDef {
if matcher.any.is_empty() {
QueryMatcherDef::Glob(matcher.glob)
} else {
QueryMatcherDef::Any(QueryAnyDef { any: matcher.any })
}
}
// Convert MCP params maps into the flat proto/Rego keyspace. Only `name` is
// currently enforced for tools/call, but this keeps the YAML shape compatible
// with any future MCP-owned params selectors.
fn flatten_param_matchers(
params: BTreeMap<String, ParamMatcherDef>,
) -> BTreeMap<String, QueryMatcherDef> {
let mut flattened = BTreeMap::new();
for (key, matcher) in params {
flatten_param_matcher(&key, matcher, &mut flattened);
}
flattened
}
// Walk one params subtree, carrying the flattened dot-path key accumulated so
// far. Leaf matchers are inserted into the map consumed by the runtime policy.
fn flatten_param_matcher(
key: &str,
matcher: ParamMatcherDef,
out: &mut BTreeMap<String, QueryMatcherDef>,
) {
match matcher {
ParamMatcherDef::Matcher(matcher) => {
out.insert(key.to_string(), matcher);
}
ParamMatcherDef::Object(children) => {
for (child_key, child) in children {
let nested_key = format!("{key}.{child_key}");
flatten_param_matcher(&nested_key, child, out);
}
}
}
}
// Convert flat runtime params back to YAML. MCP gets readable nested params
// when the flat keys can be losslessly split. Non-MCP protocols keep flat keys
// only for lossless serialization; generic JSON-RPC validation rejects params
// matchers before enforcement.
fn flat_params_to_def(
protocol: &str,
params: BTreeMap<String, QueryMatcherDef>,
) -> BTreeMap<String, ParamMatcherDef> {
let flat = params.into_iter().collect::<Vec<_>>();
// MCP uses nested YAML for readability. Non-MCP protocols keep the flat
// form for lossless serialization of existing proto data only.
if !is_mcp_protocol(protocol) {
return flat_param_matchers_to_def(flat);
}
let mut nested = BTreeMap::new();
for (key, matcher) in &flat {
if insert_nested_param(&mut nested, key, ParamMatcherDef::Matcher(matcher.clone())).is_err()
{
return flat_param_matchers_to_def(flat);
}
}
nested
}
fn flat_param_matchers_to_def(
params: Vec<(String, QueryMatcherDef)>,
) -> BTreeMap<String, ParamMatcherDef> {
params
.into_iter()
.map(|(key, matcher)| (key, ParamMatcherDef::Matcher(matcher)))
.collect()
}
// Build one nested params path from a flat key. Collisions such as `a` and
// `a.b` cannot round-trip as nested YAML, so callers fall back to the flat map.
fn insert_nested_param(
root: &mut BTreeMap<String, ParamMatcherDef>,
key: &str,
matcher: ParamMatcherDef,
) -> Result<(), ()> {
let mut parts = key.split('.').peekable();
let Some(first) = parts.next() else {
return Err(());
};
if parts.peek().is_none() {
root.insert(first.to_string(), matcher);
return Ok(());
}
let child = root
.entry(first.to_string())
.or_insert_with(|| ParamMatcherDef::Object(BTreeMap::new()));
let ParamMatcherDef::Object(children) = child else {
return Err(());
};
let remainder = parts.collect::<Vec<_>>().join(".");
insert_nested_param(children, &remainder, matcher)
}
// MCP `tool` is a policy convenience for the standard `tools/call` params.name
// field. When the endpoint method profile is enabled, authored tool selectors
// can omit method and are normalized to tools/call internally. Tool arguments
// intentionally have no policy matcher yet, so every allowed tool call permits
// all argument payloads by default.
fn params_with_tool(
mut params: BTreeMap<String, ParamMatcherDef>,
tool: Option<QueryMatcherDef>,
) -> BTreeMap<String, ParamMatcherDef> {
if let Some(tool) = tool {
params
.entry("name".to_string())
.or_insert_with(|| ParamMatcherDef::Matcher(tool));
}
params
}
fn allow_def_to_proto(_protocol: &str, allow: L7AllowDef) -> L7Allow {
let params = flatten_param_matchers(params_with_tool(allow.params, allow.tool));
L7Allow {
method: allow.method,
path: allow.path,
command: allow.command,
operation_type: allow.operation_type,
operation_name: allow.operation_name,
fields: allow.fields,
query: allow
.query
.into_iter()
.map(|(key, matcher)| (key, matcher_def_to_proto(matcher)))
.collect(),
params: params
.into_iter()
.map(|(key, matcher)| (key, matcher_def_to_proto(matcher)))
.collect(),
}
}
fn deny_def_to_proto(_protocol: &str, deny: L7DenyRuleDef) -> L7DenyRule {
let params = flatten_param_matchers(params_with_tool(deny.params, deny.tool));
L7DenyRule {
method: deny.method,
path: deny.path,
command: deny.command,
operation_type: deny.operation_type,
operation_name: deny.operation_name,
fields: deny.fields,
query: deny
.query
.into_iter()
.map(|(key, matcher)| (key, matcher_def_to_proto(matcher)))
.collect(),
params: params
.into_iter()
.map(|(key, matcher)| (key, matcher_def_to_proto(matcher)))
.collect(),
}
}
fn json_rpc_max_body_bytes(json_rpc: &Option<JsonRpcConfigDef>, mcp: &Option<McpConfigDef>) -> u32 {
// The proto has one JSON-RPC-family body limit. Prefer the MCP stanza when
// present because MCP policies should not need a shadow `json_rpc` block.
mcp.as_ref().map_or_else(
|| json_rpc.as_ref().map_or(0, |config| config.max_body_bytes),
|config| config.max_body_bytes,
)
}
fn mcp_strict_tool_names(mcp: &Option<McpConfigDef>) -> Option<bool> {
mcp.as_ref().and_then(|config| config.strict_tool_names)
}
fn mcp_allow_all_known_mcp_methods(mcp: &Option<McpConfigDef>) -> Option<bool> {
mcp.as_ref()
.and_then(|config| config.allow_all_known_mcp_methods)
}
fn mcp_options(mcp: &Option<McpConfigDef>) -> Option<McpOptions> {
let strict_tool_names = mcp_strict_tool_names(mcp);
let allow_all_known_mcp_methods = mcp_allow_all_known_mcp_methods(mcp);
(strict_tool_names.is_some() || allow_all_known_mcp_methods.is_some()).then_some(McpOptions {
strict_tool_names,
allow_all_known_mcp_methods,
})
}
fn is_mcp_protocol(protocol: &str) -> bool {
protocol.eq_ignore_ascii_case("mcp")
}
fn split_tool_param(
protocol: &str,
params: BTreeMap<String, QueryMatcherDef>,
) -> (Option<QueryMatcherDef>, BTreeMap<String, QueryMatcherDef>) {
// Only MCP has the tool-name convention. Non-MCP protocols preserve proto
// params on round-trip without inventing MCP semantics.
if !is_mcp_protocol(protocol) {
return (None, params);
}
let mut params = params;
let tool = params.remove("name");
(tool, params)
}
fn allow_proto_to_def(
protocol: &str,
allow: L7Allow,
mcp_allow_all_known_mcp_methods: bool,
) -> L7AllowDef {
let params: BTreeMap<String, QueryMatcherDef> = allow
.params
.into_iter()
.map(|(key, matcher)| (key, matcher_proto_to_def(matcher)))
.collect();
let (tool, params) = split_tool_param(protocol, params);
let params = flat_params_to_def(protocol, params);
let method = yaml_mcp_method(
protocol,
&allow.method,
tool.is_some(),
mcp_allow_all_known_mcp_methods,
);
L7AllowDef {
method,
path: allow.path,
command: allow.command,
query: allow
.query
.into_iter()
.map(|(key, matcher)| (key, matcher_proto_to_def(matcher)))
.collect(),
operation_type: allow.operation_type,
operation_name: allow.operation_name,
fields: allow.fields,
tool,
params,
}
}
fn deny_proto_to_def(
protocol: &str,
deny: &L7DenyRule,
mcp_allow_all_known_mcp_methods: bool,
) -> L7DenyRuleDef {
let params: BTreeMap<String, QueryMatcherDef> = deny
.params
.iter()
.map(|(key, matcher)| (key.clone(), matcher_proto_to_def(matcher.clone())))
.collect();
let (tool, params) = split_tool_param(protocol, params);
let params = flat_params_to_def(protocol, params);
let method = yaml_mcp_method(
protocol,
&deny.method,
tool.is_some(),
mcp_allow_all_known_mcp_methods,
);
L7DenyRuleDef {
method,
path: deny.path.clone(),
command: deny.command.clone(),
query: deny
.query
.iter()
.map(|(key, matcher)| (key.clone(), matcher_proto_to_def(matcher.clone())))
.collect(),
operation_type: deny.operation_type.clone(),
operation_name: deny.operation_name.clone(),
fields: deny.fields.clone(),
tool,
params,
}
}
fn yaml_mcp_method(
protocol: &str,
method: &str,
has_tool: bool,
mcp_allow_all_known_mcp_methods: bool,
) -> String {
if is_mcp_protocol(protocol) {
if !has_tool && method == "*" {
return String::new();
}
if has_tool && method == "tools/call" && mcp_allow_all_known_mcp_methods {
return String::new();
}
}
method.to_string()
}
fn to_proto(raw: PolicyFile) -> Result<SandboxPolicy> {
let network_middlewares = middleware::into_proto(raw.network_middlewares)
.into_diagnostic()
.wrap_err("failed to convert network middleware config")?;
let network_policies = raw
.network_policies
.into_iter()
.map(|(key, rule)| {
let proto_rule = NetworkPolicyRule {
name: if rule.name.is_empty() {
key.clone()
} else {
rule.name
},
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<u32> = 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: e.tls,
enforcement: e.enforcement,
access: e.access,
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,
// 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,
json_rpc_max_body_bytes: json_rpc_max_body_bytes(&e.json_rpc, &e.mcp),
mcp: mcp_options(&e.mcp),
}
})
.collect(),
binaries: rule
.binaries
.into_iter()
.map(|b| NetworkBinary {
path: b.path,
..Default::default()
})
.collect(),
};
(key, proto_rule)
})
.collect();
Ok(SandboxPolicy {
version: raw.version,
filesystem: raw.filesystem_policy.map(|fs| FilesystemPolicy {
include_workdir: fs.include_workdir,
read_only: fs.read_only,
read_write: fs.read_write,
}),
landlock: raw.landlock.map(|ll| LandlockPolicy {
compatibility: ll.compatibility,
}),
process: raw.process.map(|p| ProcessPolicy {
run_as_user: p.run_as_user,
run_as_group: p.run_as_group,
}),
network_policies,
network_middlewares,
})
}
// ---------------------------------------------------------------------------
// Proto → YAML conversion
// ---------------------------------------------------------------------------
fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
let filesystem_policy = policy.filesystem.as_ref().map(|fs| FilesystemDef {
include_workdir: fs.include_workdir,
read_only: fs.read_only.clone(),
read_write: fs.read_write.clone(),
});
let landlock = policy.landlock.as_ref().map(|ll| LandlockDef {
compatibility: ll.compatibility.clone(),
});
let process = policy.process.as_ref().and_then(|p| {
if p.run_as_user.is_empty() && p.run_as_group.is_empty() {
None
} else {
Some(ProcessDef {
run_as_user: p.run_as_user.clone(),
run_as_group: p.run_as_group.clone(),
})
}
});
let network_policies = policy
.network_policies
.iter()
.map(|(key, rule)| {
let yaml_rule = NetworkPolicyRuleDef {
name: rule.name.clone(),
endpoints: rule
.endpoints
.iter()
.map(|e| {
// Use compact form: if ports has exactly 1 element,
// emit port (scalar). If >1, emit ports (array).
// Proto uses u32; YAML uses u16. Clamp at boundary.
let clamp = |v: u32| -> u16 { v.min(65535) as u16 };
let (port, ports) = if e.ports.len() > 1 {
(0, e.ports.iter().map(|&p| clamp(p)).collect())
} else {
(clamp(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<L7DenyRuleDef> = 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)
};
NetworkEndpointDef {
host: e.host.clone(),
path: e.path.clone(),
port,
ports,
protocol,
tls: e.tls.clone(),
enforcement: e.enforcement.clone(),
access: e.access.clone(),
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,
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(),
json_rpc,
mcp,
}
})
.collect(),
binaries: rule
.binaries
.iter()
.map(|b| NetworkBinaryDef {
path: b.path.clone(),
harness: false,
})
.collect(),
};
(key.clone(), yaml_rule)
})
.collect();
let network_middlewares = middleware::from_proto(&policy.network_middlewares);
PolicyFile {
version: policy.version,
filesystem_policy,
landlock,
process,
network_policies,
network_middlewares,
}
}
// ---------------------------------------------------------------------------
// Sandbox UID/GID constants
// ---------------------------------------------------------------------------
/// Minimum accepted UID for sandbox process identity.
/// UIDs below this are reserved for system users and are rejected.
pub const MIN_SANDBOX_UID: u32 = 1000;
/// Maximum accepted UID for sandbox process identity.
/// UIDs above this exceed typical OS limits and are rejected.
pub const MAX_SANDBOX_UID: u32 = 2_000_000_000;
/// The literal string value accepted as a valid sandbox user/group name.
const SANDBOX_NAME: &str = "sandbox";
/// Validate whether a process identity field value is acceptable.
///
/// Accepts either the literal `"sandbox"` or a numeric UID/GID parsed as
/// `u32` within the range `[MIN_SANDBOX_UID, MAX_SANDBOX_UID]`.
///
/// Rejects:
/// - The empty string (callers should use `ensure_sandbox_process_identity`
/// to fill defaults before validation)
/// - UID 0 or values below `MIN_SANDBOX_UID`
/// - Values above `MAX_SANDBOX_UID`
/// - Non-numeric strings other than `"sandbox"` (e.g. `"root"`, `"nobody"`)
pub fn is_valid_sandbox_identity(value: &str) -> bool {
if value == SANDBOX_NAME {
return true;
}
value
.parse::<u32>()
.is_ok_and(|uid| (MIN_SANDBOX_UID..=MAX_SANDBOX_UID).contains(&uid))
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Parse a sandbox policy from a YAML string.
pub fn parse_sandbox_policy(yaml: &str) -> Result<SandboxPolicy> {
let raw: PolicyFile = serde_yml::from_str(yaml)
.into_diagnostic()
.wrap_err("failed to parse sandbox policy YAML")?;
to_proto(raw)
}
/// 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`.
pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result<String> {
let yaml_repr = from_proto(policy);
serde_yml::to_string(&yaml_repr)
.into_diagnostic()
.wrap_err("failed to serialize policy to YAML")
}
/// Convert a proto sandbox policy into the canonical policy JSON representation.
///
/// The shape mirrors the YAML schema used by [`serialize_sandbox_policy`], so
/// automation can use the same documented field names in either format.
pub fn sandbox_policy_to_json_value(policy: &SandboxPolicy) -> Result<serde_json::Value> {
let json_repr = from_proto(policy);
serde_json::to_value(&json_repr)
.into_diagnostic()
.wrap_err("failed to serialize policy to JSON")
}
/// Serialize a proto sandbox policy to a pretty-printed JSON string.
pub fn serialize_sandbox_policy_json(policy: &SandboxPolicy) -> Result<String> {
let json_repr = sandbox_policy_to_json_value(policy)?;
serde_json::to_string_pretty(&json_repr)
.into_diagnostic()
.wrap_err("failed to serialize policy to JSON")
}
/// Load a sandbox policy from an explicit source.
///
/// Resolution order:
/// 1. `cli_path` argument (e.g. from a `--policy` flag)
/// 2. `OPENSHELL_SANDBOX_POLICY` environment variable
///
/// Returns `Ok(None)` when no policy source is configured, allowing the
/// caller to omit the policy and let the server / sandbox apply its own
/// default.
pub fn load_sandbox_policy(cli_path: Option<&str>) -> Result<Option<SandboxPolicy>> {
let contents = if let Some(p) = cli_path {
let path = Path::new(p);
std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))?
} else if let Ok(policy_path) = std::env::var("OPENSHELL_SANDBOX_POLICY") {
let path = Path::new(&policy_path);
std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))?
} else {
return Ok(None);
};
parse_sandbox_policy(&contents).map(Some)
}
/// Well-known path where a sandbox container image can ship a policy YAML file.
///
/// When the gateway provides no policy at sandbox creation time, the sandbox
/// supervisor probes this path before falling back to the restrictive default.
pub const CONTAINER_POLICY_PATH: &str = "/etc/openshell/policy.yaml";
/// Legacy path used before the navigator → openshell rename.
///
/// Existing community sandbox images still ship their policy at this path.
/// The sandbox supervisor tries [`CONTAINER_POLICY_PATH`] first, then falls
/// back to this legacy path for backward compatibility.
pub const LEGACY_CONTAINER_POLICY_PATH: &str = "/etc/navigator/policy.yaml";
/// Return a restrictive default policy suitable for sandboxes that have no
/// explicit policy configured.
///
/// This policy grants filesystem access to standard system paths, runs as the
/// `sandbox` user, enables Landlock in best-effort mode, and **blocks all
/// network access** (no network policies, no inference routing).
pub fn restrictive_default_policy() -> SandboxPolicy {
SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
include_workdir: true,
read_only: vec![
"/usr".into(),
"/lib".into(),
"/proc".into(),
"/dev/urandom".into(),
"/app".into(),
"/etc".into(),
"/var/log".into(),
],
read_write: vec!["/sandbox".into(), "/tmp".into(), "/dev/null".into()],
}),
landlock: Some(LandlockPolicy {
compatibility: "best_effort".into(),
}),
process: Some(ProcessPolicy {
run_as_user: "sandbox".into(),
run_as_group: "sandbox".into(),
}),
network_policies: HashMap::new(),
network_middlewares: HashMap::default(),
}
}
/// Ensure the policy has `run_as_user: sandbox` and `run_as_group: sandbox`.
///
/// If the process section is missing, or either field is empty, this fills in
/// the required `"sandbox"` value. Call this before validation so that
/// policies without an explicit process section get the correct default.
pub fn ensure_sandbox_process_identity(policy: &mut SandboxPolicy) {
let process = policy.process.get_or_insert_with(ProcessPolicy::default);
if process.run_as_user.is_empty() {
process.run_as_user = "sandbox".into();
}
if process.run_as_group.is_empty() {
process.run_as_group = "sandbox".into();
}
}
// ---------------------------------------------------------------------------
// Policy safety validation
// ---------------------------------------------------------------------------
/// Maximum number of filesystem paths (`read_only` + `read_write` combined).
const MAX_FILESYSTEM_PATHS: usize = 256;
/// Maximum length of any single filesystem path string.
const MAX_PATH_LENGTH: usize = 4096;
/// A safety violation found in a sandbox policy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyViolation {
/// `run_as_user` or `run_as_group` is not "sandbox".
InvalidProcessIdentity { field: &'static str, value: String },
/// A filesystem path contains `..` components.
PathTraversal { path: String },
/// A filesystem path is not absolute (does not start with `/`).
RelativePath { path: String },
/// A read-write filesystem path is overly broad (e.g. `/`).
OverlyBroadPath { path: String },
/// A filesystem path exceeds the maximum allowed length.
FieldTooLong { path: String, length: usize },
/// Too many filesystem paths in the policy.
TooManyPaths { count: usize },
/// A network endpoint uses a TLD wildcard (e.g. `*.com`).
TldWildcard { policy_name: String, host: String },
/// A network endpoint uses a wildcard shape that does not match runtime semantics.
InvalidHostWildcard { policy_name: String, host: String },
/// `credential_signing` is set but `signing_service` is missing.
MissingSigningService { policy_name: String, host: String },
/// `credential_signing` has an unrecognized value.
UnknownCredentialSigning {
policy_name: String,
host: String,
value: String,
},
/// `credential_signing` and `request_body_credential_rewrite` are both set.
CredentialSigningWithBodyRewrite { policy_name: String, host: String },
/// A middleware configuration is structurally invalid.
InvalidMiddlewareConfig { name: String, reason: String },
/// Too many middleware configurations are attached to one policy.
TooManyMiddlewareConfigs { count: usize },
/// Two middleware configurations use the same execution order.
DuplicateMiddlewareOrder {
order: i32,
first_name: String,
second_name: String,
},
/// Too many include and exclude patterns are attached to one middleware.
TooManyMiddlewareSelectorPatterns { name: String, count: usize },
/// A middleware selector conflicts with an endpoint that skips TLS inspection.
MiddlewareTlsSkipConflict {
middleware_name: String,
policy_name: String,
host: String,
},
}
impl fmt::Display for PolicyViolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidProcessIdentity { field, value } => {
write!(
f,
"{field} must be 'sandbox' or a numeric UID/GID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}], got '{value}'"
)
}
Self::PathTraversal { path } => {
write!(f, "path contains '..' traversal component: {path}")
}
Self::RelativePath { path } => {
write!(f, "path must be absolute (start with '/'): {path}")
}
Self::OverlyBroadPath { path } => {
write!(f, "read-write path is overly broad: {path}")
}
Self::FieldTooLong { path, length } => {
write!(
f,
"path exceeds maximum length ({length} > {MAX_PATH_LENGTH}): {path}"
)
}
Self::TooManyPaths { count } => {
write!(
f,
"too many filesystem paths ({count} > {MAX_FILESYSTEM_PATHS})"
)
}
Self::TldWildcard { policy_name, host } => {
write!(
f,
"network policy '{policy_name}': TLD wildcard '{host}' is not allowed; \
use subdomain wildcards like '*.example.com' instead"
)
}
Self::InvalidHostWildcard { policy_name, host } => {
write!(
f,
"network policy '{policy_name}': invalid host wildcard '{host}'; \
middle DNS label wildcards must be the entire label '*' and recursive '**' \
is only allowed as the entire first label"
)
}
Self::MissingSigningService { policy_name, host } => {
write!(
f,
"network policy '{policy_name}': endpoint '{host}' has credential_signing \
set but signing_service is empty"
)
}
Self::UnknownCredentialSigning {
policy_name,
host,
value,
} => {
write!(
f,
"network policy '{policy_name}': endpoint '{host}' has unrecognized \
credential_signing value '{value}' (expected sigv4, sigv4:body, or sigv4:no_body)"
)
}
Self::CredentialSigningWithBodyRewrite { policy_name, host } => {
write!(
f,
"network policy '{policy_name}': endpoint '{host}' has both credential_signing \
and request_body_credential_rewrite set; these options are mutually exclusive"
)
}
Self::InvalidMiddlewareConfig { name, reason } => {
write!(f, "middleware config '{name}' is invalid: {reason}")
}
Self::TooManyMiddlewareConfigs { count } => {
write!(
f,
"too many middleware configs ({count} > {})",
openshell_core::middleware::MAX_MIDDLEWARE_CONFIGS
)
}
Self::DuplicateMiddlewareOrder {
order,
first_name,
second_name,
} => {
write!(
f,
"middleware configs '{first_name}' and '{second_name}' use duplicate order {order}"
)
}
Self::TooManyMiddlewareSelectorPatterns { name, count } => {
write!(
f,
"middleware config '{name}' has too many selector patterns ({count} > {})",
openshell_core::middleware::MAX_MIDDLEWARE_SELECTOR_PATTERNS
)
}
Self::MiddlewareTlsSkipConflict {
middleware_name,
policy_name,
host,
} => {
write!(
f,
"middleware config '{middleware_name}' selects network policy \
'{policy_name}' tls: skip endpoint '{host}'"
)
}
}
}
}
/// Validate that a sandbox policy does not contain unsafe content.
///
/// Returns `Ok(())` if the policy is safe, or `Err(violations)` listing all
/// safety violations found. Callers decide how to handle violations (hard
/// error vs. logged warning).
///
/// Checks performed:
/// - `run_as_user` / `run_as_group` must be "sandbox"
/// - Filesystem paths must be absolute (start with `/`)
/// - Filesystem paths must not contain `..` components
/// - Read-write paths must not be overly broad (just `/`)
/// - Individual path lengths must not exceed [`MAX_PATH_LENGTH`]
/// - Total path count must not exceed [`MAX_FILESYSTEM_PATHS`]
/// - Network endpoint hosts must not use TLD wildcards (e.g. `*.com`)
/// - Middleware names, implementations, failure modes, selectors, and built-in
/// configurations must be valid
/// - Middleware selectors must not match endpoints that skip TLS inspection
pub fn validate_sandbox_policy(
policy: &SandboxPolicy,
) -> std::result::Result<(), Vec<PolicyViolation>> {
let mut violations = Vec::new();
// Check process identity — must be "sandbox" or a numeric UID/GID
// within the acceptable sandbox range.
// `ensure_sandbox_process_identity` should be called before this to
// fill in defaults; any invalid value is rejected.
if let Some(ref process) = policy.process {
if !is_valid_sandbox_identity(&process.run_as_user) {
violations.push(PolicyViolation::InvalidProcessIdentity {
field: "run_as_user",
value: process.run_as_user.clone(),
});
}
if !is_valid_sandbox_identity(&process.run_as_group) {
violations.push(PolicyViolation::InvalidProcessIdentity {
field: "run_as_group",
value: process.run_as_group.clone(),
});
}
}
// 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(),
});
}
}
}
// Check network policy endpoint hosts for TLD wildcards.
for (key, rule) in &policy.network_policies {
let name = if rule.name.is_empty() {
key.clone()
} else {
rule.name.clone()
};
for ep in &rule.endpoints {
if ep.host.contains('*') && (ep.host.starts_with("*.") || ep.host.starts_with("**.")) {
let label_count = ep.host.split('.').count();
if label_count <= 2 {
violations.push(PolicyViolation::TldWildcard {
policy_name: name.clone(),
host: ep.host.clone(),
});
}
}
if host_wildcard_shape_invalid(&ep.host) {
violations.push(PolicyViolation::InvalidHostWildcard {
policy_name: name.clone(),
host: ep.host.clone(),
});
}
if !ep.credential_signing.is_empty()
&& !matches!(
ep.credential_signing.as_str(),
"sigv4" | "sigv4:body" | "sigv4:no_body"
)
{
violations.push(PolicyViolation::UnknownCredentialSigning {
policy_name: name.clone(),
host: ep.host.clone(),
value: ep.credential_signing.clone(),
});
}
if !ep.credential_signing.is_empty() && ep.signing_service.is_empty() {
violations.push(PolicyViolation::MissingSigningService {
policy_name: name.clone(),
host: ep.host.clone(),
});
}
if !ep.credential_signing.is_empty() && ep.request_body_credential_rewrite {
violations.push(PolicyViolation::CredentialSigningWithBodyRewrite {
policy_name: name.clone(),
host: ep.host.clone(),
});
}
}
}
violations.extend(middleware::validate(policy));
if violations.is_empty() {
Ok(())
} else {
Err(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 != "*"))
}
/// Truncate a string for safe inclusion in error messages.
fn truncate_for_display(s: &str) -> String {
if s.len() <= 80 {
s.to_string()
} else {
// Back off to a char boundary: slicing at a fixed byte index panics
// on multi-byte UTF-8 (e.g. non-ASCII characters in policy paths).
let mut end = 77;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
}
}
/// Normalize a filesystem path by collapsing redundant separators
/// and removing trailing slashes, without requiring the path to exist on disk.
///
/// This is a lexical normalization only — it does NOT resolve symlinks or
/// check the filesystem.
///
/// Re-exported from `openshell-core` so existing call sites
/// (`openshell_policy::normalize_path`) keep resolving.
pub use openshell_core::paths::normalize_path;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_for_display_handles_multi_byte_utf8_without_panicking() {
// Byte index 77 falls inside the multi-byte 'é'.
let s = format!("/{}{}", "a".repeat(75), "é".repeat(100));
let truncated = truncate_for_display(&s);
assert!(truncated.ends_with("..."));
assert!(truncated.len() <= 80);
}
#[test]
fn truncate_for_display_leaves_short_strings_untouched() {
let s = "short path";
assert_eq!(truncate_for_display(s), s);
}
/// Verify that the serialized YAML uses `filesystem_policy` (not
/// `filesystem`) so it can be fed back to `parse_sandbox_policy`.
#[test]
fn serialized_yaml_uses_filesystem_policy_key() {
let proto = restrictive_default_policy();
let yaml = serialize_sandbox_policy(&proto).expect("serialize failed");
assert!(
yaml.contains("filesystem_policy:"),
"expected `filesystem_policy:` in YAML output, got:\n{yaml}"
);
assert!(
!yaml.contains("\nfilesystem:"),
"unexpected bare `filesystem:` key in YAML output"
);
}
/// Verify that JSON serialization uses the same canonical schema keys as YAML.
#[test]
fn serialized_json_uses_policy_schema_keys() {
let proto = parse_sandbox_policy(
r"
version: 1
network_policies:
github:
endpoints:
- host: api.github.com
port: 443
protocol: https
binaries:
- path: /usr/bin/curl
",
)
.expect("parse failed");
let json = sandbox_policy_to_json_value(&proto).expect("serialize failed");
assert_eq!(json["version"], serde_json::json!(1));
assert!(json.get("filesystem").is_none());
assert!(json.get("network_policies").is_some());
}
/// Verify that `allowed_ips` survives the round-trip.
#[test]
fn round_trip_preserves_allowed_ips() {
let yaml = r#"
version: 1
network_policies:
internal:
name: internal
endpoints:
- host: db.internal.corp
port: 5432
allowed_ips:
- "10.0.5.0/24"
- "10.0.6.0/24"
binaries:
- path: /usr/bin/curl
"#;
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let ep1 = &proto1.network_policies["internal"].endpoints[0];
let ep2 = &proto2.network_policies["internal"].endpoints[0];
assert_eq!(ep1.allowed_ips, ep2.allowed_ips);
assert_eq!(ep1.allowed_ips, vec!["10.0.5.0/24", "10.0.6.0/24"]);
}
/// Verify that the network policy `name` field survives the round-trip.
#[test]
fn round_trip_preserves_policy_name() {
let yaml = r"
version: 1
network_policies:
my_api:
name: my-custom-api-name
endpoints:
- host: api.example.com
port: 443
binaries:
- path: /usr/bin/curl
";
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
assert_eq!(proto1.network_policies["my_api"].name, "my-custom-api-name");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
assert_eq!(proto2.network_policies["my_api"].name, "my-custom-api-name");
}
#[test]
fn round_trip_preserves_network_middlewares() {
let yaml = r#"
version: 1
network_middlewares:
global-redactor:
name: Global redactor
middleware: openshell/regex
order: 20
on_error: fail_open
endpoints:
include: ["api.example.com", "*.service.test"]
exclude: ["internal.example.com"]
config:
mode: redact
secondary-redactor:
middleware: openshell/regex
endpoints:
include: ["api.example.com"]
network_policies:
api:
name: api
endpoints:
- host: api.example.com
port: 443
protocol: rest
binaries:
- path: /usr/bin/curl
"#;
let proto = parse_sandbox_policy(yaml).expect("parse failed");
assert_eq!(proto.network_middlewares.len(), 2);
let redactor = &proto.network_middlewares["global-redactor"];
assert_eq!(redactor.name, "Global redactor");
assert_eq!(redactor.middleware, "openshell/regex");
assert_eq!(redactor.order, 20);
assert_eq!(redactor.on_error, "fail_open");
assert_eq!(
redactor.endpoints.as_ref().expect("selector").include,
vec!["api.example.com", "*.service.test"]
);
assert_eq!(
redactor.endpoints.as_ref().expect("selector").exclude,
vec!["internal.example.com"]
);
assert_eq!(
redactor
.config
.as_ref()
.expect("config")
.fields
.get("mode")
.and_then(|value| value.kind.as_ref()),
Some(&prost_types::value::Kind::StringValue("redact".into()))
);
assert_eq!(
proto.network_middlewares["secondary-redactor"].name,
"secondary-redactor"
);
let yaml_out = serialize_sandbox_policy(&proto).expect("serialize failed");
let reparsed = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
assert_eq!(reparsed.network_middlewares, proto.network_middlewares);
}
#[test]
fn restrictive_default_has_no_network_policies() {
let policy = restrictive_default_policy();
assert!(
policy.network_policies.is_empty(),
"restrictive default must block all network"
);
}
#[test]
fn restrictive_default_has_filesystem_policy() {
let policy = restrictive_default_policy();
let fs = policy.filesystem.expect("must have filesystem policy");
assert!(fs.include_workdir);
assert!(
fs.read_only.iter().any(|p| p == "/usr"),
"read_only should contain /usr"
);
assert!(
fs.read_write.iter().any(|p| p == "/sandbox"),
"read_write should contain /sandbox"
);
assert!(
fs.read_write.iter().any(|p| p == "/tmp"),
"read_write should contain /tmp"
);
}
#[test]
fn restrictive_default_has_process_identity() {
let policy = restrictive_default_policy();
let proc = policy.process.expect("must have process policy");
assert_eq!(proc.run_as_user, "sandbox");
assert_eq!(proc.run_as_group, "sandbox");
}
#[test]
fn restrictive_default_has_landlock() {
let policy = restrictive_default_policy();
let ll = policy.landlock.expect("must have landlock policy");
assert_eq!(ll.compatibility, "best_effort");
}
#[test]
fn restrictive_default_version_is_one() {
let policy = restrictive_default_policy();
assert_eq!(policy.version, 1);
}
#[test]
fn parse_minimal_policy_yaml() {
let yaml = "version: 1\n";
let policy = parse_sandbox_policy(yaml).expect("should parse");
assert_eq!(policy.version, 1);
assert!(policy.network_policies.is_empty());
assert!(policy.filesystem.is_none());
}
#[test]
fn parse_policy_with_network_rules() {
let yaml = r"
version: 1
network_policies:
test:
name: test_policy
endpoints:
- { host: example.com, port: 443 }
binaries:
- { path: /usr/bin/curl }
";
let policy = parse_sandbox_policy(yaml).expect("should parse");
assert_eq!(policy.network_policies.len(), 1);
let rule = &policy.network_policies["test"];
assert_eq!(rule.name, "test_policy");
assert_eq!(rule.endpoints.len(), 1);
assert_eq!(rule.endpoints[0].host, "example.com");
assert_eq!(rule.endpoints[0].port, 443);
assert_eq!(rule.binaries.len(), 1);
assert_eq!(rule.binaries[0].path, "/usr/bin/curl");
}
#[test]
fn parse_l7_query_matchers_and_round_trip() {
let yaml = r#"
version: 1
network_policies:
query_test:
name: query_test
endpoints:
- host: api.example.com
port: 8080
protocol: rest
rules:
- allow:
method: GET
path: /download
query:
slug: "my-*"
tag:
any: ["foo-*", "bar-*"]
binaries:
- path: /usr/bin/curl
"#;
let proto = parse_sandbox_policy(yaml).expect("parse failed");
let allow = proto.network_policies["query_test"].endpoints[0].rules[0]
.allow
.as_ref()
.expect("allow");
assert_eq!(allow.query["slug"].glob, "my-*");
assert_eq!(allow.query["slug"].any, Vec::<String>::new());
assert_eq!(allow.query["tag"].any, vec!["foo-*", "bar-*"]);
assert!(allow.query["tag"].glob.is_empty());
let yaml_out = serialize_sandbox_policy(&proto).expect("serialize failed");
let proto_round_trip = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let allow_round_trip = proto_round_trip.network_policies["query_test"].endpoints[0].rules
[0]
.allow
.as_ref()
.expect("allow");
assert_eq!(allow_round_trip.query["slug"].glob, "my-*");
assert_eq!(allow_round_trip.query["tag"].any, vec!["foo-*", "bar-*"]);
}
#[test]
fn parse_rejects_unknown_fields() {
let yaml = "version: 1\nbogus_field: true\n";
assert!(parse_sandbox_policy(yaml).is_err());
}
#[test]
fn parse_rejects_middleware_attachments_on_network_policies_and_endpoints() {
let policy_attachment = r"
version: 1
network_policies:
api:
middleware: [redact]
endpoints:
- host: api.example.com
port: 443
";
assert!(parse_sandbox_policy(policy_attachment).is_err());
let endpoint_attachment = r"
version: 1
network_policies:
api:
endpoints:
- host: api.example.com
port: 443
middleware: [redact]
";
assert!(parse_sandbox_policy(endpoint_attachment).is_err());
}
#[test]
fn l7_config_stanza_runtime_fields_use_canonical_schema() {
let fields = l7_config_alias_runtime_fields(
L7ConfigStanza::Mcp,
serde_json::json!({
"max_body_bytes": 131_072,
"strict_tool_names": false,
"allow_all_known_mcp_methods": true
}),
)
.expect("valid mcp config");
assert_eq!(
fields,
vec![
("json_rpc_max_body_bytes", serde_json::json!(131_072)),
("mcp_strict_tool_names", serde_json::json!(false)),
("mcp_allow_all_known_mcp_methods", serde_json::json!(true)),
]
);
let err = l7_config_alias_runtime_fields(
L7ConfigStanza::JsonRpc,
serde_json::json!({"on_parse_error": "allow"}),
)
.expect_err("unknown JSON-RPC config fields must be rejected");
assert!(err.to_string().contains("on_parse_error"));
}
#[test]
fn ensure_sandbox_process_identity_fills_defaults() {
let mut policy = restrictive_default_policy();
policy.process = None;
ensure_sandbox_process_identity(&mut policy);
let proc = policy.process.unwrap();
assert_eq!(proc.run_as_user, "sandbox");
assert_eq!(proc.run_as_group, "sandbox");
}
#[test]
fn ensure_sandbox_process_identity_fills_empty_strings() {
let mut policy = restrictive_default_policy();
policy.process = Some(ProcessPolicy {
run_as_user: String::new(),
run_as_group: String::new(),
});
ensure_sandbox_process_identity(&mut policy);
let proc = policy.process.unwrap();
assert_eq!(proc.run_as_user, "sandbox");
assert_eq!(proc.run_as_group, "sandbox");
}
#[test]
fn ensure_sandbox_process_identity_preserves_sandbox() {
let mut policy = restrictive_default_policy();
ensure_sandbox_process_identity(&mut policy);
let proc = policy.process.unwrap();
assert_eq!(proc.run_as_user, "sandbox");
assert_eq!(proc.run_as_group, "sandbox");
}
#[test]
fn container_policy_path_is_expected() {
assert_eq!(CONTAINER_POLICY_PATH, "/etc/openshell/policy.yaml");
}
#[test]
fn legacy_container_policy_path_is_expected() {
assert_eq!(LEGACY_CONTAINER_POLICY_PATH, "/etc/navigator/policy.yaml");
}
// ---- Policy validation tests ----
fn middleware_config(implementation: &str) -> openshell_core::proto::NetworkMiddlewareConfig {
openshell_core::proto::NetworkMiddlewareConfig {
name: String::new(),
middleware: implementation.into(),
order: 0,
config: None,
on_error: String::new(),
endpoints: Some(openshell_core::proto::MiddlewareEndpointSelector {
include: vec!["api.example.com".into()],
exclude: Vec::new(),
}),
}
}
fn add_middleware(
policy: &mut SandboxPolicy,
name: &str,
config: openshell_core::proto::NetworkMiddlewareConfig,
) {
policy.network_middlewares.insert(name.into(), config);
}
#[test]
fn structural_validation_defers_implementation_owned_config() {
let mut policy = restrictive_default_policy();
let mut middleware = middleware_config("openshell/future");
middleware.config = Some(
openshell_core::proto_struct::json_object_to_struct(
std::iter::once(("implementation_field".into(), serde_json::json!(42))).collect(),
)
.unwrap(),
);
policy
.network_middlewares
.insert("future".into(), middleware);
validate_sandbox_policy(&policy)
.expect("generic policy validation must not select installed implementations");
}
#[test]
fn json_validation_delegates_implementation_owned_config() {
let data = serde_json::json!({
"network_middlewares": {
"future": {
"middleware": "openshell/future",
"config": {"implementation_field": 42},
"endpoints": {"include": ["api.example.com"]}
}
}
});
let violations =
validate_network_middleware_json_with_config(&data, |implementation, _config| {
Err(format!("{implementation} is not installed"))
})
.expect("parse middleware policy");
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::InvalidMiddlewareConfig { name, reason }
if name == "future" && reason.contains("not installed")
)));
}
#[test]
fn json_validation_skips_config_callbacks_when_middleware_count_is_invalid() {
let configs: serde_json::Map<String, serde_json::Value> = (0
..=openshell_core::middleware::MAX_MIDDLEWARE_CONFIGS)
.map(|index| {
(
format!("middleware-{index}"),
serde_json::json!({
"middleware": "openshell/regex",
"endpoints": {"include": ["api.example.com"]}
}),
)
})
.collect();
let data = serde_json::json!({"network_middlewares": configs});
let calls = std::cell::Cell::new(0usize);
let violations = validate_network_middleware_json_with_config(&data, |_, _| {
calls.set(calls.get() + 1);
Ok(())
})
.expect("parse middleware policy");
assert_eq!(calls.get(), 0, "invalid policy must not invoke services");
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::TooManyMiddlewareConfigs { count }
if *count == openshell_core::middleware::MAX_MIDDLEWARE_CONFIGS + 1
)));
}
#[test]
fn validate_rejects_root_run_as_user() {
let mut policy = restrictive_default_policy();
policy.process = Some(ProcessPolicy {
run_as_user: "root".into(),
run_as_group: "sandbox".into(),
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(violations.iter().any(|v| matches!(
v,
PolicyViolation::InvalidProcessIdentity {
field: "run_as_user",
..
}
)));
}
#[test]
fn validate_rejects_uid_zero() {
let mut policy = restrictive_default_policy();
policy.process = Some(ProcessPolicy {
run_as_user: "0".into(),
run_as_group: "0".into(),
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert_eq!(violations.len(), 2);
}
#[test]
fn validate_rejects_invalid_middleware_control_fields() {
let cases = [
(
"",
middleware_config("openshell/regex"),
"name must not be empty",
),
(
"redactor",
middleware_config(""),
"middleware must not be empty",
),
(
"redactor",
{
let mut middleware = middleware_config("openshell/regex");
middleware.on_error = "maybe".into();
middleware
},
"invalid on_error",
),
(
"redactor",
{
let mut middleware = middleware_config("openshell/regex");
middleware.endpoints = None;
middleware
},
"endpoint selector is required",
),
(
"redactor",
{
let mut middleware = middleware_config("openshell/regex");
middleware.endpoints.as_mut().unwrap().include.clear();
middleware
},
"must include at least one host pattern",
),
];
for (name, middleware, expected) in cases {
let mut policy = restrictive_default_policy();
add_middleware(&mut policy, name, middleware);
let errors = validate_sandbox_policy(&policy)
.expect_err("invalid middleware must be rejected")
.into_iter()
.map(|violation| violation.to_string())
.collect::<Vec<_>>()
.join("; ");
assert!(
errors.contains(expected),
"expected {expected:?} in {errors:?}"
);
}
}
#[test]
fn validate_rejects_duplicate_middleware_orders() {
let mut policy = restrictive_default_policy();
let mut alpha = middleware_config("openshell/regex");
alpha.order = 10;
add_middleware(&mut policy, "alpha", alpha);
let mut beta = middleware_config("openshell/regex");
beta.order = 10;
add_middleware(&mut policy, "beta", beta);
let violations = validate_sandbox_policy(&policy).expect_err("duplicate order");
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::DuplicateMiddlewareOrder {
order: 10,
first_name,
second_name,
} if first_name == "alpha" && second_name == "beta"
)));
}
#[test]
fn validate_accepts_maximum_middleware_configs() {
let mut policy = restrictive_default_policy();
for index in 0..openshell_core::middleware::MAX_MIDDLEWARE_CONFIGS {
let name = format!("middleware-{index}");
let mut config = middleware_config("openshell/regex");
config.order = i32::try_from(index).unwrap();
add_middleware(&mut policy, &name, config);
}
validate_sandbox_policy(&policy).expect("maximum middleware config count");
}
#[test]
fn validate_rejects_middleware_config_over_capacity() {
let mut policy = restrictive_default_policy();
for index in 0..=openshell_core::middleware::MAX_MIDDLEWARE_CONFIGS {
let name = format!("middleware-{index}");
let mut config = middleware_config("openshell/regex");
config.order = i32::try_from(index).unwrap();
add_middleware(&mut policy, &name, config);
}
let violations = validate_sandbox_policy(&policy).expect_err("config count over capacity");
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::TooManyMiddlewareConfigs { count }
if *count == openshell_core::middleware::MAX_MIDDLEWARE_CONFIGS + 1
)));
}
#[test]
fn validate_accepts_maximum_middleware_selector_patterns() {
let mut policy = restrictive_default_policy();
let mut middleware = middleware_config("openshell/regex");
let selector = middleware.endpoints.as_mut().expect("selector");
selector.exclude = vec![
"excluded.example.com".into();
openshell_core::middleware::MAX_MIDDLEWARE_SELECTOR_PATTERNS - 1
];
add_middleware(&mut policy, "redactor", middleware);
validate_sandbox_policy(&policy).expect("maximum selector pattern count");
}
#[test]
fn validate_rejects_middleware_selector_patterns_over_capacity() {
let mut policy = restrictive_default_policy();
let mut middleware = middleware_config("openshell/regex");
let selector = middleware.endpoints.as_mut().expect("selector");
selector.exclude = vec![
"excluded.example.com".into();
openshell_core::middleware::MAX_MIDDLEWARE_SELECTOR_PATTERNS
];
add_middleware(&mut policy, "redactor", middleware);
let violations =
validate_sandbox_policy(&policy).expect_err("selector patterns over capacity");
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::TooManyMiddlewareSelectorPatterns { name, count }
if name == "redactor"
&& *count
== openshell_core::middleware::MAX_MIDDLEWARE_SELECTOR_PATTERNS + 1
)));
}
#[test]
fn validate_rejects_malformed_middleware_selector_patterns() {
let mut policy = restrictive_default_policy();
let mut middleware = middleware_config("openshell/regex");
middleware.endpoints.as_mut().unwrap().include = vec!["api[.example.com".into()];
add_middleware(&mut policy, "redactor", middleware);
let errors = validate_sandbox_policy(&policy)
.expect_err("malformed selector")
.into_iter()
.map(|violation| violation.to_string())
.collect::<Vec<_>>()
.join("; ");
assert!(errors.contains("invalid host pattern"), "{errors}");
}
#[test]
fn middleware_host_selector_matching_is_case_insensitive() {
assert!(middleware_host_matches("*.Example.COM", "API.example.com").unwrap());
assert!(!middleware_host_matches("*.example.com", "example.com").unwrap());
assert!(!middleware_host_matches("*.example.com", "deep.api.example.com").unwrap());
assert!(middleware_host_matches("**.example.com", "deep.api.example.com").unwrap());
assert!(!middleware_host_matches("**.example.com", "example.com").unwrap());
}
#[test]
fn validate_rejects_middleware_selector_matching_tls_skip_endpoint() {
let mut policy = restrictive_default_policy();
add_middleware(
&mut policy,
"redactor",
middleware_config("openshell/regex"),
);
policy.network_policies.insert(
"api".into(),
NetworkPolicyRule {
name: "api".into(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".into(),
port: 443,
tls: "skip".into(),
..Default::default()
}],
binaries: Vec::new(),
},
);
let violations = validate_sandbox_policy(&policy).expect_err("tls skip conflict");
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::MiddlewareTlsSkipConflict {
middleware_name,
policy_name,
host,
} if middleware_name == "redactor" && policy_name == "api" && host == "api.example.com"
)));
}
#[test]
fn validate_accepts_fail_open_middleware_selector_matching_tls_skip_endpoint() {
let mut policy = restrictive_default_policy();
let mut middleware = middleware_config("openshell/regex");
middleware.on_error = "fail_open".into();
add_middleware(&mut policy, "redactor", middleware);
policy.network_policies.insert(
"api".into(),
NetworkPolicyRule {
name: "api".into(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".into(),
port: 443,
tls: "skip".into(),
..Default::default()
}],
binaries: Vec::new(),
},
);
validate_sandbox_policy(&policy)
.expect("fail-open middleware may select uninspectable tls: skip traffic");
}
#[test]
fn validate_rejects_explicit_fail_closed_middleware_on_tls_skip_endpoint() {
let mut policy = restrictive_default_policy();
let mut middleware = middleware_config("openshell/regex");
middleware.on_error = "fail_closed".into();
add_middleware(&mut policy, "redactor", middleware);
policy.network_policies.insert(
"api".into(),
NetworkPolicyRule {
name: "api".into(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".into(),
port: 443,
tls: "skip".into(),
..Default::default()
}],
binaries: Vec::new(),
},
);
let violations = validate_sandbox_policy(&policy).expect_err("tls skip conflict");
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::MiddlewareTlsSkipConflict { middleware_name, .. }
if middleware_name == "redactor"
)));
}
#[test]
fn validate_rejects_concrete_selector_overlapping_tls_skip_wildcard() {
let mut policy = restrictive_default_policy();
let mut middleware = middleware_config("openshell/regex");
middleware.endpoints.as_mut().unwrap().include = vec!["api.example.com".into()];
add_middleware(&mut policy, "redactor", middleware);
policy.network_policies.insert(
"api".into(),
NetworkPolicyRule {
name: "api".into(),
endpoints: vec![NetworkEndpoint {
host: "*.example.com".into(),
port: 443,
tls: "skip".into(),
..Default::default()
}],
binaries: Vec::new(),
},
);
let violations = validate_sandbox_policy(&policy).expect_err("tls skip conflict");
assert!(violations.iter().any(|violation| matches!(
violation,
PolicyViolation::MiddlewareTlsSkipConflict {
middleware_name,
policy_name,
host,
} if middleware_name == "redactor" && policy_name == "api" && host == "*.example.com"
)));
}
#[test]
fn validate_rejects_non_sandbox_user() {
let mut policy = restrictive_default_policy();
policy.process = Some(ProcessPolicy {
run_as_user: "nobody".into(),
run_as_group: "nogroup".into(),
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert_eq!(violations.len(), 2);
assert!(
violations
.iter()
.all(|v| matches!(v, PolicyViolation::InvalidProcessIdentity { .. }))
);
}
#[test]
fn validate_accepts_sandbox_identity() {
let policy = restrictive_default_policy();
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_rejects_path_traversal() {
let mut policy = restrictive_default_policy();
policy.filesystem = Some(FilesystemPolicy {
include_workdir: true,
read_only: vec!["/usr/../etc/shadow".into()],
read_write: vec!["/tmp".into()],
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::PathTraversal { .. }))
);
}
#[test]
fn validate_rejects_relative_paths() {
let mut policy = restrictive_default_policy();
policy.filesystem = Some(FilesystemPolicy {
include_workdir: true,
read_only: vec!["usr/lib".into()],
read_write: vec!["/tmp".into()],
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::RelativePath { .. }))
);
}
#[test]
fn validate_rejects_overly_broad_read_write_path() {
let mut policy = restrictive_default_policy();
policy.filesystem = Some(FilesystemPolicy {
include_workdir: true,
read_only: vec!["/usr".into()],
read_write: vec!["/".into()],
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::OverlyBroadPath { .. }))
);
}
#[test]
fn validate_accepts_valid_policy() {
let policy = restrictive_default_policy();
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_accepts_empty_process() {
let policy = SandboxPolicy {
version: 1,
process: None,
filesystem: None,
landlock: None,
network_policies: HashMap::new(),
network_middlewares: HashMap::default(),
};
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_rejects_empty_run_as_user() {
let mut policy = restrictive_default_policy();
policy.process = Some(ProcessPolicy {
run_as_user: String::new(),
run_as_group: String::new(),
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert_eq!(violations.len(), 2);
}
#[test]
fn validate_rejects_too_many_paths() {
let mut policy = restrictive_default_policy();
let many_paths: Vec<String> = (0..300).map(|i| format!("/path/{i}")).collect();
policy.filesystem = Some(FilesystemPolicy {
include_workdir: true,
read_only: many_paths,
read_write: vec!["/tmp".into()],
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::TooManyPaths { .. }))
);
}
#[test]
fn validate_rejects_path_too_long() {
let mut policy = restrictive_default_policy();
let long_path = format!("/{}", "a".repeat(5000));
policy.filesystem = Some(FilesystemPolicy {
include_workdir: true,
read_only: vec![long_path],
read_write: vec!["/tmp".into()],
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::FieldTooLong { .. }))
);
}
#[test]
fn validate_rejects_tld_wildcard() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"bad".into(),
NetworkPolicyRule {
name: "bad-rule".into(),
endpoints: vec![NetworkEndpoint {
host: "*.com".into(),
port: 443,
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::TldWildcard { .. }))
);
}
#[test]
fn validate_rejects_double_star_tld_wildcard() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"bad".into(),
NetworkPolicyRule {
name: "bad-rule".into(),
endpoints: vec![NetworkEndpoint {
host: "**.org".into(),
port: 443,
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::TldWildcard { .. }))
);
}
#[test]
fn validate_rejects_all_host_star_wildcards() {
for host in ["*", "**"] {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"bad".into(),
NetworkPolicyRule {
name: "bad-rule".into(),
endpoints: vec![NetworkEndpoint {
host: host.into(),
port: 443,
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::InvalidHostWildcard { .. })),
"expected bare host wildcard {host:?} to be rejected, got {violations:?}"
);
}
}
#[test]
fn validate_accepts_subdomain_wildcard() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"ok".into(),
NetworkPolicyRule {
name: "ok-rule".into(),
endpoints: vec![NetworkEndpoint {
host: "*.example.com".into(),
port: 443,
..Default::default()
}],
..Default::default()
},
);
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_accepts_middle_label_star_wildcard() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"ok".into(),
NetworkPolicyRule {
name: "ok-rule".into(),
endpoints: vec![NetworkEndpoint {
host: "*.s3.*.amazonaws.com".into(),
port: 443,
..Default::default()
}],
..Default::default()
},
);
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_rejects_partial_middle_label_wildcard() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"bad".into(),
NetworkPolicyRule {
name: "bad-rule".into(),
endpoints: vec![NetworkEndpoint {
host: "*.s3.us-*.amazonaws.com".into(),
port: 443,
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::InvalidHostWildcard { .. }))
);
}
#[test]
fn validate_accepts_explicit_domain() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"ok".into(),
NetworkPolicyRule {
name: "ok-rule".into(),
endpoints: vec![NetworkEndpoint {
host: "example.com".into(),
port: 443,
..Default::default()
}],
..Default::default()
},
);
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_rejects_credential_signing_without_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "bedrock".into(),
endpoints: vec![NetworkEndpoint {
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4".into(),
signing_service: String::new(),
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::MissingSigningService { .. }))
);
}
#[test]
fn validate_accepts_credential_signing_with_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "bedrock".into(),
endpoints: vec![NetworkEndpoint {
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4".into(),
signing_service: "bedrock".into(),
..Default::default()
}],
..Default::default()
},
);
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_accepts_sigv4_body_with_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "bedrock".into(),
endpoints: vec![NetworkEndpoint {
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4:body".into(),
signing_service: "bedrock".into(),
..Default::default()
}],
..Default::default()
},
);
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_accepts_sigv4_no_body_with_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "s3".into(),
endpoints: vec![NetworkEndpoint {
host: "s3.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4:no_body".into(),
signing_service: "s3".into(),
..Default::default()
}],
..Default::default()
},
);
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_rejects_sigv4_no_body_without_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "s3".into(),
endpoints: vec![NetworkEndpoint {
host: "s3.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4:no_body".into(),
signing_service: String::new(),
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::MissingSigningService { .. }))
);
}
#[test]
fn validate_rejects_unknown_credential_signing() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "test".into(),
endpoints: vec![NetworkEndpoint {
host: "example.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4_typo".into(),
signing_service: "bedrock".into(),
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::UnknownCredentialSigning { .. }))
);
}
#[test]
fn validate_rejects_credential_signing_with_body_rewrite() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "bedrock".into(),
endpoints: vec![NetworkEndpoint {
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4".into(),
signing_service: "bedrock".into(),
request_body_credential_rewrite: true,
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::CredentialSigningWithBodyRewrite { .. }))
);
}
#[test]
fn normalize_path_collapses_separators() {
assert_eq!(normalize_path("/usr//lib"), "/usr/lib");
assert_eq!(normalize_path("/usr/./lib"), "/usr/lib");
assert_eq!(normalize_path("/tmp/"), "/tmp");
}
#[test]
fn normalize_path_preserves_parent_dir() {
// normalize_path preserves ".." — validation catches it separately
assert_eq!(normalize_path("/usr/../etc"), "/usr/../etc");
}
#[test]
fn policy_violation_display() {
let v = PolicyViolation::InvalidProcessIdentity {
field: "run_as_user",
value: "root".into(),
};
let s = format!("{v}");
assert!(s.contains("root"));
assert!(s.contains("run_as_user"));
assert!(s.contains("sandbox"));
}
// ---- is_valid_sandbox_identity tests ----
#[test]
fn valid_identity_accepts_sandbox() {
assert!(is_valid_sandbox_identity("sandbox"));
}
#[test]
fn valid_identity_accepts_numeric_uid_in_range() {
assert!(is_valid_sandbox_identity("1000"));
assert!(is_valid_sandbox_identity("50000"));
assert!(is_valid_sandbox_identity("1000660000"));
}
#[test]
fn valid_identity_accepts_boundary_uids() {
assert!(is_valid_sandbox_identity(&MIN_SANDBOX_UID.to_string()));
assert!(is_valid_sandbox_identity(&MAX_SANDBOX_UID.to_string()));
}
#[test]
fn valid_identity_rejects_zero() {
assert!(!is_valid_sandbox_identity("0"));
}
#[test]
fn valid_identity_rejects_system_uids_below_min() {
assert!(!is_valid_sandbox_identity("999"));
assert!(!is_valid_sandbox_identity("100"));
assert!(!is_valid_sandbox_identity("1"));
}
#[test]
fn valid_identity_rejects_uid_above_max() {
assert!(!is_valid_sandbox_identity(
&MAX_SANDBOX_UID.saturating_add(1).to_string()
));
}
#[test]
fn valid_identity_rejects_non_numeric_names() {
assert!(!is_valid_sandbox_identity("root"));
assert!(!is_valid_sandbox_identity("nobody"));
assert!(!is_valid_sandbox_identity("user"));
}
#[test]
fn valid_identity_rejects_empty_string() {
assert!(!is_valid_sandbox_identity(""));
}
// ---- Policy validation with numeric UIDs ----
#[test]
fn validate_accepts_numeric_uid_in_range() {
let policy = SandboxPolicy {
version: 1,
process: Some(ProcessPolicy {
run_as_user: "1000".into(),
run_as_group: "5000".into(),
}),
filesystem: None,
landlock: None,
network_policies: HashMap::new(),
network_middlewares: HashMap::default(),
};
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_accepts_boundary_uids() {
let policy = SandboxPolicy {
version: 1,
process: Some(ProcessPolicy {
run_as_user: MIN_SANDBOX_UID.to_string(),
run_as_group: MAX_SANDBOX_UID.to_string(),
}),
filesystem: None,
landlock: None,
network_policies: HashMap::new(),
network_middlewares: HashMap::default(),
};
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn validate_rejects_uid_out_of_range_low() {
let mut policy = restrictive_default_policy();
policy.process = Some(ProcessPolicy {
run_as_user: "500".into(),
run_as_group: "sandbox".into(),
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(violations.iter().any(|v| matches!(
v,
PolicyViolation::InvalidProcessIdentity {
field: "run_as_user",
..
}
)));
}
#[test]
fn validate_rejects_uid_out_of_range_high() {
let mut policy = restrictive_default_policy();
policy.process = Some(ProcessPolicy {
run_as_user: (MAX_SANDBOX_UID + 1).to_string(),
run_as_group: "sandbox".into(),
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(violations.iter().any(|v| matches!(
v,
PolicyViolation::InvalidProcessIdentity {
field: "run_as_user",
..
}
)));
}
#[test]
fn validate_rejects_root_string() {
let mut policy = restrictive_default_policy();
policy.process = Some(ProcessPolicy {
run_as_user: "root".into(),
run_as_group: "sandbox".into(),
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(violations.iter().any(|v| matches!(
v,
PolicyViolation::InvalidProcessIdentity {
field: "run_as_user",
..
}
)));
}
#[test]
fn validate_rejects_nobody_string() {
let mut policy = restrictive_default_policy();
policy.process = Some(ProcessPolicy {
run_as_user: "nobody".into(),
run_as_group: "nogroup".into(),
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert_eq!(violations.len(), 2);
}
#[test]
fn validate_accepts_mixed_sandbox_name_and_uid() {
// run_as_user as "sandbox" name, run_as_group as numeric UID
let policy = SandboxPolicy {
version: 1,
process: Some(ProcessPolicy {
run_as_user: "sandbox".into(),
run_as_group: "1000".into(),
}),
filesystem: None,
landlock: None,
network_policies: HashMap::new(),
network_middlewares: HashMap::default(),
};
assert!(validate_sandbox_policy(&policy).is_ok());
}
#[test]
fn policy_violation_display_includes_range() {
let v = PolicyViolation::InvalidProcessIdentity {
field: "run_as_user",
value: "root".into(),
};
let s = format!("{v}");
assert!(s.contains("sandbox"));
assert!(s.contains(&MIN_SANDBOX_UID.to_string()));
assert!(s.contains(&MAX_SANDBOX_UID.to_string()));
assert!(s.contains("root"));
}
// ---- Multi-port and host wildcard tests ----
#[test]
fn parse_ports_array() {
let yaml = r"
version: 1
network_policies:
test:
name: test
endpoints:
- { host: api.example.com, ports: [80, 443] }
binaries:
- { path: /usr/bin/curl }
";
let policy = parse_sandbox_policy(yaml).expect("should parse");
let ep = &policy.network_policies["test"].endpoints[0];
assert_eq!(ep.ports, vec![80, 443]);
// port should be set to first element for backwards compat
assert_eq!(ep.port, 80);
}
#[test]
fn parse_single_port_normalized_to_ports() {
let yaml = r"
version: 1
network_policies:
test:
name: test
endpoints:
- { host: api.example.com, port: 443 }
binaries:
- { path: /usr/bin/curl }
";
let policy = parse_sandbox_policy(yaml).expect("should parse");
let ep = &policy.network_policies["test"].endpoints[0];
assert_eq!(ep.ports, vec![443]);
assert_eq!(ep.port, 443);
}
#[test]
fn round_trip_preserves_endpoint_path() {
let yaml = r#"
version: 1
network_policies:
test:
name: test
endpoints:
- host: api.example.com
port: 443
path: "/graphql"
protocol: graphql
rules:
- allow:
operation_type: query
binaries:
- { path: /usr/bin/curl }
"#;
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let ep1 = &proto1.network_policies["test"].endpoints[0];
let ep2 = &proto2.network_policies["test"].endpoints[0];
assert_eq!(ep1.path, "/graphql");
assert_eq!(ep1.path, ep2.path);
}
#[test]
fn round_trip_preserves_multi_port() {
let yaml = r"
version: 1
network_policies:
test:
name: test
endpoints:
- host: api.example.com
ports:
- 80
- 443
binaries:
- { path: /usr/bin/curl }
";
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let ep1 = &proto1.network_policies["test"].endpoints[0];
let ep2 = &proto2.network_policies["test"].endpoints[0];
assert_eq!(ep1.ports, ep2.ports);
assert_eq!(ep1.ports, vec![80, 443]);
}
#[test]
fn serialize_single_port_uses_compact_form() {
let yaml = r"
version: 1
network_policies:
test:
name: test
endpoints:
- { host: api.example.com, port: 443 }
binaries:
- { path: /usr/bin/curl }
";
let proto = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto).expect("serialize failed");
// Should use compact `port: 443` form, not `ports: [443]`
assert!(
yaml_out.contains("port: 443"),
"Single port should serialize as compact form, got:\n{yaml_out}"
);
assert!(
!yaml_out.contains("ports:"),
"Single port should not produce ports array, got:\n{yaml_out}"
);
}
#[test]
fn parse_wildcard_host() {
let yaml = r#"
version: 1
network_policies:
test:
name: test
endpoints:
- { host: "*.example.com", port: 443 }
binaries:
- { path: /usr/bin/curl }
"#;
let policy = parse_sandbox_policy(yaml).expect("should parse");
let ep = &policy.network_policies["test"].endpoints[0];
assert_eq!(ep.host, "*.example.com");
}
#[test]
fn round_trip_preserves_wildcard_host() {
let yaml = r#"
version: 1
network_policies:
test:
name: test
endpoints:
- host: "*.example.com"
port: 443
binaries:
- { path: /usr/bin/curl }
"#;
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
assert_eq!(
proto1.network_policies["test"].endpoints[0].host,
proto2.network_policies["test"].endpoints[0].host
);
}
#[test]
fn parse_deny_rules_from_yaml() {
let yaml = r#"
version: 1
network_policies:
github:
name: github
endpoints:
- host: api.github.com
port: 443
protocol: rest
access: read-write
deny_rules:
- method: POST
path: "/repos/*/pulls/*/reviews"
- method: PUT
path: "/repos/*/branches/*/protection"
binaries:
- path: /usr/bin/curl
"#;
let proto = parse_sandbox_policy(yaml).expect("parse failed");
let ep = &proto.network_policies["github"].endpoints[0];
assert_eq!(ep.deny_rules.len(), 2);
assert_eq!(ep.deny_rules[0].method, "POST");
assert_eq!(ep.deny_rules[0].path, "/repos/*/pulls/*/reviews");
assert_eq!(ep.deny_rules[1].method, "PUT");
assert_eq!(ep.deny_rules[1].path, "/repos/*/branches/*/protection");
}
#[test]
fn round_trip_preserves_deny_rules() {
let yaml = r#"
version: 1
network_policies:
github:
name: github
endpoints:
- host: api.github.com
port: 443
protocol: rest
access: full
deny_rules:
- method: POST
path: "/repos/*/pulls/*/reviews"
- method: DELETE
path: "/repos/*/branches/*/protection"
query:
force: "true"
binaries:
- path: /usr/bin/curl
"#;
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let ep1 = &proto1.network_policies["github"].endpoints[0];
let ep2 = &proto2.network_policies["github"].endpoints[0];
assert_eq!(ep1.deny_rules.len(), ep2.deny_rules.len());
assert_eq!(ep2.deny_rules[0].method, "POST");
assert_eq!(ep2.deny_rules[0].path, "/repos/*/pulls/*/reviews");
assert_eq!(ep2.deny_rules[1].method, "DELETE");
assert_eq!(ep2.deny_rules[1].query["force"].glob, "true");
}
#[test]
fn parse_deny_rules_with_query_any() {
let yaml = r#"
version: 1
network_policies:
test:
name: test
endpoints:
- host: api.example.com
port: 443
protocol: rest
access: full
deny_rules:
- method: POST
path: /action
query:
type:
any: ["admin-*", "root-*"]
binaries:
- path: /usr/bin/curl
"#;
let proto = parse_sandbox_policy(yaml).expect("parse failed");
let deny = &proto.network_policies["test"].endpoints[0].deny_rules[0];
assert_eq!(deny.query["type"].any, vec!["admin-*", "root-*"]);
}
#[test]
fn round_trip_preserves_graphql_policy_fields() {
let yaml = r"
version: 1
network_policies:
github_graphql:
name: github_graphql
endpoints:
- host: api.github.com
port: 443
protocol: graphql
enforcement: enforce
persisted_queries: allow_registered
graphql_max_body_bytes: 131072
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]
deny_rules:
- operation_type: mutation
fields: [deleteRepository]
binaries:
- path: /usr/bin/curl
";
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let ep = &proto2.network_policies["github_graphql"].endpoints[0];
assert_eq!(ep.protocol, "graphql");
assert_eq!(ep.persisted_queries, "allow_registered");
assert_eq!(ep.graphql_max_body_bytes, 131_072);
assert_eq!(
ep.graphql_persisted_queries["abc123"].operation_type,
"query"
);
assert_eq!(ep.rules[0].allow.as_ref().unwrap().operation_type, "query");
assert_eq!(ep.rules[1].allow.as_ref().unwrap().operation_name, "Issue*");
assert_eq!(ep.deny_rules[0].operation_type, "mutation");
assert_eq!(ep.deny_rules[0].fields, vec!["deleteRepository"]);
}
#[test]
fn round_trip_preserves_json_rpc_max_body_bytes() {
let yaml = r"
version: 1
network_policies:
jsonrpc_api:
name: jsonrpc_api
endpoints:
- host: jsonrpc.example.com
port: 443
protocol: json-rpc
enforcement: enforce
json_rpc:
max_body_bytes: 131072
rules:
- allow:
method: initialize
binaries:
- path: /usr/bin/curl
";
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let ep = &proto2.network_policies["jsonrpc_api"].endpoints[0];
assert_eq!(ep.protocol, "json-rpc");
assert_eq!(ep.json_rpc_max_body_bytes, 131_072);
}
#[test]
fn parse_mcp_rules_to_runtime_fields() {
let yaml = r"
version: 1
network_policies:
mcp:
name: mcp
endpoints:
- host: mcp.example.com
port: 443
path: /mcp
protocol: mcp
enforcement: enforce
mcp:
max_body_bytes: 131072
strict_tool_names: false
rules:
- allow:
method: initialize
- allow:
method: tools/list
- allow:
method: tools/call
tool:
any: [search_web, list_tools]
deny_rules:
- method: tools/call
tool: send_email
binaries:
- path: /usr/bin/curl
";
let proto = parse_sandbox_policy(yaml).expect("parse failed");
let ep = &proto.network_policies["mcp"].endpoints[0];
assert_eq!(ep.protocol, "mcp");
assert_eq!(ep.json_rpc_max_body_bytes, 131_072);
assert_eq!(
ep.mcp
.as_ref()
.and_then(|options| options.strict_tool_names),
Some(false)
);
assert_eq!(ep.rules.len(), 3);
assert_eq!(ep.rules[2].allow.as_ref().unwrap().method, "tools/call");
assert_eq!(
ep.rules[2].allow.as_ref().unwrap().params["name"].any,
vec!["search_web".to_string(), "list_tools".to_string()]
);
assert_eq!(ep.deny_rules.len(), 1);
assert_eq!(ep.deny_rules[0].method, "tools/call");
assert_eq!(ep.deny_rules[0].params["name"].glob, "send_email");
}
#[test]
fn round_trip_mcp_policy_serializes_mcp_expression() {
let yaml = r"
version: 1
network_policies:
mcp:
name: mcp
endpoints:
- host: mcp.example.com
port: 443
protocol: mcp
mcp:
max_body_bytes: 131072
strict_tool_names: false
rules:
- allow:
method: tools/call
tool: search_web
deny_rules:
- method: tools/call
tool:
any: [send_email, delete_resource]
binaries:
- path: /usr/bin/curl
";
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
assert!(yaml_out.contains("protocol: mcp"));
assert!(yaml_out.contains("method: tools/call"));
assert!(yaml_out.contains("tool: search_web"));
assert!(yaml_out.contains("any:"));
assert!(yaml_out.contains("- send_email"));
assert!(yaml_out.contains("- delete_resource"));
assert!(yaml_out.contains("deny_rules:"));
assert!(!yaml_out.contains("arguments:"));
assert!(yaml_out.contains("mcp:"));
assert!(yaml_out.contains("strict_tool_names: false"));
assert_eq!(proto1, proto2);
}
#[test]
fn parse_rejects_unsupported_json_rpc_config_fields() {
let yaml = r"
version: 1
network_policies:
jsonrpc_api:
endpoints:
- host: jsonrpc.example.com
port: 443
protocol: json-rpc
json_rpc:
max_body_bytes: 131072
on_parse_error: deny
batch_policy: all
access: full
binaries:
- path: /usr/bin/curl
";
assert!(
parse_sandbox_policy(yaml).is_err(),
"unsupported json_rpc fields must not be silently accepted"
);
}
#[test]
fn round_trip_preserves_websocket_credential_rewrite() {
let yaml = r"
version: 1
network_policies:
discord_gateway:
name: discord_gateway
endpoints:
- host: gateway.example.com
port: 443
protocol: rest
enforcement: enforce
access: full
websocket_credential_rewrite: true
binaries:
- path: /usr/bin/node
";
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let ep = &proto2.network_policies["discord_gateway"].endpoints[0];
assert_eq!(ep.protocol, "rest");
assert!(ep.websocket_credential_rewrite);
assert!(yaml_out.contains("websocket_credential_rewrite: true"));
}
#[test]
fn round_trip_preserves_request_body_credential_rewrite() {
let yaml = r"
version: 1
network_policies:
slack_api:
name: slack_api
endpoints:
- host: slack.com
port: 443
protocol: rest
enforcement: enforce
access: read-write
request_body_credential_rewrite: true
binaries:
- path: /usr/bin/node
";
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let ep = &proto2.network_policies["slack_api"].endpoints[0];
assert_eq!(ep.protocol, "rest");
assert!(ep.request_body_credential_rewrite);
assert!(yaml_out.contains("request_body_credential_rewrite: true"));
}
#[test]
fn websocket_credential_rewrite_defaults_false() {
let yaml = r"
version: 1
network_policies:
gateway:
endpoints:
- host: gateway.example.com
port: 443
protocol: rest
access: full
binaries:
- path: /usr/bin/node
";
let proto = parse_sandbox_policy(yaml).expect("parse failed");
let ep = &proto.network_policies["gateway"].endpoints[0];
assert!(!ep.websocket_credential_rewrite);
assert!(!ep.request_body_credential_rewrite);
}
#[test]
fn parse_rejects_unknown_fields_in_deny_rule() {
let yaml = r"
version: 1
network_policies:
test:
endpoints:
- host: example.com
port: 443
deny_rules:
- method: POST
path: /foo
bogus: true
";
assert!(parse_sandbox_policy(yaml).is_err());
}
#[test]
fn rejects_port_above_65535() {
let yaml = r"
version: 1
network_policies:
test:
endpoints:
- host: example.com
port: 70000
";
assert!(
parse_sandbox_policy(yaml).is_err(),
"port >65535 should fail to parse"
);
}
}