Files
OpenShell/e2e/rust/tests/custom_image.rs
John T. MyersandJohn Myers 758c62d18d 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>
2026-03-30 13:19:19 -07:00

73 lines
2.5 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
#![cfg(feature = "e2e")]
//! E2E test: build a custom container image and run a sandbox with it.
//!
//! Prerequisites:
//! - A running openshell gateway (`openshell gateway start`)
//! - Docker daemon running (for image build)
//! - The `openshell` binary (built automatically from the workspace)
use std::io::Write;
use openshell_e2e::harness::output::strip_ansi;
use openshell_e2e::harness::sandbox::SandboxGuard;
const DOCKERFILE_CONTENT: &str = r#"FROM public.ecr.aws/docker/library/python:3.13-slim
# iproute2 is required for sandbox network namespace isolation.
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \
&& rm -rf /var/lib/apt/lists/*
# Create the sandbox user/group so the supervisor can switch to it.
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.
# 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"]
"#;
const MARKER: &str = "custom-image-e2e-marker";
/// Build a custom Docker image from a Dockerfile and verify that a sandbox
/// created from it contains the expected marker file.
#[tokio::test]
async fn sandbox_from_custom_dockerfile() {
// Step 1 — Write a temporary Dockerfile.
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let dockerfile_path = tmpdir.path().join("Dockerfile");
{
let mut f = std::fs::File::create(&dockerfile_path).expect("create Dockerfile");
f.write_all(DOCKERFILE_CONTENT.as_bytes())
.expect("write Dockerfile");
}
// Step 2 — Create a sandbox from the Dockerfile.
let dockerfile_str = dockerfile_path.to_str().expect("Dockerfile path is UTF-8");
let mut guard = SandboxGuard::create(&[
"--from",
dockerfile_str,
"--",
"cat",
"/etc/marker.txt",
])
.await
.expect("sandbox create from Dockerfile");
// Step 3 — Verify the marker file content appears in the output.
let clean_output = strip_ansi(&guard.create_output);
assert!(
clean_output.contains(MARKER),
"expected marker '{MARKER}' in sandbox output:\n{clean_output}"
);
// Explicit cleanup (also happens in Drop, but explicit is clearer in tests).
guard.cleanup().await;
}