Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 77 additions & 8 deletions crates/openshell-core/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ pub enum LandlockCompatibility {
HardRequirement,
}

/// Accepted `landlock.compatibility` values in their proto string form.
///
/// Single source of truth shared by YAML parsing, proto→runtime conversion,
/// and gateway policy validation so the accepted set cannot drift.
pub const LANDLOCK_COMPATIBILITY_VALUES: [&str; 2] = ["best_effort", "hard_requirement"];

/// Returns `true` if `value` is an accepted `landlock.compatibility` string.
///
/// The empty string is accepted and defaults to `best_effort`.
pub fn is_valid_landlock_compatibility(value: &str) -> bool {
value.is_empty() || LANDLOCK_COMPATIBILITY_VALUES.contains(&value)
}

// ============================================================================
// Proto to Rust type conversions
// ============================================================================
Expand All @@ -114,7 +127,11 @@ impl TryFrom<ProtoSandboxPolicy> for SandboxPolicy {
.map(FilesystemPolicy::from)
.unwrap_or_default(),
network,
landlock: proto.landlock.map(LandlockPolicy::from).unwrap_or_default(),
landlock: proto
.landlock
.map(LandlockPolicy::try_from)
.transpose()?
.unwrap_or_default(),
process: proto.process.map(ProcessPolicy::from).unwrap_or_default(),
})
}
Expand All @@ -138,14 +155,20 @@ impl From<ProtoFilesystemPolicy> for FilesystemPolicy {
}
}

impl From<ProtoLandlockPolicy> for LandlockPolicy {
fn from(proto: ProtoLandlockPolicy) -> Self {
let compatibility = if proto.compatibility == "hard_requirement" {
LandlockCompatibility::HardRequirement
} else {
LandlockCompatibility::BestEffort
impl TryFrom<ProtoLandlockPolicy> for LandlockPolicy {
type Error = miette::Error;

fn try_from(proto: ProtoLandlockPolicy) -> Result<Self, Self::Error> {
let compatibility = match proto.compatibility.as_str() {
"best_effort" | "" => LandlockCompatibility::BestEffort,
"hard_requirement" => LandlockCompatibility::HardRequirement,
otherwise => miette::bail!(
"invalid landlock.compatibility {:?}; accepted: {}",
otherwise,
LANDLOCK_COMPATIBILITY_VALUES.join(", ")
),
};
Self { compatibility }
Ok(Self { compatibility })
}
}

Expand All @@ -165,3 +188,49 @@ impl From<ProtoProcessPolicy> for ProcessPolicy {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn try_from_maps_known_compatibility_values() {
for (input, expected) in [
("", LandlockCompatibility::BestEffort),
("best_effort", LandlockCompatibility::BestEffort),
("hard_requirement", LandlockCompatibility::HardRequirement),
] {
let proto = ProtoLandlockPolicy {
compatibility: input.into(),
};
let policy = LandlockPolicy::try_from(proto).expect("should convert");
assert_eq!(
std::mem::discriminant(&policy.compatibility),
std::mem::discriminant(&expected),
"input {input:?} mapped to unexpected variant",
);
}
}

#[test]
fn try_from_rejects_invalid_compatibility() {
let proto = ProtoLandlockPolicy {
compatibility: "hard-requirement".into(),
};
let err = LandlockPolicy::try_from(proto).expect_err("should reject");
let msg = format!("{err:?}");
assert!(
msg.contains("best_effort") && msg.contains("hard_requirement"),
"error should list accepted values, got: {msg}",
);
}

#[test]
fn is_valid_landlock_compatibility_accepts_empty_and_known() {
assert!(is_valid_landlock_compatibility(""));
assert!(is_valid_landlock_compatibility("best_effort"));
assert!(is_valid_landlock_compatibility("hard_requirement"));
assert!(!is_valid_landlock_compatibility("nope"));
assert!(!is_valid_landlock_compatibility("BestEffort"));
}
}
139 changes: 128 additions & 11 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,19 @@ struct FilesystemDef {
read_write: Vec<String>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum LandlockCompatibilityDef {
#[default]
BestEffort,
HardRequirement,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct LandlockDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
compatibility: String,
#[serde(default)]
compatibility: LandlockCompatibilityDef,
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -810,7 +818,10 @@ fn to_proto(raw: PolicyFile) -> Result<SandboxPolicy> {
read_write: fs.read_write,
}),
landlock: raw.landlock.map(|ll| LandlockPolicy {
compatibility: ll.compatibility,
compatibility: match ll.compatibility {
LandlockCompatibilityDef::BestEffort => "best_effort".to_string(),
LandlockCompatibilityDef::HardRequirement => "hard_requirement".to_string(),
},
}),
process: raw.process.map(|p| ProcessPolicy {
run_as_user: p.run_as_user,
Expand All @@ -825,16 +836,28 @@ fn to_proto(raw: PolicyFile) -> Result<SandboxPolicy> {
// Proto → YAML conversion
// ---------------------------------------------------------------------------

fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
fn from_proto(policy: &SandboxPolicy) -> Result<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 landlock = match policy.landlock.as_ref() {
Some(ll) => {
let compatibility = match ll.compatibility.as_str() {
"hard_requirement" => LandlockCompatibilityDef::HardRequirement,
"best_effort" | "" => LandlockCompatibilityDef::BestEffort,
otherwise => miette::bail!(
"invalid landlock.compatibility {:?}; accepted: {}",
otherwise,
openshell_core::policy::LANDLOCK_COMPATIBILITY_VALUES.join(", ")
),
};
Some(LandlockDef { compatibility })
}
_ => None,
};

let process = policy.process.as_ref().and_then(|p| {
if p.run_as_user.is_empty() && p.run_as_group.is_empty() {
Expand Down Expand Up @@ -958,14 +981,14 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {

let network_middlewares = middleware::from_proto(&policy.network_middlewares);

PolicyFile {
Ok(PolicyFile {
version: policy.version,
filesystem_policy,
landlock,
process,
network_policies,
network_middlewares,
}
})
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1020,7 +1043,7 @@ pub fn parse_sandbox_policy(yaml: &str) -> Result<SandboxPolicy> {
/// 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);
let yaml_repr = from_proto(policy)?;
serde_yml::to_string(&yaml_repr)
.into_diagnostic()
.wrap_err("failed to serialize policy to YAML")
Expand All @@ -1031,7 +1054,7 @@ pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result<String> {
/// 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);
let json_repr = from_proto(policy)?;
serde_json::to_value(&json_repr)
.into_diagnostic()
.wrap_err("failed to serialize policy to JSON")
Expand Down Expand Up @@ -1188,6 +1211,8 @@ pub enum PolicyViolation {
policy_name: String,
host: String,
},
/// `landlock.compatibility` has an unrecognized value.
InvalidLandlockCompatibility { value: String },
}

impl fmt::Display for PolicyViolation {
Expand Down Expand Up @@ -1298,6 +1323,13 @@ impl fmt::Display for PolicyViolation {
'{policy_name}' tls: skip endpoint '{host}'"
)
}
Self::InvalidLandlockCompatibility { value } => {
write!(
f,
"invalid landlock.compatibility '{value}'; accepted: {}",
openshell_core::policy::LANDLOCK_COMPATIBILITY_VALUES.join(", ")
)
}
}
}
}
Expand Down Expand Up @@ -1342,6 +1374,17 @@ pub fn validate_sandbox_policy(
}
}

// Check landlock compatibility mode is a recognized value. Direct gRPC/SDK
// clients bypass YAML serde validation, so reject invalid values here at the
// gateway create path rather than deferring rejection to sandbox startup.
if let Some(ref landlock) = policy.landlock
&& !openshell_core::policy::is_valid_landlock_compatibility(&landlock.compatibility)
{
violations.push(PolicyViolation::InvalidLandlockCompatibility {
value: landlock.compatibility.clone(),
});
}

// Check filesystem paths
if let Some(ref fs) = policy.filesystem {
let total_paths = fs.read_only.len() + fs.read_write.len();
Expand Down Expand Up @@ -2023,6 +2066,80 @@ network_policies:
assert_eq!(violations.len(), 2);
}

#[test]
fn parse_rejects_invalid_landlock_compatibility() {
let err = parse_sandbox_policy("version: 1\nlandlock:\n compatibility: bogus\n")
.expect_err("should reject invalid YAML enum value");
let msg = format!("{err:?}");
assert!(
msg.contains("best_effort") && msg.contains("hard_requirement"),
"error should list accepted values, got: {msg}",
);
}

#[test]
fn parse_accepts_known_landlock_compatibility() {
for value in ["best_effort", "hard_requirement"] {
let yaml = format!("version: 1\nlandlock:\n compatibility: {value}\n");
let policy = parse_sandbox_policy(&yaml).expect("should parse");
assert_eq!(
policy.landlock.as_ref().expect("landlock").compatibility,
value,
);
}
}

#[test]
fn validate_rejects_invalid_landlock_compatibility_proto() {
let mut policy = restrictive_default_policy();
policy.landlock = Some(LandlockPolicy {
compatibility: "nope".into(),
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::InvalidLandlockCompatibility { .. })),
"expected InvalidLandlockCompatibility, got: {violations:?}",
);
}

#[test]
fn validate_accepts_empty_landlock_compatibility() {
// Empty string is the proto default and maps to best_effort.
let mut policy = restrictive_default_policy();
policy.landlock = Some(LandlockPolicy {
compatibility: String::new(),
});
assert!(validate_sandbox_policy(&policy).is_ok());
}

#[test]
fn serialize_rejects_invalid_landlock_compatibility() {
// Old policies persisted before gateway validation can hold invalid
// values; serialization must error rather than normalize to best_effort.
let mut policy = restrictive_default_policy();
policy.landlock = Some(LandlockPolicy {
compatibility: "hard-requirement".to_string(),
});
let err = serialize_sandbox_policy(&policy).expect_err("should reject");
let msg = format!("{err:?}");
assert!(
msg.contains("best_effort") && msg.contains("hard_requirement"),
"error should list accepted values, got: {msg}",
);
}

#[test]
fn serialize_accepts_empty_landlock_compatibility() {
// Empty string is the proto default; serialize must not error on it.
let mut policy = restrictive_default_policy();
policy.landlock = Some(LandlockPolicy {
compatibility: String::new(),
});
assert!(serialize_sandbox_policy(&policy).is_ok());
}

#[test]
fn validate_rejects_invalid_middleware_control_fields() {
let cases = [
Expand Down
Loading
Loading