diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-core/src/policy.rs index 1645b9da44..0addc073b8 100644 --- a/crates/openshell-core/src/policy.rs +++ b/crates/openshell-core/src/policy.rs @@ -114,7 +114,11 @@ impl TryFrom 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(), }) } @@ -138,14 +142,19 @@ impl From for FilesystemPolicy { } } -impl From for LandlockPolicy { - fn from(proto: ProtoLandlockPolicy) -> Self { - let compatibility = if proto.compatibility == "hard_requirement" { - LandlockCompatibility::HardRequirement - } else { - LandlockCompatibility::BestEffort +impl TryFrom for LandlockPolicy { + type Error = miette::Error; + + fn try_from(proto: ProtoLandlockPolicy) -> Result { + let compatibility = match proto.compatibility.as_str() { + "best_effort" | "" => LandlockCompatibility::BestEffort, + "hard_requirement" => LandlockCompatibility::HardRequirement, + otherwise => miette::bail!( + "invalid landlock.compatibility {:?}; accepted: best_effort, hard_requirement", + otherwise + ), }; - Self { compatibility } + Ok(Self { compatibility }) } } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index ea838b3b74..5095d0deb5 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -70,11 +70,19 @@ struct FilesystemDef { read_write: Vec, } +#[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)] @@ -781,7 +789,10 @@ fn to_proto(raw: PolicyFile) -> Result { 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, @@ -804,7 +815,10 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { }); let landlock = policy.landlock.as_ref().map(|ll| LandlockDef { - compatibility: ll.compatibility.clone(), + compatibility: match ll.compatibility.as_str() { + "hard_requirement" => LandlockCompatibilityDef::HardRequirement, + _ => LandlockCompatibilityDef::BestEffort, + }, }); let process = policy.process.as_ref().and_then(|p| { diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs b/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs index bf42faede8..d1efbb4ab2 100644 --- a/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs +++ b/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs @@ -144,6 +144,14 @@ fn prepare_with_path_open_mode( } if read_only.is_empty() && read_write.is_empty() { + if matches!( + policy.landlock.compatibility, + LandlockCompatibility::HardRequirement + ) { + miette::bail!( + "landlock.compatibility is hard_requirement but no filesystem paths are configured" + ); + } return Ok(None); } diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index d585517d13..12581a4d29 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -93,14 +93,14 @@ Configures [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforc **Compatibility modes:** -| Value | Kernel ABI unavailable | Individual path inaccessible | All paths inaccessible | -|---|---|---|---| -| `best_effort` | Warns and continues without Landlock. | Skips the path, applies remaining rules. | Warns and continues without Landlock (refuses to apply an empty ruleset). | -| `hard_requirement` | Aborts sandbox startup. | Aborts sandbox startup. | Aborts sandbox startup. | +| Value | No paths configured | Kernel ABI unavailable | Individual path inaccessible | All paths inaccessible | +|---|---|---|---|---| +| `best_effort` | Landlock skipped (no-op). | Warns and continues without Landlock. | Skips the path, applies remaining rules. | Warns and continues without Landlock (refuses to apply an empty ruleset). | +| `hard_requirement` | Aborts sandbox startup. | Aborts sandbox startup. | Aborts sandbox startup. | Aborts sandbox startup. | `best_effort` (the default) is appropriate for most deployments. It handles missing paths gracefully. For example, `/app` might not exist in every container image but is included in the baseline path set for containers that do have it. Individual missing paths are skipped while the remaining filesystem rules are still enforced. -`hard_requirement` is for environments where any gap in filesystem isolation is unacceptable. If a listed path cannot be opened for any reason (missing, permission denied, symlink loop), sandbox startup fails immediately rather than running with reduced protection. +`hard_requirement` is for environments where any gap in filesystem isolation is unacceptable. If a listed path cannot be opened for any reason (missing, permission denied, symlink loop), sandbox startup fails immediately rather than running with reduced protection. Configuring `hard_requirement` with no filesystem paths is also a startup error. When a path is skipped under `best_effort`, the sandbox logs a warning that includes the path, the specific error, and a human-readable reason (for example, "path does not exist" or "permission denied").