From 3bbdd3467eb9dd05b3a209d402cae0c6a2329a34 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:03:22 -0700 Subject: [PATCH 1/2] 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 --- architecture/sandbox.md | 12 +- architecture/security-policy.md | 14 +- crates/openshell-sandbox/src/lib.rs | 35 +++ .../src/sandbox/linux/landlock.rs | 209 ++++++++++++++++-- docs/reference/policy-schema.md | 15 +- docs/sandboxes/policies.md | 15 +- 6 files changed, 267 insertions(+), 33 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 1117d0f7..bfc71ba3 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -431,15 +431,21 @@ Landlock restricts the child process's filesystem access to an explicit allowlis 1. Build path lists from `filesystem.read_only` and `filesystem.read_write` 2. If `include_workdir` is true, add the working directory to `read_write` 3. If both lists are empty, skip Landlock entirely (no-op) -4. Create a Landlock ruleset targeting ABI V1: +4. Create a Landlock ruleset targeting ABI V2: - Read-only paths receive `AccessFs::from_read(abi)` rights - Read-write paths receive `AccessFs::from_all(abi)` rights -5. Call `ruleset.restrict_self()` -- this applies to the calling process and all descendants +5. For each path, attempt `PathFd::new()`. If it fails: + - `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. + - `HardRequirement`: Return a fatal error, aborting the sandbox. +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) +7. Call `ruleset.restrict_self()` -- this applies to the calling process and all descendants -Error behavior depends on `LandlockCompatibility`: +Kernel-level error behavior (e.g., Landlock ABI unavailable) depends on `LandlockCompatibility`: - `BestEffort`: Log a warning and continue without filesystem isolation - `HardRequirement`: Return a fatal error, aborting the sandbox +**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. + ### Seccomp syscall filtering **File:** `crates/openshell-sandbox/src/sandbox/linux/seccomp.rs` diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 8c41e5b9..8b7b61d2 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -320,7 +320,7 @@ Controls which filesystem paths the sandboxed process can access. Enforced via L | `read_only` | `string[]` | `[]` | Paths accessible in read-only mode | | `read_write` | `string[]` | `[]` | Paths accessible in read-write mode | -**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. +**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. **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()`. @@ -358,10 +358,16 @@ Controls Landlock LSM compatibility behavior. **Static field** -- immutable afte | Value | Behavior | | ------------------ | --------------------------------------------------------------------------------------------------------------------------- | -| `best_effort` | If Landlock is unavailable (older kernel, unprivileged container), log a warning and continue without filesystem sandboxing | -| `hard_requirement` | If Landlock is unavailable, abort sandbox startup with an error | +| `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. | +| `hard_requirement` | If Landlock is unavailable or any configured path cannot be opened, abort sandbox startup with an error. | -See `crates/openshell-sandbox/src/sandbox/linux/landlock.rs` -- `compat_level()`. +**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. + +**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`). + +**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. + +See `crates/openshell-sandbox/src/sandbox/linux/landlock.rs` -- `compat_level()`, `try_open_path()`, `classify_path_fd_error()`, `classify_io_error()`. ```yaml landlock: diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 493e4d23..297d7fc3 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -899,12 +899,31 @@ fn enrich_proto_baseline_paths(proto: &mut openshell_core::proto::SandboxPolicy) let mut modified = false; for &path in PROXY_BASELINE_READ_ONLY { if !fs.read_only.iter().any(|p| p.as_str() == path) { + // Baseline paths are system-injected, not user-specified. Skip + // paths that do not exist in this container image to avoid noisy + // warnings from Landlock and, more critically, to prevent a single + // missing baseline path from abandoning the entire Landlock + // ruleset under best-effort mode (see issue #664). + if !std::path::Path::new(path).exists() { + debug!( + path, + "Baseline read-only path does not exist, skipping enrichment" + ); + continue; + } fs.read_only.push(path.to_string()); modified = true; } } for &path in PROXY_BASELINE_READ_WRITE { if !fs.read_write.iter().any(|p| p.as_str() == path) { + if !std::path::Path::new(path).exists() { + debug!( + path, + "Baseline read-write path does not exist, skipping enrichment" + ); + continue; + } fs.read_write.push(path.to_string()); modified = true; } @@ -929,6 +948,15 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { for &path in PROXY_BASELINE_READ_ONLY { let p = std::path::PathBuf::from(path); if !policy.filesystem.read_only.contains(&p) { + // Baseline paths are system-injected — skip non-existent paths to + // avoid Landlock ruleset abandonment (issue #664). + if !p.exists() { + debug!( + path, + "Baseline read-only path does not exist, skipping enrichment" + ); + continue; + } policy.filesystem.read_only.push(p); modified = true; } @@ -936,6 +964,13 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { for &path in PROXY_BASELINE_READ_WRITE { let p = std::path::PathBuf::from(path); if !policy.filesystem.read_write.contains(&p) { + if !p.exists() { + debug!( + path, + "Baseline read-write path does not exist, skipping enrichment" + ); + continue; + } policy.filesystem.read_write.push(p); modified = true; } diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index e276840d..8dc058f8 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -5,11 +5,11 @@ use crate::policy::{LandlockCompatibility, SandboxPolicy}; use landlock::{ - ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr, - RulesetCreatedAttr, + ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, PathFdError, Ruleset, + RulesetAttr, RulesetCreatedAttr, }; use miette::{IntoDiagnostic, Result}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tracing::{debug, info, warn}; pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { @@ -29,6 +29,7 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { return Ok(()); } + let total_paths = read_only.len() + read_write.len(); let abi = ABI::V2; info!( abi = ?abi, @@ -38,47 +39,61 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { "Applying Landlock filesystem sandbox" ); + let compatibility = &policy.landlock.compatibility; + let result: Result<()> = (|| { let access_all = AccessFs::from_all(abi); let access_read = AccessFs::from_read(abi); let mut ruleset = Ruleset::default(); ruleset = ruleset - .set_compatibility(compat_level(&policy.landlock.compatibility)) + .set_compatibility(compat_level(compatibility)) .handle_access(access_all) .into_diagnostic()?; let mut ruleset = ruleset.create().into_diagnostic()?; + let mut rules_applied: usize = 0; + + for path in &read_only { + if let Some(path_fd) = try_open_path(path, compatibility)? { + debug!(path = %path.display(), "Landlock allow read-only"); + ruleset = ruleset + .add_rule(PathBeneath::new(path_fd, access_read)) + .into_diagnostic()?; + rules_applied += 1; + } + } - for path in read_only { - debug!(path = %path.display(), "Landlock allow read-only"); - ruleset = ruleset - .add_rule(PathBeneath::new( - PathFd::new(path).into_diagnostic()?, - access_read, - )) - .into_diagnostic()?; + for path in &read_write { + if let Some(path_fd) = try_open_path(path, compatibility)? { + debug!(path = %path.display(), "Landlock allow read-write"); + ruleset = ruleset + .add_rule(PathBeneath::new(path_fd, access_all)) + .into_diagnostic()?; + rules_applied += 1; + } } - for path in read_write { - debug!(path = %path.display(), "Landlock allow read-write"); - ruleset = ruleset - .add_rule(PathBeneath::new( - PathFd::new(path).into_diagnostic()?, - access_all, - )) - .into_diagnostic()?; + if rules_applied == 0 { + return Err(miette::miette!( + "Landlock ruleset has zero valid paths — all {} path(s) failed to open. \ + Refusing to apply an empty ruleset that would block all filesystem access.", + total_paths, + )); } + let skipped = total_paths - rules_applied; + info!( + rules_applied, + skipped, "Landlock ruleset built successfully" + ); + ruleset.restrict_self().into_diagnostic()?; Ok(()) })(); if let Err(err) = result { - if matches!( - policy.landlock.compatibility, - LandlockCompatibility::BestEffort - ) { + if matches!(compatibility, LandlockCompatibility::BestEffort) { warn!( error = %err, "Landlock filesystem sandbox is UNAVAILABLE — running WITHOUT filesystem restrictions. \ @@ -92,9 +107,155 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { Ok(()) } +/// Attempt to open a path for Landlock rule creation. +/// +/// In `BestEffort` mode, inaccessible paths (missing, permission denied, symlink +/// loops, etc.) are skipped with a warning and `Ok(None)` is returned so the +/// caller can continue building the ruleset from the remaining valid paths. +/// +/// In `HardRequirement` mode, any failure is fatal — the caller propagates the +/// error, which ultimately aborts sandbox startup. +fn try_open_path(path: &Path, compatibility: &LandlockCompatibility) -> Result> { + match PathFd::new(path) { + Ok(fd) => Ok(Some(fd)), + Err(err) => { + let reason = classify_path_fd_error(&err); + match compatibility { + LandlockCompatibility::BestEffort => { + warn!( + path = %path.display(), + error = %err, + reason = reason, + "Skipping inaccessible Landlock path (best-effort mode)" + ); + Ok(None) + } + LandlockCompatibility::HardRequirement => Err(miette::miette!( + "Landlock path unavailable in hard_requirement mode: {} ({}): {}", + path.display(), + reason, + err, + )), + } + } + } +} + +/// Classify a [`PathFdError`] into a human-readable reason. +/// +/// `PathFd::new()` wraps `open(path, O_PATH | O_CLOEXEC)` which can fail for +/// several reasons beyond simple non-existence. The `PathFdError::OpenCall` +/// variant wraps the underlying `std::io::Error`. +fn classify_path_fd_error(err: &PathFdError) -> &'static str { + match err { + PathFdError::OpenCall { source, .. } => classify_io_error(source), + // PathFdError is #[non_exhaustive], handle future variants gracefully. + _ => "unexpected error", + } +} + +/// Classify a `std::io::Error` into a human-readable reason string. +fn classify_io_error(err: &std::io::Error) -> &'static str { + match err.kind() { + std::io::ErrorKind::NotFound => "path does not exist", + std::io::ErrorKind::PermissionDenied => "permission denied", + _ => match err.raw_os_error() { + Some(40) => "too many symlink levels", // ELOOP + Some(36) => "path name too long", // ENAMETOOLONG + Some(20) => "path component is not a directory", // ENOTDIR + _ => "unexpected error", + }, + } +} + fn compat_level(level: &LandlockCompatibility) -> CompatLevel { match level { LandlockCompatibility::BestEffort => CompatLevel::BestEffort, LandlockCompatibility::HardRequirement => CompatLevel::HardRequirement, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn try_open_path_best_effort_returns_none_for_missing_path() { + let result = try_open_path( + &PathBuf::from("/nonexistent/openshell/test/path"), + &LandlockCompatibility::BestEffort, + ); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn try_open_path_hard_requirement_errors_for_missing_path() { + let result = try_open_path( + &PathBuf::from("/nonexistent/openshell/test/path"), + &LandlockCompatibility::HardRequirement, + ); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("hard_requirement"), + "error should mention hard_requirement mode: {err_msg}" + ); + assert!( + err_msg.contains("does not exist"), + "error should include the classified reason: {err_msg}" + ); + } + + #[test] + fn try_open_path_succeeds_for_existing_path() { + let dir = tempfile::tempdir().unwrap(); + let result = try_open_path(dir.path(), &LandlockCompatibility::BestEffort); + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); + } + + #[test] + fn classify_not_found() { + let err = std::io::Error::from_raw_os_error(libc::ENOENT); + assert_eq!(classify_io_error(&err), "path does not exist"); + } + + #[test] + fn classify_permission_denied() { + let err = std::io::Error::from_raw_os_error(libc::EACCES); + assert_eq!(classify_io_error(&err), "permission denied"); + } + + #[test] + fn classify_symlink_loop() { + let err = std::io::Error::from_raw_os_error(libc::ELOOP); + assert_eq!(classify_io_error(&err), "too many symlink levels"); + } + + #[test] + fn classify_name_too_long() { + let err = std::io::Error::from_raw_os_error(libc::ENAMETOOLONG); + assert_eq!(classify_io_error(&err), "path name too long"); + } + + #[test] + fn classify_not_a_directory() { + let err = std::io::Error::from_raw_os_error(libc::ENOTDIR); + assert_eq!(classify_io_error(&err), "path component is not a directory"); + } + + #[test] + fn classify_unknown_error() { + let err = std::io::Error::from_raw_os_error(libc::EIO); + assert_eq!(classify_io_error(&err), "unexpected error"); + } + + #[test] + fn classify_path_fd_error_extracts_io_error() { + // Use PathFd::new on a non-existent path to get a real PathFdError + // (the OpenCall variant is #[non_exhaustive] and can't be constructed directly). + let err = PathFd::new("/nonexistent/openshell/classify/test").unwrap_err(); + assert_eq!(classify_path_fd_error(&err), "path does not exist"); + } +} diff --git a/docs/reference/policy-schema.md b/docs/reference/policy-schema.md index cb37d0ba..6916e8d0 100644 --- a/docs/reference/policy-schema.md +++ b/docs/reference/policy-schema.md @@ -105,7 +105,20 @@ Configures [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforc | Field | Type | Required | Values | Description | |---|---|---|---|---| -| `compatibility` | string | No | `best_effort`, `hard_requirement` | How OpenShell handles kernel ABI differences. `best_effort` uses the highest Landlock ABI the host kernel supports. `hard_requirement` fails if the required ABI is unavailable. | +| `compatibility` | string | No | `best_effort`, `hard_requirement` | How OpenShell handles Landlock failures. See behavior table below. | + +**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. | + +`best_effort` (the default) is appropriate for most deployments. It handles missing paths gracefully -- for example, `/app` may 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. + +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"). Example: diff --git a/docs/sandboxes/policies.md b/docs/sandboxes/policies.md index 565a7a4c..fa5ed5d8 100644 --- a/docs/sandboxes/policies.md +++ b/docs/sandboxes/policies.md @@ -70,10 +70,23 @@ Dynamic sections can be updated on a running sandbox with `openshell policy set` | Section | Type | Description | |---|---|---| | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | -| `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (use the highest ABI the host kernel supports) or `hard_requirement` (fail if the required ABI is unavailable). | +| `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). See the [Policy Schema Reference](../reference/policy-schema.md#landlock) for the full behavior table. | | `process` | Static | Sets the OS-level identity for the agent process. `run_as_user` and `run_as_group` default to `sandbox`. Root (`root` or `0`) is rejected. The agent also runs with seccomp filters that block dangerous system calls. | | `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the {doc}`policy engine <../about/architecture>` with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request is checked against that endpoint's `rules` (method and path).
Endpoints without `protocol` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through {doc}`../inference/configure`. | +## Baseline Filesystem Paths + +When a sandbox runs in proxy mode (the default), OpenShell automatically adds baseline filesystem paths required for the sandbox child process to function: `/usr`, `/lib`, `/etc`, `/var/log` (read-only) and `/sandbox`, `/tmp` (read-write). Paths like `/app` are included in the baseline set but are only added if they exist in the container image. + +This filtering prevents a missing baseline path from degrading Landlock enforcement. Without it, a single missing path could cause the entire Landlock ruleset to fail, leaving the sandbox with no filesystem restrictions at all. + +User-specified paths in your policy YAML are not pre-filtered. If you list a path that does not exist: + +- In `best_effort` mode, the path is skipped with a warning and remaining rules are still applied. +- In `hard_requirement` mode, sandbox startup fails immediately. + +This distinction means baseline system paths degrade gracefully while user-specified paths surface configuration errors. + ## Apply a Custom Policy Pass a policy YAML file when creating the sandbox: From b99c07c72bf1240f4ea24026e6172875ba413e59 Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 30 Mar 2026 12:14:18 -0700 Subject: [PATCH 2/2] 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. --- .../src/sandbox/linux/landlock.rs | 34 +++++++++++++++---- e2e/rust/tests/custom_image.rs | 6 ++-- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index 8dc058f8..abb91fd4 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -120,14 +120,36 @@ fn try_open_path(path: &Path, compatibility: &LandlockCompatibility) -> Result Ok(Some(fd)), Err(err) => { let reason = classify_path_fd_error(&err); + let is_not_found = matches!( + &err, + PathFdError::OpenCall { source, .. } + if source.kind() == std::io::ErrorKind::NotFound + ); match compatibility { LandlockCompatibility::BestEffort => { - warn!( - path = %path.display(), - error = %err, - reason = reason, - "Skipping inaccessible Landlock path (best-effort mode)" - ); + // NotFound is expected for stale baseline paths (e.g. + // /app baked into the server-stored policy but absent + // in this container image). Log at debug! to avoid + // polluting SSH exec stdout — the pre_exec hook + // inherits the tracing subscriber whose writer targets + // fd 1 (the pipe/PTY). + // + // Other errors (permission denied, symlink loops, etc.) + // are genuinely unexpected and logged at warn!. + if is_not_found { + debug!( + path = %path.display(), + reason, + "Skipping non-existent Landlock path (best-effort mode)" + ); + } else { + warn!( + path = %path.display(), + error = %err, + reason, + "Skipping inaccessible Landlock path (best-effort mode)" + ); + } Ok(None) } LandlockCompatibility::HardRequirement => Err(miette::miette!( diff --git a/e2e/rust/tests/custom_image.rs b/e2e/rust/tests/custom_image.rs index 14fc3f47..10cf9909 100644 --- a/e2e/rust/tests/custom_image.rs +++ b/e2e/rust/tests/custom_image.rs @@ -26,7 +26,9 @@ RUN groupadd -g 1000 sandbox && \ useradd -m -u 1000 -g sandbox sandbox # Write a marker file so we can verify this is our custom image. -RUN echo "custom-image-e2e-marker" > /opt/marker.txt +# Place under /etc (Landlock baseline read-only path) so the sandbox +# can read it when filesystem restrictions are properly enforced. +RUN echo "custom-image-e2e-marker" > /etc/marker.txt CMD ["sleep", "infinity"] "#; @@ -53,7 +55,7 @@ async fn sandbox_from_custom_dockerfile() { dockerfile_str, "--", "cat", - "/opt/marker.txt", + "/etc/marker.txt", ]) .await .expect("sandbox create from Dockerfile");