@@ -147,7 +147,31 @@ impl ToolExecutor {
147147 } ) ?
148148 . to_path_buf ( ) ;
149149 let normalized_rel = Self :: normalize_relative ( & rel_from_sandbox) ?;
150- Ok ( sandbox_canonical. join ( normalized_rel) )
150+ let candidate = sandbox_canonical. join ( & normalized_rel) ;
151+
152+ // Resolve the deepest existing ancestor through symlinks. This prevents an
153+ // attacker from creating a symlink inside the sandbox (e.g. evil -> /etc) and
154+ // then writing to evil/newfile, where the leaf does not exist but the parent
155+ // is a symlink that escapes the sandbox.
156+ let mut ancestor = candidate. as_path ( ) ;
157+ loop {
158+ if ancestor. exists ( ) {
159+ let canonical_ancestor = ancestor. canonicalize ( ) . map_err ( AppError :: from) ?;
160+ if !canonical_ancestor. starts_with ( & sandbox_canonical) {
161+ return Err ( AppError :: Validation ( format ! (
162+ "Path '{}' is outside project sandbox" ,
163+ requested
164+ ) ) ) ;
165+ }
166+ break ;
167+ }
168+ match ancestor. parent ( ) {
169+ Some ( parent) => ancestor = parent,
170+ None => break ,
171+ }
172+ }
173+
174+ Ok ( candidate)
151175 }
152176
153177 fn is_sensitive_file ( & self , path : & Path ) -> bool {
@@ -603,6 +627,47 @@ mod tests {
603627 cleanup ( "path_traversal_abs" ) ;
604628 }
605629
630+ #[ tokio:: test]
631+ #[ cfg( unix) ]
632+ async fn test_symlink_parent_escape_rejected ( ) {
633+ // Regression: an attacker creates a symlink inside the sandbox pointing to a
634+ // privileged directory (e.g. evil -> /tmp), then attempts to write a NEW file
635+ // through that symlink. The leaf does not exist, so the original validate_path
636+ // skipped canonicalization and tokio::fs::write happily followed the symlink,
637+ // letting the agent write outside the sandbox.
638+ let sandbox_path = with_sandbox ( "symlink_parent_escape" ) ;
639+ let outside = PathBuf :: from ( "/tmp" ) . join ( "enowx-test-symlink-target" ) ;
640+ tokio:: fs:: create_dir_all ( & outside)
641+ . await
642+ . expect ( "create outside dir" ) ;
643+
644+ std:: os:: unix:: fs:: symlink ( & outside, sandbox_path. join ( "evil" ) )
645+ . expect ( "create symlink" ) ;
646+
647+ let executor = ToolExecutor :: new ( sandbox_path) ;
648+
649+ let call = ToolCall {
650+ tool : ToolName :: WriteFile ,
651+ input : serde_json:: json!( {
652+ "path" : "evil/pwned.txt" ,
653+ "content" : "should not land outside sandbox" ,
654+ } ) ,
655+ } ;
656+ let result = executor. execute ( call) . await ;
657+ assert ! (
658+ result. is_error,
659+ "write through symlinked parent must be rejected, got: {}" ,
660+ result. output
661+ ) ;
662+ assert ! (
663+ !outside. join( "pwned.txt" ) . exists( ) ,
664+ "file must not have been written outside sandbox"
665+ ) ;
666+
667+ let _ = tokio:: fs:: remove_dir_all ( & outside) . await ;
668+ cleanup ( "symlink_parent_escape" ) ;
669+ }
670+
606671 #[ tokio:: test]
607672 async fn test_is_outside_sandbox ( ) {
608673 let sandbox_path = with_sandbox ( "outside_sandbox" ) ;
0 commit comments