@@ -7,7 +7,6 @@ use crate::tls::{TlsOptions, grpc_client};
77use miette:: { IntoDiagnostic , Result , WrapErr } ;
88#[ cfg( unix) ]
99use nix:: sys:: signal:: { SaFlags , SigAction , SigHandler , SigSet , Signal , sigaction} ;
10- use openshell_core:: ObjectId ;
1110use 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} ;
1919use owo_colors:: OwoColorize ;
2020use std:: fs;
2121use 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.
12041204pub 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]
0 commit comments