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.

10 changes: 10 additions & 0 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ template resource limits. Docker and Podman apply them as runtime limits.
Kubernetes mirrors each limit into the matching request. VM accepts the fields
but currently ignores them.

Docker and Podman also accept per-sandbox driver-config mounts for existing
runtime-managed named volumes and tmpfs mounts. Podman additionally accepts
image mounts through its image-volume API. User-supplied bind and volume mounts
default to read-only. Direct host bind mounts, and Docker local-driver
bind-backed named volumes, are available only when explicitly enabled in the
active local driver table of `gateway.toml`. Host bind mounts are an unsafe
operator override because they place gateway-host filesystem state inside the
sandbox and can negate OpenShell workspace isolation and filesystem-policy
controls. Driver-owned supervisor, token, and TLS bind mounts stay reserved.

Kubernetes deployments may set an AppArmor profile on sandbox agent containers
through the driver configuration. The Helm chart defaults sandbox agents to
`Unconfined` so runtime/default AppArmor profiles do not block supervisor
Expand Down
153 changes: 153 additions & 0 deletions crates/openshell-core/src/driver_mounts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Shared validation helpers for driver-config mounts.

use std::path::Path;

const RESERVED_MOUNT_TARGETS: &[&str] = &[
"/opt/openshell",
"/etc/openshell",
"/etc/openshell-tls",
"/run/netns",
];

/// Validate a non-empty driver mount source.
pub fn validate_mount_source(source: &str, field: &str) -> Result<String, String> {
let source = source.trim();
if source.is_empty() {
return Err(format!("{field} must not be empty"));
}
if source.as_bytes().contains(&0) {
return Err(format!("{field} must not contain NUL bytes"));
}
Ok(source.to_string())
}

/// Validate a bind mount source as an absolute host path.
pub fn validate_absolute_mount_source(source: &str, field: &str) -> Result<String, String> {
let source = validate_mount_source(source, field)?;
if !Path::new(&source).is_absolute() {
return Err(format!("{field} must be an absolute host path"));
}
Ok(source)
}

/// Validate a relative subpath inside a runtime-managed mount source.
pub fn validate_mount_subpath(subpath: &str) -> Result<String, String> {
let subpath = subpath.trim();
if subpath.is_empty() {
return Err("mount subpath must not be empty".to_string());
}
if subpath.as_bytes().contains(&0) {
return Err("mount subpath must not contain NUL bytes".to_string());
}
let path = Path::new(subpath);
if path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err("mount subpath must be relative and must not contain '..'".to_string());
}
Ok(subpath.to_string())
}

/// Validate a container-side mount target for user-supplied driver mounts.
pub fn validate_container_mount_target(target: &str) -> Result<String, String> {
let target = normalize_container_mount_target(target);
if target.is_empty() {
return Err("mount target must not be empty".to_string());
}
if target.as_bytes().contains(&0) {
return Err("mount target must not contain NUL bytes".to_string());
}
if !target.starts_with('/') {
return Err("mount target must be an absolute container path".to_string());
}
if target == "/" {
return Err("mount target must not be the container root".to_string());
}
let path = Path::new(&target);
if path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err("mount target must not contain '..'".to_string());
}
if target == "/sandbox" {
return Err("mount target '/sandbox' is reserved for the OpenShell workspace".to_string());
}
for reserved in RESERVED_MOUNT_TARGETS {
if path_is_or_under(&target, reserved) {
return Err(format!(
"mount target '{target}' conflicts with reserved OpenShell path '{reserved}'"
));
}
}
Ok(target)
}

fn normalize_container_mount_target(target: &str) -> String {
let target = target.trim();
if target == "/" {
return target.to_string();
}
target.trim_end_matches('/').to_string()
}

fn path_is_or_under(path: &str, parent: &str) -> bool {
path == parent
|| path
.strip_prefix(parent)
.is_some_and(|rest| rest.starts_with('/'))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn container_target_allows_paths_under_workspace() {
assert_eq!(
validate_container_mount_target("/sandbox/work/").unwrap(),
"/sandbox/work"
);
}

#[test]
fn container_target_rejects_workspace_root_only() {
let err = validate_container_mount_target("/sandbox/").unwrap_err();

assert!(err.contains("reserved for the OpenShell workspace"));
}

#[test]
fn container_target_rejects_reserved_openshell_tls_legacy_path() {
let err = validate_container_mount_target("/etc/openshell-tls/client").unwrap_err();

assert!(err.contains("/etc/openshell-tls"));
}

#[test]
fn container_target_rejects_reserved_openshell_tree() {
let err = validate_container_mount_target("/etc/openshell/tls/client").unwrap_err();

assert!(err.contains("/etc/openshell"));
}

#[test]
fn container_target_does_not_prefix_match_unrelated_paths() {
assert_eq!(
validate_container_mount_target("/etc/openshell-tools").unwrap(),
"/etc/openshell-tools"
);
}

#[test]
fn mount_subpath_must_be_relative_without_parent_dirs() {
assert_eq!(validate_mount_subpath(" project/a ").unwrap(), "project/a");
assert!(validate_mount_subpath("/project").is_err());
assert!(validate_mount_subpath("../project").is_err());
}
}
2 changes: 2 additions & 0 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

pub mod auth;
pub mod config;
pub mod driver_mounts;
pub mod driver_utils;
pub mod error;
pub mod forward;
Expand All @@ -22,6 +23,7 @@ pub mod net;
pub mod paths;
pub mod progress;
pub mod proto;
pub mod proto_struct;
pub mod sandbox_env;
pub mod settings;
pub mod telemetry;
Expand Down
40 changes: 40 additions & 0 deletions crates/openshell-core/src/proto_struct.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Helpers for decoding `google.protobuf.Struct` values.

/// Convert a protobuf Struct into a JSON object for typed serde decoding.
#[must_use]
pub fn struct_to_json_object(
config: &prost_types::Struct,
) -> serde_json::Map<String, serde_json::Value> {
config
.fields
.iter()
.map(|(key, value)| (key.clone(), value_to_json(value)))
.collect()
}

/// Convert a protobuf Struct into a JSON value for typed serde decoding.
#[must_use]
pub fn struct_to_json_value(config: &prost_types::Struct) -> serde_json::Value {
serde_json::Value::Object(struct_to_json_object(config))
}

/// Convert a protobuf Value into a JSON value for typed serde decoding.
#[must_use]
pub fn value_to_json(value: &prost_types::Value) -> serde_json::Value {
match value.kind.as_ref() {
Some(prost_types::value::Kind::NumberValue(num)) => serde_json::Number::from_f64(*num)
.map_or(serde_json::Value::Null, serde_json::Value::Number),
Some(prost_types::value::Kind::StringValue(val)) => serde_json::Value::String(val.clone()),
Some(prost_types::value::Kind::BoolValue(val)) => serde_json::Value::Bool(*val),
Some(prost_types::value::Kind::StructValue(val)) => {
serde_json::Value::Object(struct_to_json_object(val))
}
Some(prost_types::value::Kind::ListValue(list)) => {
serde_json::Value::Array(list.values.iter().map(value_to_json).collect())
}
Some(prost_types::value::Kind::NullValue(_)) | None => serde_json::Value::Null,
}
}
1 change: 1 addition & 0 deletions crates/openshell-driver-docker/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ tracing = { workspace = true }
bytes = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
prost-types = { workspace = true }
bollard = { version = "0.20" }
tar = "0.4"
tempfile = "3"
Expand Down
37 changes: 37 additions & 0 deletions crates/openshell-driver-docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,43 @@ contract:

The agent child process does not retain these supervisor privileges.

## Driver Config Mounts

The gateway forwards the `docker` block from `--driver-config-json` to this
driver. The driver accepts user-supplied `mounts` entries with these Docker
mount types:

- `bind`: mounts an absolute host path when `[openshell.drivers.docker]`
has `enable_bind_mounts = true`.
- `volume`: mounts an existing Docker named volume. The driver validates that
the volume exists before provisioning and never creates or removes it.
Docker local-driver volumes created with bind options are treated as host
bind mounts and require `enable_bind_mounts = true`.
- `tmpfs`: mounts an in-memory filesystem with optional `options`,
`size_bytes`, and `mode`.

Host bind mounts are disabled by default because they expose gateway host
paths to sandbox requests. Image mounts are not part of the Docker
driver-config schema. The driver still uses internal bind mounts for
OpenShell-owned supervisor, token, and TLS material.

Docker `bind` mounts accept `source`, `target`, and optional `read_only`.
Docker `volume` mounts may include `subpath`. User-supplied bind and volume
mounts are read-only by default; set `read_only: false` to make them writable.
Mount targets must be absolute container paths and must not replace the
workspace root (`/sandbox`) or overlap OpenShell supervisor files,
`/etc/openshell`, `/etc/openshell-tls`, or `/run/netns`.

Example named-volume usage:

```shell
docker volume create openshell-work

openshell sandbox create \
--driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work"}]}}' \
-- claude
```

## Supervisor Binary Resolution

The Docker driver bind-mounts a host-side Linux `openshell-sandbox` binary into
Expand Down
Loading
Loading