diff --git a/crates/bashkit/src/fs/realfs.rs b/crates/bashkit/src/fs/realfs.rs index 95553e2a..997c2c90 100644 --- a/crates/bashkit/src/fs/realfs.rs +++ b/crates/bashkit/src/fs/realfs.rs @@ -369,23 +369,16 @@ impl FsBackend for RealFs { Ok(()) } - async fn symlink(&self, target: &Path, link: &Path) -> Result<()> { - self.check_writable()?; - let real_link = self.resolve(link)?; - // Store the target as-is (virtual path) - symlinks are stored but - // not followed per security policy (TM-ESC-002) - #[cfg(unix)] - tokio::fs::symlink(target, &real_link).await?; - #[cfg(not(unix))] - { - let _ = (target, &real_link); - return Err(IoError::new( - ErrorKind::Unsupported, - "symlinks not supported on this platform", - ) - .into()); - } - Ok(()) + /// THREAT[TM-ESC-003]: Symlink creation is blocked in RealFs to prevent + /// sandbox escape. Even though bashkit itself doesn't follow symlinks + /// (TM-ESC-002), any external process sharing the directory tree would + /// follow them, enabling reads/writes to arbitrary host paths. + async fn symlink(&self, _target: &Path, _link: &Path) -> Result<()> { + Err(IoError::new( + ErrorKind::PermissionDenied, + "symlink creation is not allowed in RealFs (sandbox security)", + ) + .into()) } async fn read_link(&self, path: &Path) -> Result { diff --git a/crates/bashkit/tests/realfs_tests.rs b/crates/bashkit/tests/realfs_tests.rs index d65ba2cd..6a6b8c85 100644 --- a/crates/bashkit/tests/realfs_tests.rs +++ b/crates/bashkit/tests/realfs_tests.rs @@ -297,3 +297,48 @@ async fn direct_fs_api_exists() { assert!(fs.exists(Path::new("/mnt/data/hello.txt")).await.unwrap()); assert!(!fs.exists(Path::new("/mnt/data/nope.txt")).await.unwrap()); } + +// ==================== Symlink sandbox escape prevention (Issue #979) ==================== + +#[tokio::test] +async fn realfs_symlink_absolute_escape_blocked() { + let dir = setup_host_dir(); + let mut bash = Bash::builder() + .mount_real_readwrite_at(dir.path(), "/mnt/workspace") + .build(); + + // Attempt to create a symlink pointing to /etc/passwd + let r = bash + .exec("ln -s /etc/passwd /mnt/workspace/escape 2>&1; echo $?") + .await + .unwrap(); + // Should fail with non-zero exit code + assert!( + r.stdout.trim().ends_with('1') + || r.stdout.contains("not allowed") + || r.stdout.contains("Permission denied"), + "Symlink creation should be blocked, got: {}", + r.stdout + ); +} + +#[tokio::test] +async fn realfs_symlink_relative_escape_blocked() { + let dir = setup_host_dir(); + let mut bash = Bash::builder() + .mount_real_readwrite_at(dir.path(), "/mnt/workspace") + .build(); + + // Attempt relative path traversal via symlink + let r = bash + .exec("ln -s ../../../../etc/passwd /mnt/workspace/escape 2>&1; echo $?") + .await + .unwrap(); + assert!( + r.stdout.trim().ends_with('1') + || r.stdout.contains("not allowed") + || r.stdout.contains("Permission denied"), + "Relative symlink escape should be blocked, got: {}", + r.stdout + ); +}