Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/openshell-driver-podman/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ openshell-otel-test-support = { path = "../openshell-otel-test-support" }
opentelemetry_sdk = { workspace = true, features = ["testing"] }
prost-types = { workspace = true }
temp-env = "0.3"
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }

[lints]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,69 +1,43 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

#![cfg(feature = "e2e-podman")]

//! Podman driver daemon-unavailable e2e tests.
//! Podman driver daemon-unavailable integration tests.
//!
//! These tests verify that `openshell-driver-podman` fails fast with an
//! actionable error when it cannot reach a Podman API socket, instead of
//! hanging or silently serving gRPC against a dead connection.
//!
//! The tests do NOT require a running Podman daemon or gateway — they point
//! They do NOT require a running Podman daemon or gateway — they point
//! `--podman-socket` at a path that is guaranteed not to exist to simulate
//! the daemon being unavailable.
//! the daemon being unavailable. As a plain Cargo integration test in this
//! crate, this runs via the normal `cargo test -p openshell-driver-podman`
//! lane with no special CI wiring: Cargo provides `CARGO_BIN_EXE_<name>` for
//! this crate's own `[[bin]]` target automatically.

use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::time::{Duration, Instant};

use openshell_e2e::harness::output::strip_ansi;

/// Locate the workspace root by walking up from this crate's manifest directory.
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("failed to resolve workspace root from CARGO_MANIFEST_DIR")
.to_path_buf()
}

/// Return the path to the `openshell-driver-podman` binary.
///
/// Uses `OPENSHELL_EXTERNAL_DRIVER_BIN` when set (the same env var the shell
/// e2e harness uses for prebuilt standalone driver artifacts), otherwise
/// expects the binary at `<workspace>/target/debug/openshell-driver-podman`.
fn driver_podman_bin() -> PathBuf {
let bin = std::env::var_os("OPENSHELL_EXTERNAL_DRIVER_BIN").map_or_else(
|| workspace_root().join("target/debug/openshell-driver-podman"),
PathBuf::from,
);
assert!(
bin.is_file(),
"openshell-driver-podman binary not found at {} — set OPENSHELL_EXTERNAL_DRIVER_BIN \
or run `cargo build -p openshell-driver-podman` first",
bin.display()
);
bin
}

/// Run `openshell-driver-podman` pointed at a Podman socket that does not
/// exist, and wait for it to exit.
///
/// The driver retries a handful of times before giving up (to tolerate the
/// socket briefly re-activating), so this can take several seconds.
async fn run_with_unreachable_podman_socket() -> (String, i32, Duration, PathBuf) {
let tmpdir = tempfile::tempdir().expect("create isolated socket dir");
let missing_socket = tmpdir.path().join("openshell-e2e-nonexistent-podman.sock");
// Use a short relative path so miette cannot insert a line-wrap gutter
// inside it on platforms with long temporary-directory paths.
let missing_socket = PathBuf::from("missing-podman.sock");

let start = Instant::now();
let mut cmd = tokio::process::Command::new(driver_podman_bin());
let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell-driver-podman"));
cmd.arg("--podman-socket")
.arg(&missing_socket)
.current_dir(tmpdir.path())
.kill_on_drop(true)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());

let output = tokio::time::timeout(Duration::from_secs(60), cmd.output())
let output = tokio::time::timeout(Duration::from_mins(1), cmd.output())
.await
.expect("openshell-driver-podman should exit instead of hanging")
.expect("spawn openshell-driver-podman");
Expand Down Expand Up @@ -102,15 +76,14 @@ async fn driver_error_names_unreachable_socket() {
let (output, code, _, missing_socket) = run_with_unreachable_podman_socket().await;

assert_ne!(code, 0);
let clean = strip_ansi(&output);

assert!(
clean.contains("connection error"),
"driver error should describe a connection failure:\n{clean}"
output.contains("connection error"),
"driver error should describe a connection failure:\n{output}"
);
assert!(
clean.contains(missing_socket.to_str().expect("socket path is utf-8")),
"driver error should name the unreachable socket path {}:\n{clean}",
output.contains(missing_socket.to_str().expect("socket path is utf-8")),
"driver error should name the unreachable socket path {}:\n{output}",
missing_socket.display()
);
}
103 changes: 59 additions & 44 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,61 @@ pub fn validate_sandbox_policy(
validate_sandbox_policy_with_mcp_presence(policy, McpVersionPresence::RequireMaterialized)
}

/// Validate filesystem paths shared by typed policies and raw OPA data.
///
/// Paths must be absolute, contain no parent traversal, and fit the path count
/// and byte limits. Read-write access to the filesystem root is forbidden.
/// Callers that expose errors outside trusted authoring tools must redact the
/// path values carried by the returned violations.
pub fn validate_filesystem_paths(
read_only: &[String],
read_write: &[String],
) -> std::result::Result<(), Vec<PolicyViolation>> {
let mut violations = Vec::new();
let total_paths = read_only.len() + read_write.len();
if total_paths > MAX_FILESYSTEM_PATHS {
violations.push(PolicyViolation::TooManyPaths { count: total_paths });
}

for path_str in read_only.iter().chain(read_write.iter()) {
if path_str.len() > MAX_PATH_LENGTH {
violations.push(PolicyViolation::FieldTooLong {
path: truncate_for_display(path_str),
length: path_str.len(),
});
continue;
}
let path = Path::new(path_str);
if !path.has_root() {
violations.push(PolicyViolation::RelativePath {
path: path_str.clone(),
});
}
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
violations.push(PolicyViolation::PathTraversal {
path: path_str.clone(),
});
}
}

for path_str in read_write {
// Repeated separators still designate the filesystem root.
if path_str.trim_end_matches('/').is_empty() {
violations.push(PolicyViolation::OverlyBroadPath {
path: path_str.clone(),
});
}
}
if violations.is_empty() {
Ok(())
} else {
Err(violations)
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum McpVersionPresence {
RequireMaterialized,
Expand Down Expand Up @@ -1389,50 +1444,10 @@ fn validate_sandbox_policy_with_mcp_presence(
});
}

// Check filesystem paths
if let Some(ref fs) = policy.filesystem {
let total_paths = fs.read_only.len() + fs.read_write.len();
if total_paths > MAX_FILESYSTEM_PATHS {
violations.push(PolicyViolation::TooManyPaths { count: total_paths });
}

for path_str in fs.read_only.iter().chain(fs.read_write.iter()) {
if path_str.len() > MAX_PATH_LENGTH {
violations.push(PolicyViolation::FieldTooLong {
path: truncate_for_display(path_str),
length: path_str.len(),
});
continue;
}

let path = Path::new(path_str);

if !path.has_root() {
violations.push(PolicyViolation::RelativePath {
path: path_str.clone(),
});
}

if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
violations.push(PolicyViolation::PathTraversal {
path: path_str.clone(),
});
}
}

// Only reject "/" as read-write (overly broad)
for path_str in &fs.read_write {
let normalized = path_str.trim_end_matches('/');
if normalized.is_empty() {
// Path is "/" or "///" etc.
violations.push(PolicyViolation::OverlyBroadPath {
path: path_str.clone(),
});
}
}
if let Some(ref fs) = policy.filesystem
&& let Err(errors) = validate_filesystem_paths(&fs.read_only, &fs.read_write)
{
violations.extend(errors);
}

// Protobuf maps do not preserve iteration order. Sort rule keys so callers
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-supervisor-network/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ openshell-core = { path = "../openshell-core", features = ["oauth"] }
openshell-isolation-interface = { path = "../openshell-isolation-interface" }
openshell-ocsf = { path = "../openshell-ocsf" }
openshell-policy = { path = "../openshell-policy" }
openshell-policy-schema = { path = "../openshell-policy-schema" }
openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" }

async-trait = "0.1"
Expand Down
Loading
Loading