Skip to content

Commit 0af4bad

Browse files
fix(cli): address workspace review feedback
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
1 parent 7cd2bbc commit 0af4bad

12 files changed

Lines changed: 98 additions & 109 deletions

File tree

.agents/skills/openshell-cli/SKILL.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,17 +223,19 @@ openshell sandbox ssh-config my-sandbox >> ~/.ssh/config
223223
# Upload local files to the sandbox working directory
224224
openshell sandbox upload my-sandbox ./src
225225

226-
# Download files from sandbox (replace /workspace with the path from `pwd -P`)
227-
openshell sandbox download my-sandbox /workspace/output ./local-output
226+
# Download a path relative to the sandbox working directory
227+
openshell sandbox download my-sandbox output ./local-output
228228
```
229229

230230
Uploads honor `.gitignore` by default. Add `--no-git-ignore` only when ignored files are intentionally in scope.
231231

232232
Uploads preserve symlinks, including dangling symlinks, instead of dereferencing their targets. A symlink source bypasses Git-aware filtering so the link itself is archived.
233233

234234
When the upload destination is omitted, the CLI discovers the remote working
235-
directory. Downloads must use an absolute sandbox path within that same
236-
canonical working directory.
235+
directory. Uploading a named directory merges it into an existing directory of
236+
the same name, overwriting matching entries without deleting unrelated entries.
237+
Downloads accept paths relative to that working directory or absolute paths
238+
within it.
237239

238240
### Execute a non-interactive command
239241

.agents/skills/openshell-cli/cli-reference.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,11 @@ Open an interactive SSH shell. The name defaults to the last-used sandbox. `--ed
258258

259259
### `openshell sandbox upload <name> <path> [dest]`
260260

261-
Upload files using tar-over-SSH. The CLI discovers the canonical remote working directory when the destination is omitted. `.gitignore` filtering is enabled unless `--no-git-ignore` is passed.
261+
Upload files using tar-over-SSH. The CLI discovers the canonical remote working directory when the destination is omitted. A named directory merges into an existing directory of the same name, overwriting matching entries without deleting unrelated entries. `.gitignore` filtering is enabled unless `--no-git-ignore` is passed.
262262

263263
### `openshell sandbox download <name> <path> [dest]`
264264

265-
Download files using tar-over-SSH. The sandbox source must be an absolute path within the canonical remote working directory. The local destination defaults to `.`.
265+
Download files using tar-over-SSH. The sandbox source may be relative to the canonical remote working directory or an absolute path within it. The local destination defaults to `.`.
266266

267267
### `openshell sandbox ssh-config [name]`
268268

crates/openshell-cli/src/ssh.rs

Lines changed: 39 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ use crate::tls::{TlsOptions, grpc_client};
77
use miette::{IntoDiagnostic, Result, WrapErr};
88
#[cfg(unix)]
99
use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction};
10-
use openshell_core::ObjectId;
1110
use openshell_core::forward::{
1211
ForwardSpec, build_proxy_command, format_gateway_url, resolve_ssh_gateway, shell_escape,
1312
validate_ssh_session_response, write_forward_pid,
@@ -16,6 +15,7 @@ use openshell_core::proto::{
1615
CreateSshSessionRequest, GetSandboxRequest, SshRelayTarget, TcpForwardFrame, TcpForwardInit,
1716
tcp_forward_init,
1817
};
18+
use openshell_core::{ObjectId, driver_mounts};
1919
use owo_colors::OwoColorize;
2020
use std::fs;
2121
use std::future::Future;
@@ -867,36 +867,36 @@ fn lexical_clean_absolute_path(path: &str) -> Option<String> {
867867
Some(out)
868868
}
869869

870-
/// Validate that a sandbox-side source path passed to `sandbox download`
871-
/// resolves under the sandbox writable root.
870+
/// Resolve a sandbox-side source path passed to `sandbox download` under the
871+
/// sandbox writable root.
872872
///
873873
/// Returns the cleaned, traversal-resolved path on success. Refuses any
874-
/// path that lexically escapes the discovered workspace root with a
875-
/// user-facing error.
874+
/// path that lexically escapes the discovered workspace root with a user-facing
875+
/// error. Relative paths are interpreted from the workspace root.
876876
///
877877
/// This is a lexical guard only — it does not follow symlinks. Call
878878
/// `resolve_sandbox_source_path` after this on any path that will be passed
879879
/// to a subsequent SSH I/O operation, so a workspace symlink to `/etc` cannot
880880
/// leak files outside the workspace.
881-
fn validate_sandbox_source_path(path: &str, workspace_root: &str) -> Result<String> {
881+
fn validate_sandbox_source_path(workspace_root: &str, path: &str) -> Result<String> {
882882
if path.is_empty() {
883883
return Err(miette::miette!("sandbox source path is empty"));
884884
}
885-
let cleaned = lexical_clean_absolute_path(path)
886-
.ok_or_else(|| miette::miette!("sandbox source path must be absolute (got '{path}')"))?;
887-
if !is_under_workspace(&cleaned, workspace_root) {
885+
let candidate = if path.starts_with('/') {
886+
path.to_string()
887+
} else {
888+
format!("{workspace_root}/{path}")
889+
};
890+
let cleaned = lexical_clean_absolute_path(&candidate)
891+
.ok_or_else(|| miette::miette!("sandbox source path is invalid (got '{path}')"))?;
892+
if !driver_mounts::path_is_or_under(Path::new(&cleaned), Path::new(workspace_root)) {
888893
return Err(miette::miette!(
889894
"sandbox source path '{path}' is outside the sandbox workspace ({workspace_root})"
890895
));
891896
}
892897
Ok(cleaned)
893898
}
894899

895-
/// Pure helper: is `path` equal to the workspace root or a descendant of it?
896-
fn is_under_workspace(path: &str, workspace_root: &str) -> bool {
897-
path == workspace_root || path.starts_with(&format!("{workspace_root}/"))
898-
}
899-
900900
/// Discover the workspace root and resolve every symlink in `sandbox_path` in
901901
/// one SSH probe, then refuse the result if it lands outside the workspace.
902902
///
@@ -928,13 +928,13 @@ async fn resolve_sandbox_source_path(
928928
}
929929

930930
let workspace_root = validate_discovered_workspace_root(workspace_root)?;
931-
validate_sandbox_source_path(sandbox_path, &workspace_root)?;
931+
validate_sandbox_source_path(&workspace_root, sandbox_path)?;
932932
if resolved.is_empty() {
933933
return Err(miette::miette!(
934934
"sandbox source path '{sandbox_path}' does not exist"
935935
));
936936
}
937-
if !is_under_workspace(resolved, &workspace_root) {
937+
if !driver_mounts::path_is_or_under(Path::new(resolved), Path::new(&workspace_root)) {
938938
return Err(miette::miette!(
939939
"sandbox source path '{sandbox_path}' resolves to '{resolved}', outside the sandbox workspace ({workspace_root})"
940940
));
@@ -1199,8 +1199,8 @@ async fn probe_sandbox_source_kind(
11991199
/// behaviour for the directory-source case.
12001200
///
12011201
/// The sandbox source path is also subjected to a workspace-boundary check
1202-
/// before any SSH command is issued; paths that lexically resolve outside
1203-
/// the discovered workspace root are refused.
1202+
/// before any file probe or archive command is issued; paths that resolve
1203+
/// outside the discovered workspace root are refused.
12041204
pub async fn sandbox_sync_down(
12051205
server: &str,
12061206
name: &str,
@@ -2006,89 +2006,70 @@ mod tests {
20062006
fn validate_sandbox_source_path_accepts_workspace_paths() {
20072007
let workspace_root = "/workspace/project";
20082008
assert_eq!(
2009-
validate_sandbox_source_path("/workspace/project/file.txt", workspace_root).unwrap(),
2009+
validate_sandbox_source_path(workspace_root, "/workspace/project/file.txt").unwrap(),
20102010
"/workspace/project/file.txt"
20112011
);
20122012
assert_eq!(
20132013
validate_sandbox_source_path(
2014-
"/workspace/project/.agent/workspace/hello.txt",
2015-
workspace_root
2014+
workspace_root,
2015+
"/workspace/project/.agent/workspace/hello.txt"
20162016
)
20172017
.unwrap(),
20182018
"/workspace/project/.agent/workspace/hello.txt"
20192019
);
20202020
assert_eq!(
2021-
validate_sandbox_source_path("/workspace/project", workspace_root).unwrap(),
2021+
validate_sandbox_source_path(workspace_root, "/workspace/project").unwrap(),
20222022
"/workspace/project"
20232023
);
20242024
assert_eq!(
2025-
validate_sandbox_source_path("/workspace/project/", workspace_root).unwrap(),
2025+
validate_sandbox_source_path(workspace_root, "/workspace/project/").unwrap(),
20262026
"/workspace/project"
20272027
);
20282028
assert_eq!(
2029-
validate_sandbox_source_path("/workspace/project/sub/../file", workspace_root).unwrap(),
2029+
validate_sandbox_source_path(workspace_root, "/workspace/project/sub/../file").unwrap(),
20302030
"/workspace/project/file"
20312031
);
2032+
assert_eq!(
2033+
validate_sandbox_source_path(workspace_root, "output/file.txt").unwrap(),
2034+
"/workspace/project/output/file.txt"
2035+
);
2036+
assert_eq!(
2037+
validate_sandbox_source_path(workspace_root, "./output/../file.txt").unwrap(),
2038+
"/workspace/project/file.txt"
2039+
);
20322040
}
20332041

20342042
#[test]
20352043
fn validate_sandbox_source_path_rejects_traversal_and_escapes() {
20362044
let workspace_root = "/workspace/project";
2037-
let traversal = validate_sandbox_source_path("/etc/passwd", workspace_root).unwrap_err();
2045+
let traversal = validate_sandbox_source_path(workspace_root, "/etc/passwd").unwrap_err();
20382046
assert!(
20392047
format!("{traversal}").contains("outside the sandbox workspace"),
20402048
"unexpected error: {traversal}"
20412049
);
20422050

20432051
let parent_escape =
2044-
validate_sandbox_source_path("/workspace/project/../../etc/passwd", workspace_root)
2052+
validate_sandbox_source_path(workspace_root, "/workspace/project/../../etc/passwd")
20452053
.unwrap_err();
20462054
assert!(
20472055
format!("{parent_escape}").contains("outside the sandbox workspace"),
20482056
"unexpected error: {parent_escape}"
20492057
);
20502058

20512059
let prefix_only =
2052-
validate_sandbox_source_path("/workspace/projected/secrets", workspace_root)
2060+
validate_sandbox_source_path(workspace_root, "/workspace/projected/secrets")
20532061
.unwrap_err();
20542062
assert!(
20552063
format!("{prefix_only}").contains("outside the sandbox workspace"),
20562064
"unexpected error: {prefix_only}"
20572065
);
20582066

2059-
let empty = validate_sandbox_source_path("", workspace_root).unwrap_err();
2067+
let empty = validate_sandbox_source_path(workspace_root, "").unwrap_err();
20602068
assert!(format!("{empty}").contains("empty"));
20612069

2062-
let relative =
2063-
validate_sandbox_source_path("workspace/project/file", workspace_root).unwrap_err();
2064-
assert!(format!("{relative}").contains("must be absolute"));
2065-
}
2066-
2067-
#[test]
2068-
fn is_under_workspace_accepts_root_and_descendants() {
2069-
assert!(is_under_workspace(
2070-
"/workspace/project",
2071-
"/workspace/project"
2072-
));
2073-
assert!(is_under_workspace(
2074-
"/workspace/project/file",
2075-
"/workspace/project"
2076-
));
2077-
assert!(is_under_workspace(
2078-
"/workspace/project/sub/nested",
2079-
"/workspace/project"
2080-
));
2081-
}
2082-
2083-
#[test]
2084-
fn is_under_workspace_rejects_outside_paths_and_prefix_collisions() {
2085-
assert!(!is_under_workspace("/etc/passwd", "/workspace/project"));
2086-
assert!(!is_under_workspace(
2087-
"/workspace/projected/secrets",
2088-
"/workspace/project"
2089-
));
2090-
assert!(!is_under_workspace("/", "/workspace/project"));
2091-
assert!(!is_under_workspace("", "/workspace/project"));
2070+
let relative_escape =
2071+
validate_sandbox_source_path(workspace_root, "../../etc/passwd").unwrap_err();
2072+
assert!(format!("{relative_escape}").contains("outside the sandbox workspace"));
20922073
}
20932074

20942075
#[test]

crates/openshell-core/src/driver_mounts.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ pub fn validate_mount_subpath(subpath: &str) -> Result<(), String> {
9797
}
9898

9999
/// Validate a container-side mount target for user-supplied driver mounts.
100+
///
101+
/// Workspace collisions depend on the inspected image's resolved working
102+
/// directory and are checked separately by `validate_workspace_mount_target`.
100103
pub fn validate_container_mount_target(target: &str) -> Result<(), String> {
101104
if target.is_empty() {
102105
return Err("mount target must not be empty".to_string());

crates/openshell-driver-docker/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2436,8 +2436,8 @@ fn build_container_create_body_for_image(
24362436
working_dir: Some("/".to_string()),
24372437
env: Some(build_environment_for_oci_user(sandbox, config, &image.user)),
24382438
entrypoint: Some(vec![SUPERVISOR_MOUNT_PATH.to_string()]),
2439-
// Clear the image CMD so Docker does not append inherited args to the
2440-
// supervisor entrypoint.
2439+
// Replace the image CMD with the supervisor's resolved workspace
2440+
// argument so Docker cannot append inherited image arguments.
24412441
cmd: Some(vec!["--workdir".to_string(), workspace_root]),
24422442
labels: Some(labels),
24432443
host_config: Some(HostConfig {

crates/openshell-driver-kubernetes/src/driver.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4066,7 +4066,8 @@ mod tests {
40664066
"init container must not depend on a shell"
40674067
);
40684068

4069-
// Agent container command should be overridden to the emptyDir path
4069+
// `--workdir` is optional for standalone supervisor invocations and
4070+
// has no implicit default, so Kubernetes must pass its fixed workspace.
40704071
let command = pod_template["spec"]["containers"][0]["command"]
40714072
.as_array()
40724073
.expect("command should be set");

crates/openshell-sandbox/src/lib.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,20 +189,20 @@ pub async fn run_sandbox(
189189
// OpenShift retain their authoritative numeric pair; Docker and Podman
190190
// fill only omitted policy fields from OCI Config.User.
191191
#[cfg(unix)]
192-
let (resolved_process_identity, workspace_home) = {
192+
let (resolved_process_identity, workdir_is_home) = {
193193
let driver_identity = openshell_supervisor_process::identity::DriverIdentity::from_env()?;
194-
let workspace_home = matches!(
194+
let workdir_is_home = matches!(
195195
&driver_identity,
196196
openshell_supervisor_process::identity::DriverIdentity::OciUser { .. }
197197
);
198198
let resolved = openshell_supervisor_process::identity::resolve_process_identity(
199199
&mut policy,
200200
&driver_identity,
201201
)?;
202-
(resolved, workspace_home)
202+
(resolved, workdir_is_home)
203203
};
204204
#[cfg(not(unix))]
205-
let (resolved_process_identity, workspace_home) = (
205+
let (resolved_process_identity, workdir_is_home) = (
206206
openshell_supervisor_process::process::ResolvedProcessIdentity::default(),
207207
false,
208208
);
@@ -691,7 +691,7 @@ pub async fn run_sandbox(
691691
program,
692692
args,
693693
workdir.as_deref(),
694-
workspace_home,
694+
workdir_is_home,
695695
timeout_secs,
696696
interactive,
697697
sandbox_id.as_deref(),
@@ -1510,7 +1510,7 @@ mod baseline_tests {
15101510
}
15111511

15121512
#[test]
1513-
fn baseline_read_write_uses_dynamic_workdir_instead_of_sandbox() {
1513+
fn baseline_read_write_does_not_hardcode_sandbox() {
15141514
let (_ro, rw) = baseline_enrichment_paths();
15151515
assert!(rw.contains(&"/tmp".to_string()));
15161516
assert!(!rw.contains(&"/sandbox".to_string()));

crates/openshell-supervisor-process/src/process.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ impl ProcessHandle {
530530
program: &str,
531531
args: &[String],
532532
workdir: Option<&str>,
533-
workspace_home: bool,
533+
workdir_is_home: bool,
534534
interactive: bool,
535535
policy: &SandboxPolicy,
536536
resolved_identity: ResolvedProcessIdentity,
@@ -543,7 +543,7 @@ impl ProcessHandle {
543543
program,
544544
args,
545545
workdir,
546-
workspace_home,
546+
workdir_is_home,
547547
interactive,
548548
policy,
549549
resolved_identity,
@@ -565,7 +565,7 @@ impl ProcessHandle {
565565
program: &str,
566566
args: &[String],
567567
workdir: Option<&str>,
568-
workspace_home: bool,
568+
workdir_is_home: bool,
569569
interactive: bool,
570570
policy: &SandboxPolicy,
571571
resolved_identity: ResolvedProcessIdentity,
@@ -577,7 +577,7 @@ impl ProcessHandle {
577577
program,
578578
args,
579579
workdir,
580-
workspace_home,
580+
workdir_is_home,
581581
interactive,
582582
policy,
583583
resolved_identity,
@@ -593,7 +593,7 @@ impl ProcessHandle {
593593
program: &str,
594594
args: &[String],
595595
workdir: Option<&str>,
596-
workspace_home: bool,
596+
workdir_is_home: bool,
597597
interactive: bool,
598598
policy: &SandboxPolicy,
599599
resolved_identity: ResolvedProcessIdentity,
@@ -620,7 +620,7 @@ impl ProcessHandle {
620620

621621
if let Some(dir) = workdir {
622622
cmd.current_dir(dir);
623-
if workspace_home {
623+
if workdir_is_home {
624624
cmd.env("HOME", dir);
625625
}
626626
}
@@ -752,7 +752,7 @@ impl ProcessHandle {
752752
program: &str,
753753
args: &[String],
754754
workdir: Option<&str>,
755-
workspace_home: bool,
755+
workdir_is_home: bool,
756756
interactive: bool,
757757
policy: &SandboxPolicy,
758758
resolved_identity: ResolvedProcessIdentity,
@@ -776,7 +776,7 @@ impl ProcessHandle {
776776

777777
if let Some(dir) = workdir {
778778
cmd.current_dir(dir);
779-
if workspace_home {
779+
if workdir_is_home {
780780
cmd.env("HOME", dir);
781781
}
782782
}

0 commit comments

Comments
 (0)