Skip to content

Commit 758c62d

Browse files
authored
fix(sandbox): handle per-path Landlock errors instead of abandoning entire ruleset (#677)
* fix(sandbox): handle per-path Landlock errors instead of abandoning entire ruleset A single missing path (e.g., /app in containers without that directory) caused PathFd::new() to propagate an error out of the entire Landlock setup closure. Under BestEffort mode, this silently disabled all filesystem restrictions for the sandbox. Changes: - Extract try_open_path() and classify_path_error() helpers that handle PathFd failures per-path instead of per-ruleset - BestEffort mode: skip inaccessible paths with a warning, apply remaining rules - HardRequirement mode: fail immediately on any inaccessible path - Add zero-rule safety check to prevent applying an empty ruleset that would block all filesystem access - Pre-filter system-injected baseline paths (e.g., /app) in enrichment functions so missing paths never reach Landlock - Add unit tests for try_open_path, classify_path_error, and error classification for ENOENT, EACCES, ELOOP, ENAMETOOLONG, ENOTDIR - Update user-facing docs and architecture docs with Landlock behavior tables, baseline path filtering, and compatibility mode semantics - Fix stale ABI::V1 references in docs (code uses ABI::V2) Closes #664 * fix(sandbox): use debug log for NotFound in Landlock best-effort mode NotFound errors for stale baseline paths (e.g. /app persisted in the server-stored policy but absent in this container) are expected in best-effort mode. Downgrade from warn! to debug! so the message does not leak into SSH exec stdout (the pre_exec hook inherits the tracing subscriber whose writer targets fd 1). Genuine errors (permission denied, symlink loops, etc.) remain at warn! for operator visibility. Also move custom_image e2e marker from /opt to /etc (a Landlock baseline read-only path) since the security fix now properly enforces filesystem restrictions. --------- Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
1 parent 38655a6 commit 758c62d

File tree

7 files changed

+293
-35
lines changed

7 files changed

+293
-35
lines changed

architecture/sandbox.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,15 +431,21 @@ Landlock restricts the child process's filesystem access to an explicit allowlis
431431
1. Build path lists from `filesystem.read_only` and `filesystem.read_write`
432432
2. If `include_workdir` is true, add the working directory to `read_write`
433433
3. If both lists are empty, skip Landlock entirely (no-op)
434-
4. Create a Landlock ruleset targeting ABI V1:
434+
4. Create a Landlock ruleset targeting ABI V2:
435435
- Read-only paths receive `AccessFs::from_read(abi)` rights
436436
- Read-write paths receive `AccessFs::from_all(abi)` rights
437-
5. Call `ruleset.restrict_self()` -- this applies to the calling process and all descendants
437+
5. For each path, attempt `PathFd::new()`. If it fails:
438+
- `BestEffort`: Log a warning with the error classification (not found, permission denied, symlink loop, etc.) and skip the path. Continue building the ruleset from remaining valid paths.
439+
- `HardRequirement`: Return a fatal error, aborting the sandbox.
440+
6. If all paths failed (zero rules applied), return an error rather than calling `restrict_self()` on an empty ruleset (which would block all filesystem access)
441+
7. Call `ruleset.restrict_self()` -- this applies to the calling process and all descendants
438442

439-
Error behavior depends on `LandlockCompatibility`:
443+
Kernel-level error behavior (e.g., Landlock ABI unavailable) depends on `LandlockCompatibility`:
440444
- `BestEffort`: Log a warning and continue without filesystem isolation
441445
- `HardRequirement`: Return a fatal error, aborting the sandbox
442446

447+
**Baseline path filtering**: System-injected baseline paths (e.g., `/app`) are pre-filtered by `enrich_proto_baseline_paths()` / `enrich_sandbox_baseline_paths()` using `Path::exists()` before they reach Landlock. User-specified paths are not pre-filtered -- they are evaluated at Landlock apply time so misconfigurations surface as warnings or errors.
448+
443449
### Seccomp syscall filtering
444450

445451
**File:** `crates/openshell-sandbox/src/sandbox/linux/seccomp.rs`

architecture/security-policy.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ Controls which filesystem paths the sandboxed process can access. Enforced via L
320320
| `read_only` | `string[]` | `[]` | Paths accessible in read-only mode |
321321
| `read_write` | `string[]` | `[]` | Paths accessible in read-write mode |
322322

323-
**Enforcement mapping**: Each path becomes a Landlock `PathBeneath` rule. Read-only paths receive `AccessFs::from_read(ABI::V1)` permissions. Read-write paths receive `AccessFs::from_all(ABI::V1)` permissions (read, write, execute, create, delete, rename). All other paths are denied by the Landlock ruleset.
323+
**Enforcement mapping**: Each path becomes a Landlock `PathBeneath` rule. Read-only paths receive `AccessFs::from_read(ABI::V2)` permissions. Read-write paths receive `AccessFs::from_all(ABI::V2)` permissions (read, write, execute, create, delete, rename). All other paths are denied by the Landlock ruleset.
324324

325325
**Filesystem preparation**: Before the child process spawns, the supervisor creates any `read_write` directories that do not exist and sets their ownership to `process.run_as_user`:`process.run_as_group` via `chown()`. See `crates/openshell-sandbox/src/lib.rs` -- `prepare_filesystem()`.
326326

@@ -358,10 +358,16 @@ Controls Landlock LSM compatibility behavior. **Static field** -- immutable afte
358358

359359
| Value | Behavior |
360360
| ------------------ | --------------------------------------------------------------------------------------------------------------------------- |
361-
| `best_effort` | If Landlock is unavailable (older kernel, unprivileged container), log a warning and continue without filesystem sandboxing |
362-
| `hard_requirement` | If Landlock is unavailable, abort sandbox startup with an error |
361+
| `best_effort` | If Landlock is unavailable (older kernel, unprivileged container), log a warning and continue without filesystem sandboxing. Individual inaccessible paths (missing, permission denied, symlink loops) are skipped with a warning while remaining rules are still applied. If all paths fail, the sandbox continues without Landlock rather than applying an empty ruleset that would block all access. |
362+
| `hard_requirement` | If Landlock is unavailable or any configured path cannot be opened, abort sandbox startup with an error. |
363363

364-
See `crates/openshell-sandbox/src/sandbox/linux/landlock.rs` -- `compat_level()`.
364+
**Per-path error handling**: `PathFd::new()` (which wraps `open(path, O_PATH | O_CLOEXEC)`) can fail for several reasons beyond path non-existence: `EACCES` (permission denied), `ELOOP` (symlink loop), `ENAMETOOLONG`, `ENOTDIR`. Each failure is classified with a human-readable reason in logs. In `best_effort` mode, the path is skipped and ruleset construction continues. In `hard_requirement` mode, the error is fatal.
365+
366+
**Baseline path filtering**: The enrichment functions (`enrich_proto_baseline_paths`, `enrich_sandbox_baseline_paths`) pre-filter system-injected baseline paths (e.g., `/app`) by checking `Path::exists()` before adding them to the policy. This prevents missing baseline paths from reaching Landlock at all. User-specified paths are not pre-filtered — they are evaluated at Landlock apply time so that misconfigurations surface as warnings (`best_effort`) or errors (`hard_requirement`).
367+
368+
**Zero-rule safety check**: If all paths in the ruleset fail to open, `apply()` returns an error rather than calling `restrict_self()` on an empty ruleset. An empty Landlock ruleset with `restrict_self()` would block all filesystem access — the inverse of the intended degradation behavior. This error is caught by the outer `BestEffort` handler, which logs a warning and continues without Landlock.
369+
370+
See `crates/openshell-sandbox/src/sandbox/linux/landlock.rs` -- `compat_level()`, `try_open_path()`, `classify_path_fd_error()`, `classify_io_error()`.
365371

366372
```yaml
367373
landlock:

crates/openshell-sandbox/src/lib.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,12 +899,31 @@ fn enrich_proto_baseline_paths(proto: &mut openshell_core::proto::SandboxPolicy)
899899
let mut modified = false;
900900
for &path in PROXY_BASELINE_READ_ONLY {
901901
if !fs.read_only.iter().any(|p| p.as_str() == path) {
902+
// Baseline paths are system-injected, not user-specified. Skip
903+
// paths that do not exist in this container image to avoid noisy
904+
// warnings from Landlock and, more critically, to prevent a single
905+
// missing baseline path from abandoning the entire Landlock
906+
// ruleset under best-effort mode (see issue #664).
907+
if !std::path::Path::new(path).exists() {
908+
debug!(
909+
path,
910+
"Baseline read-only path does not exist, skipping enrichment"
911+
);
912+
continue;
913+
}
902914
fs.read_only.push(path.to_string());
903915
modified = true;
904916
}
905917
}
906918
for &path in PROXY_BASELINE_READ_WRITE {
907919
if !fs.read_write.iter().any(|p| p.as_str() == path) {
920+
if !std::path::Path::new(path).exists() {
921+
debug!(
922+
path,
923+
"Baseline read-write path does not exist, skipping enrichment"
924+
);
925+
continue;
926+
}
908927
fs.read_write.push(path.to_string());
909928
modified = true;
910929
}
@@ -929,13 +948,29 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) {
929948
for &path in PROXY_BASELINE_READ_ONLY {
930949
let p = std::path::PathBuf::from(path);
931950
if !policy.filesystem.read_only.contains(&p) {
951+
// Baseline paths are system-injected — skip non-existent paths to
952+
// avoid Landlock ruleset abandonment (issue #664).
953+
if !p.exists() {
954+
debug!(
955+
path,
956+
"Baseline read-only path does not exist, skipping enrichment"
957+
);
958+
continue;
959+
}
932960
policy.filesystem.read_only.push(p);
933961
modified = true;
934962
}
935963
}
936964
for &path in PROXY_BASELINE_READ_WRITE {
937965
let p = std::path::PathBuf::from(path);
938966
if !policy.filesystem.read_write.contains(&p) {
967+
if !p.exists() {
968+
debug!(
969+
path,
970+
"Baseline read-write path does not exist, skipping enrichment"
971+
);
972+
continue;
973+
}
939974
policy.filesystem.read_write.push(p);
940975
modified = true;
941976
}

crates/openshell-sandbox/src/sandbox/linux/landlock.rs

Lines changed: 207 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
66
use crate::policy::{LandlockCompatibility, SandboxPolicy};
77
use landlock::{
8-
ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
9-
RulesetCreatedAttr,
8+
ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, PathFdError, Ruleset,
9+
RulesetAttr, RulesetCreatedAttr,
1010
};
1111
use miette::{IntoDiagnostic, Result};
12-
use std::path::PathBuf;
12+
use std::path::{Path, PathBuf};
1313
use tracing::{debug, info, warn};
1414

1515
pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
@@ -29,6 +29,7 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
2929
return Ok(());
3030
}
3131

32+
let total_paths = read_only.len() + read_write.len();
3233
let abi = ABI::V2;
3334
info!(
3435
abi = ?abi,
@@ -38,47 +39,61 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
3839
"Applying Landlock filesystem sandbox"
3940
);
4041

42+
let compatibility = &policy.landlock.compatibility;
43+
4144
let result: Result<()> = (|| {
4245
let access_all = AccessFs::from_all(abi);
4346
let access_read = AccessFs::from_read(abi);
4447

4548
let mut ruleset = Ruleset::default();
4649
ruleset = ruleset
47-
.set_compatibility(compat_level(&policy.landlock.compatibility))
50+
.set_compatibility(compat_level(compatibility))
4851
.handle_access(access_all)
4952
.into_diagnostic()?;
5053

5154
let mut ruleset = ruleset.create().into_diagnostic()?;
55+
let mut rules_applied: usize = 0;
56+
57+
for path in &read_only {
58+
if let Some(path_fd) = try_open_path(path, compatibility)? {
59+
debug!(path = %path.display(), "Landlock allow read-only");
60+
ruleset = ruleset
61+
.add_rule(PathBeneath::new(path_fd, access_read))
62+
.into_diagnostic()?;
63+
rules_applied += 1;
64+
}
65+
}
5266

53-
for path in read_only {
54-
debug!(path = %path.display(), "Landlock allow read-only");
55-
ruleset = ruleset
56-
.add_rule(PathBeneath::new(
57-
PathFd::new(path).into_diagnostic()?,
58-
access_read,
59-
))
60-
.into_diagnostic()?;
67+
for path in &read_write {
68+
if let Some(path_fd) = try_open_path(path, compatibility)? {
69+
debug!(path = %path.display(), "Landlock allow read-write");
70+
ruleset = ruleset
71+
.add_rule(PathBeneath::new(path_fd, access_all))
72+
.into_diagnostic()?;
73+
rules_applied += 1;
74+
}
6175
}
6276

63-
for path in read_write {
64-
debug!(path = %path.display(), "Landlock allow read-write");
65-
ruleset = ruleset
66-
.add_rule(PathBeneath::new(
67-
PathFd::new(path).into_diagnostic()?,
68-
access_all,
69-
))
70-
.into_diagnostic()?;
77+
if rules_applied == 0 {
78+
return Err(miette::miette!(
79+
"Landlock ruleset has zero valid paths — all {} path(s) failed to open. \
80+
Refusing to apply an empty ruleset that would block all filesystem access.",
81+
total_paths,
82+
));
7183
}
7284

85+
let skipped = total_paths - rules_applied;
86+
info!(
87+
rules_applied,
88+
skipped, "Landlock ruleset built successfully"
89+
);
90+
7391
ruleset.restrict_self().into_diagnostic()?;
7492
Ok(())
7593
})();
7694

7795
if let Err(err) = result {
78-
if matches!(
79-
policy.landlock.compatibility,
80-
LandlockCompatibility::BestEffort
81-
) {
96+
if matches!(compatibility, LandlockCompatibility::BestEffort) {
8297
warn!(
8398
error = %err,
8499
"Landlock filesystem sandbox is UNAVAILABLE — running WITHOUT filesystem restrictions. \
@@ -92,9 +107,177 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
92107
Ok(())
93108
}
94109

110+
/// Attempt to open a path for Landlock rule creation.
111+
///
112+
/// In `BestEffort` mode, inaccessible paths (missing, permission denied, symlink
113+
/// loops, etc.) are skipped with a warning and `Ok(None)` is returned so the
114+
/// caller can continue building the ruleset from the remaining valid paths.
115+
///
116+
/// In `HardRequirement` mode, any failure is fatal — the caller propagates the
117+
/// error, which ultimately aborts sandbox startup.
118+
fn try_open_path(path: &Path, compatibility: &LandlockCompatibility) -> Result<Option<PathFd>> {
119+
match PathFd::new(path) {
120+
Ok(fd) => Ok(Some(fd)),
121+
Err(err) => {
122+
let reason = classify_path_fd_error(&err);
123+
let is_not_found = matches!(
124+
&err,
125+
PathFdError::OpenCall { source, .. }
126+
if source.kind() == std::io::ErrorKind::NotFound
127+
);
128+
match compatibility {
129+
LandlockCompatibility::BestEffort => {
130+
// NotFound is expected for stale baseline paths (e.g.
131+
// /app baked into the server-stored policy but absent
132+
// in this container image). Log at debug! to avoid
133+
// polluting SSH exec stdout — the pre_exec hook
134+
// inherits the tracing subscriber whose writer targets
135+
// fd 1 (the pipe/PTY).
136+
//
137+
// Other errors (permission denied, symlink loops, etc.)
138+
// are genuinely unexpected and logged at warn!.
139+
if is_not_found {
140+
debug!(
141+
path = %path.display(),
142+
reason,
143+
"Skipping non-existent Landlock path (best-effort mode)"
144+
);
145+
} else {
146+
warn!(
147+
path = %path.display(),
148+
error = %err,
149+
reason,
150+
"Skipping inaccessible Landlock path (best-effort mode)"
151+
);
152+
}
153+
Ok(None)
154+
}
155+
LandlockCompatibility::HardRequirement => Err(miette::miette!(
156+
"Landlock path unavailable in hard_requirement mode: {} ({}): {}",
157+
path.display(),
158+
reason,
159+
err,
160+
)),
161+
}
162+
}
163+
}
164+
}
165+
166+
/// Classify a [`PathFdError`] into a human-readable reason.
167+
///
168+
/// `PathFd::new()` wraps `open(path, O_PATH | O_CLOEXEC)` which can fail for
169+
/// several reasons beyond simple non-existence. The `PathFdError::OpenCall`
170+
/// variant wraps the underlying `std::io::Error`.
171+
fn classify_path_fd_error(err: &PathFdError) -> &'static str {
172+
match err {
173+
PathFdError::OpenCall { source, .. } => classify_io_error(source),
174+
// PathFdError is #[non_exhaustive], handle future variants gracefully.
175+
_ => "unexpected error",
176+
}
177+
}
178+
179+
/// Classify a `std::io::Error` into a human-readable reason string.
180+
fn classify_io_error(err: &std::io::Error) -> &'static str {
181+
match err.kind() {
182+
std::io::ErrorKind::NotFound => "path does not exist",
183+
std::io::ErrorKind::PermissionDenied => "permission denied",
184+
_ => match err.raw_os_error() {
185+
Some(40) => "too many symlink levels", // ELOOP
186+
Some(36) => "path name too long", // ENAMETOOLONG
187+
Some(20) => "path component is not a directory", // ENOTDIR
188+
_ => "unexpected error",
189+
},
190+
}
191+
}
192+
95193
fn compat_level(level: &LandlockCompatibility) -> CompatLevel {
96194
match level {
97195
LandlockCompatibility::BestEffort => CompatLevel::BestEffort,
98196
LandlockCompatibility::HardRequirement => CompatLevel::HardRequirement,
99197
}
100198
}
199+
200+
#[cfg(test)]
201+
mod tests {
202+
use super::*;
203+
204+
#[test]
205+
fn try_open_path_best_effort_returns_none_for_missing_path() {
206+
let result = try_open_path(
207+
&PathBuf::from("/nonexistent/openshell/test/path"),
208+
&LandlockCompatibility::BestEffort,
209+
);
210+
assert!(result.is_ok());
211+
assert!(result.unwrap().is_none());
212+
}
213+
214+
#[test]
215+
fn try_open_path_hard_requirement_errors_for_missing_path() {
216+
let result = try_open_path(
217+
&PathBuf::from("/nonexistent/openshell/test/path"),
218+
&LandlockCompatibility::HardRequirement,
219+
);
220+
assert!(result.is_err());
221+
let err_msg = result.unwrap_err().to_string();
222+
assert!(
223+
err_msg.contains("hard_requirement"),
224+
"error should mention hard_requirement mode: {err_msg}"
225+
);
226+
assert!(
227+
err_msg.contains("does not exist"),
228+
"error should include the classified reason: {err_msg}"
229+
);
230+
}
231+
232+
#[test]
233+
fn try_open_path_succeeds_for_existing_path() {
234+
let dir = tempfile::tempdir().unwrap();
235+
let result = try_open_path(dir.path(), &LandlockCompatibility::BestEffort);
236+
assert!(result.is_ok());
237+
assert!(result.unwrap().is_some());
238+
}
239+
240+
#[test]
241+
fn classify_not_found() {
242+
let err = std::io::Error::from_raw_os_error(libc::ENOENT);
243+
assert_eq!(classify_io_error(&err), "path does not exist");
244+
}
245+
246+
#[test]
247+
fn classify_permission_denied() {
248+
let err = std::io::Error::from_raw_os_error(libc::EACCES);
249+
assert_eq!(classify_io_error(&err), "permission denied");
250+
}
251+
252+
#[test]
253+
fn classify_symlink_loop() {
254+
let err = std::io::Error::from_raw_os_error(libc::ELOOP);
255+
assert_eq!(classify_io_error(&err), "too many symlink levels");
256+
}
257+
258+
#[test]
259+
fn classify_name_too_long() {
260+
let err = std::io::Error::from_raw_os_error(libc::ENAMETOOLONG);
261+
assert_eq!(classify_io_error(&err), "path name too long");
262+
}
263+
264+
#[test]
265+
fn classify_not_a_directory() {
266+
let err = std::io::Error::from_raw_os_error(libc::ENOTDIR);
267+
assert_eq!(classify_io_error(&err), "path component is not a directory");
268+
}
269+
270+
#[test]
271+
fn classify_unknown_error() {
272+
let err = std::io::Error::from_raw_os_error(libc::EIO);
273+
assert_eq!(classify_io_error(&err), "unexpected error");
274+
}
275+
276+
#[test]
277+
fn classify_path_fd_error_extracts_io_error() {
278+
// Use PathFd::new on a non-existent path to get a real PathFdError
279+
// (the OpenCall variant is #[non_exhaustive] and can't be constructed directly).
280+
let err = PathFd::new("/nonexistent/openshell/classify/test").unwrap_err();
281+
assert_eq!(classify_path_fd_error(&err), "path does not exist");
282+
}
283+
}

0 commit comments

Comments
 (0)