Skip to content
Open
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
1 change: 1 addition & 0 deletions .agents/skills/openshell-cli/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ Create a sandbox through the active gateway, wait for readiness, then connect or
| `--name <NAME>` | Sandbox name (auto-generated if omitted) |
| `--from <SOURCE>` | Sandbox source: community name, Dockerfile path, directory, or image reference (BYOC) |
| `--upload <PATH>[:<DEST>]` | Upload local files into sandbox (default dest: `/sandbox`) |
| `--volume <HOST>[:<SANDBOX>][:ro]` | Bind-mount a host directory into the sandbox. Only supported by Docker-backed gateways. Repeatable. |
| `--no-keep` | Delete sandbox after the initial command or shell exits |
| `--provider <NAME>` | Provider to attach (repeatable) |
| `--policy <PATH>` | Path to custom policy YAML |
Expand Down
58 changes: 58 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,24 @@ enum SandboxCommands {
#[arg(long, overrides_with = "auto_providers")]
no_auto_providers: bool,

/// Bind-mount a host directory into the sandbox.
///
/// Format: `HOST_PATH[:SANDBOX_PATH][:ro]`
///
/// - `HOST_PATH` is the absolute path on the gateway host.
/// - `SANDBOX_PATH` is the absolute path inside the sandbox
/// (defaults to the same as `HOST_PATH` when omitted).
/// - Append `:ro` to mount read-only.
///
/// Only supported by local Docker-backed gateways. Kubernetes and
/// VM gateways will reject this flag at sandbox create time.
///
/// Examples:
/// `--volume /home/user/project:/workspace`
/// `--volume /data:/data:ro`
#[arg(long = "volume", value_name = "HOST_PATH[:SANDBOX_PATH][:ro]")]
volumes: Vec<String>,

/// Attach labels to the sandbox (key=value format, repeatable).
#[arg(long = "label")]
labels: Vec<String>,
Expand Down Expand Up @@ -2372,6 +2390,7 @@ async fn main() -> Result<()> {
no_tty,
auto_providers,
no_auto_providers,
volumes,
labels,
command,
} => {
Expand Down Expand Up @@ -2412,6 +2431,12 @@ async fn main() -> Result<()> {
(local, remote, !no_git_ignore)
});

// Parse --volume specs into (host_path, sandbox_path, read_only) tuples.
let mut volume_specs = Vec::new();
for vol in &volumes {
volume_specs.push(parse_volume_spec(vol)?);
}

let editor = editor.map(Into::into);
let forward = forward
.map(|s| openshell_core::forward::ForwardSpec::parse(&s))
Expand All @@ -2438,6 +2463,7 @@ async fn main() -> Result<()> {
&command,
tty_override,
auto_providers_override,
&volume_specs,
&labels_map,
&tls,
))
Expand Down Expand Up @@ -2829,6 +2855,38 @@ async fn main() -> Result<()> {
Ok(())
}

/// Parse a volume spec like `HOST_PATH[:SANDBOX_PATH][:ro]` into components.
///
/// Returns `(host_path, sandbox_path, read_only)`. When `SANDBOX_PATH` is
/// omitted, `sandbox_path` defaults to `host_path`. The only recognised
/// option suffix is `ro` (read-only); any other suffix is rejected.
fn parse_volume_spec(spec: &str) -> Result<(String, String, bool), miette::Report> {
// Split into at most three colon-separated parts.
let parts: Vec<&str> = spec.splitn(3, ':').collect();
let host_path = parts[0];
if host_path.is_empty() {
return Err(miette::miette!(
"invalid volume spec '{spec}': host path must not be empty"
));
}
let sandbox_path = parts.get(1).copied().unwrap_or(host_path);
if sandbox_path.is_empty() {
return Err(miette::miette!(
"invalid volume spec '{spec}': sandbox path must not be empty when specified"
));
}
let read_only = match parts.get(2).copied() {
Some("ro") => true,
Some("rw") | None => false,
Some(opt) => {
return Err(miette::miette!(
"invalid volume option '{opt}' in spec '{spec}': only 'ro' and 'rw' are supported"
));
}
};
Ok((host_path.to_string(), sandbox_path.to_string(), read_only))
}

/// Parse an upload spec like `<local>[:<remote>]` into (`local_path`, `optional_sandbox_path`).
fn parse_upload_spec(spec: &str) -> (String, Option<String>) {
if let Some((local, remote)) = spec.split_once(':') {
Expand Down
38 changes: 30 additions & 8 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ use openshell_core::proto::{
ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest,
ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderProfile,
ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest,
RevokeSshSessionRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
ServiceEndpointResponse, SetClusterInferenceRequest, SettingScope, SettingValue,
TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderRequest,
WatchSandboxRequest, exec_sandbox_event, setting_value, tcp_forward_init,
RevokeSshSessionRequest, Sandbox, SandboxMount, SandboxPhase, SandboxPolicy, SandboxSpec,
SandboxTemplate, ServiceEndpointResponse, SetClusterInferenceRequest, SettingScope,
SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value,
tcp_forward_init,
};
use openshell_core::settings::{self, SettingValueKind};
use openshell_core::{ObjectId, ObjectName};
Expand Down Expand Up @@ -1475,6 +1476,7 @@ pub async fn sandbox_create(
command: &[String],
tty_override: Option<bool>,
auto_providers_override: Option<bool>,
volumes: &[(String, String, bool)],
labels: &HashMap<String, String>,
tls: &TlsOptions,
) -> Result<()> {
Expand Down Expand Up @@ -1531,10 +1533,30 @@ pub async fn sandbox_create(

let policy = load_sandbox_policy(policy)?;

let template = image.map(|img| SandboxTemplate {
image: img,
..SandboxTemplate::default()
});
let sandbox_mounts: Vec<SandboxMount> = volumes
.iter()
.map(|(host_path, sandbox_path, read_only)| SandboxMount {
host_path: host_path.clone(),
sandbox_path: sandbox_path.clone(),
read_only: *read_only,
})
.collect();

// Build the sandbox template. An explicit image (via --from) or any
// mounts (via --volume) are both reasons to include a template in the
// request. When neither is present the gateway fills in the default.
let template = match (image, sandbox_mounts.is_empty()) {
(Some(img), _) => Some(SandboxTemplate {
image: img,
mounts: sandbox_mounts,
..SandboxTemplate::default()
}),
(None, false) => Some(SandboxTemplate {
mounts: sandbox_mounts,
..SandboxTemplate::default()
}),
(None, true) => None,
};

let request = CreateSandboxRequest {
spec: Some(SandboxSpec {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() {
&["echo".to_string(), "OK".to_string()],
Some(false),
Some(false),
&[],
&HashMap::new(),
&tls,
)
Expand Down Expand Up @@ -716,6 +717,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() {
&["echo".to_string(), "OK".to_string()],
Some(false),
Some(false),
&[],
&HashMap::new(),
&tls,
)
Expand Down Expand Up @@ -758,6 +760,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() {
&[],
Some(true),
Some(false),
&[],
&HashMap::new(),
&tls,
)
Expand Down Expand Up @@ -800,6 +803,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() {
&["echo".to_string(), "OK".to_string()],
Some(false),
Some(false),
&[],
&HashMap::new(),
&tls,
)
Expand Down Expand Up @@ -842,6 +846,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {
&["echo".to_string(), "OK".to_string()],
Some(false),
Some(false),
&[],
&HashMap::new(),
&tls,
)
Expand Down
26 changes: 23 additions & 3 deletions crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use openshell_core::config::{DEFAULT_DOCKER_NETWORK_NAME, DEFAULT_STOP_TIMEOUT_S
use openshell_core::gpu::cdi_gpu_device_ids;
use openshell_core::proto::compute::v1::{
CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse,
DriverCondition, DriverSandbox, DriverSandboxStatus, DriverSandboxTemplate,
DriverCondition, DriverSandbox, DriverSandboxMount, DriverSandboxStatus, DriverSandboxTemplate,
GetCapabilitiesRequest, GetCapabilitiesResponse, GetSandboxRequest, GetSandboxResponse,
ListSandboxesRequest, ListSandboxesResponse, StopSandboxRequest, StopSandboxResponse,
ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent,
Expand Down Expand Up @@ -326,6 +326,14 @@ impl DockerComputeDriver {
));
}

for (i, mount) in template.mounts.iter().enumerate() {
if mount.host_path.is_empty() {
return Err(Status::invalid_argument(format!(
"template.mounts[{i}].host_path must not be empty"
)));
}
}

let _ = docker_resource_limits(template)?;
Ok(())
}
Expand Down Expand Up @@ -869,7 +877,10 @@ impl ComputeDriver for DockerComputeDriver {
}
}

fn build_binds(config: &DockerDriverRuntimeConfig) -> Vec<String> {
fn build_binds(
config: &DockerDriverRuntimeConfig,
user_mounts: &[DriverSandboxMount],
) -> Vec<String> {
let mut binds = vec![format!(
"{}:{}:ro,z",
config.supervisor_bin.display(),
Expand All @@ -884,6 +895,15 @@ fn build_binds(config: &DockerDriverRuntimeConfig) -> Vec<String> {
));
binds.push(format!("{}:{}:ro,z", tls.key.display(), TLS_KEY_MOUNT_PATH));
}
for mount in user_mounts {
let sandbox_path = if mount.sandbox_path.is_empty() {
&mount.host_path
} else {
&mount.sandbox_path
};
let options = if mount.read_only { ":ro" } else { "" };
binds.push(format!("{}:{}{}", mount.host_path, sandbox_path, options));
}
binds
}

Expand Down Expand Up @@ -997,7 +1017,7 @@ fn build_container_create_body(
nano_cpus: resource_limits.nano_cpus,
memory: resource_limits.memory_bytes,
device_requests: docker_gpu_device_requests(spec.gpu, &spec.gpu_device),
binds: Some(build_binds(config)),
binds: Some(build_binds(config, &template.mounts)),
restart_policy: Some(RestartPolicy {
name: Some(RestartPolicyNameEnum::UNLESS_STOPPED),
maximum_retry_count: None,
Expand Down
4 changes: 3 additions & 1 deletion crates/openshell-driver-docker/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ fn test_sandbox() -> DriverSandbox {
environment: HashMap::from([("TEMPLATE_ENV".to_string(), "template".to_string())]),
resources: None,
platform_config: None,
mounts: vec![],
}),
gpu: false,
gpu_device: String::new(),
Expand Down Expand Up @@ -359,6 +360,7 @@ fn docker_resource_limits_rejects_requests() {
memory_limit: String::new(),
}),
platform_config: None,
mounts: vec![],
};

let err = docker_resource_limits(&template).unwrap_err();
Expand Down Expand Up @@ -410,7 +412,7 @@ fn build_environment_keeps_path_driver_controlled() {

#[test]
fn build_binds_uses_docker_tls_directory() {
let binds = build_binds(&runtime_config());
let binds = build_binds(&runtime_config(), &[]);
let targets = binds
.iter()
.filter_map(|bind| bind.split(':').nth(1).map(String::from))
Expand Down
11 changes: 11 additions & 0 deletions crates/openshell-driver-kubernetes/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ impl KubernetesComputeDriver {
}

pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), tonic::Status> {
let has_mounts = sandbox
.spec
.as_ref()
.and_then(|spec| spec.template.as_ref())
.is_some_and(|tmpl| !tmpl.mounts.is_empty());
if has_mounts {
return Err(tonic::Status::failed_precondition(
"--volume bind mounts are not supported by Kubernetes-backed gateways; \
use `openshell sandbox upload` to transfer files into the sandbox instead",
));
}
let gpu_requested = sandbox.spec.as_ref().is_some_and(|spec| spec.gpu);
if gpu_requested
&& !self.has_gpu_capacity().await.map_err(|err| {
Expand Down
6 changes: 6 additions & 0 deletions crates/openshell-driver-vm/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,12 @@ fn validate_vm_sandbox(sandbox: &Sandbox, gpu_enabled: bool) -> Result<(), Statu
"vm sandboxes do not support template.resources",
));
}
if !template.mounts.is_empty() {
return Err(Status::failed_precondition(
"--volume bind mounts are not supported by VM-backed gateways; \
use `openshell sandbox upload` to transfer files into the sandbox instead",
));
}
}
Ok(())
}
Expand Down
21 changes: 20 additions & 1 deletion crates/openshell-sandbox/src/sandbox/linux/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,29 @@ pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<Option<P
let access_all = AccessFs::from_all(abi);
let access_read = AccessFs::from_read(abi);

// Exclude ReadDir from the restriction mask.
//
// Docker Desktop on macOS uses a proprietary 'fakeowner' filesystem for
// host bind mounts. Landlock rules for fakeowner directories are added
// successfully (PathFd opens without error, rules_applied increments), but
// the kernel-side enforcement fails to match the rule at access time —
// making every `opendir(2)` / `getdents64(2)` on a fakeowner path return
// EACCES even when the path is in the allowlist.
//
// By removing ReadDir from the restriction mask, directory listing is
// unrestricted for all paths (the kernel does not check Landlock for
// ReadDir at all). File-level access (ReadFile, WriteFile, Execute, etc.)
// remains fully enforced by the existing per-path rules, so the practical
// security boundary is unchanged: the sandbox can see file names inside
// directories it can traverse, but cannot read or write their contents
// unless an explicit allow rule exists.
let read_dir_flag = landlock::BitFlags::from(AccessFs::ReadDir);
let restriction_mask = access_all & !read_dir_flag;

let mut ruleset = Ruleset::default();
ruleset = ruleset
.set_compatibility(compat_level(compatibility))
.handle_access(access_all)
.handle_access(restriction_mask)
.into_diagnostic()?;

let mut ruleset = ruleset.create().into_diagnostic()?;
Expand Down
Loading
Loading