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
25 changes: 17 additions & 8 deletions crates/openshell-core/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,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 +142,19 @@ 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: best_effort, hard_requirement",
otherwise
),
};
Self { compatibility }
Ok(Self { compatibility })
}
}

Expand Down
22 changes: 18 additions & 4 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,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 @@ -781,7 +789,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 @@ -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| {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
10 changes: 5 additions & 5 deletions docs/reference/policy-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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").

Expand Down
Loading