Skip to content
Merged
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
5 changes: 3 additions & 2 deletions crates/bashkit/src/fs/posix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,10 @@ impl<B: FsBackend + 'static> FileSystem for PosixFs<B> {
}

async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
let target = Self::normalize(target);
// Don't normalize target: symlink targets are stored as-is on disk.
// Normalizing a relative target to absolute would break containment checks.
let link = Self::normalize(link);
self.backend.symlink(&target, &link).await
self.backend.symlink(target, &link).await
}

async fn read_link(&self, path: &Path) -> Result<PathBuf> {
Expand Down
50 changes: 40 additions & 10 deletions crates/bashkit/src/fs/realfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,16 +414,46 @@ impl FsBackend for RealFs {
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())
/// THREAT[TM-ESC-003]: Symlink creation in RealFs is allowed only in
/// ReadWrite mode. The OS resolves symlink targets on the host filesystem,
/// so we must validate that the effective target stays within the mount
/// root on disk. Absolute targets are rejected. Relative targets are
/// normalized against the link's host-side parent directory.
async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
self.check_writable()?;
let real_link = self.resolve(link)?;

// Absolute targets always escape the mount root on disk
if target.is_absolute() {
return Err(IoError::new(
ErrorKind::PermissionDenied,
"symlink with absolute target not allowed in RealFs (sandbox security)",
)
.into());
}

// Relative targets: resolve against the link's host-side parent
// to verify the effective path stays within root
let link_parent = real_link.parent().unwrap_or(&self.root);
let effective = normalize_host_path(&link_parent.join(target));
if !effective.starts_with(&self.root) {
return Err(IoError::new(
ErrorKind::PermissionDenied,
"symlink target escapes realfs root (sandbox security)",
)
.into());
}

#[cfg(unix)]
{
tokio::fs::symlink(target, &real_link).await?;
}
#[cfg(not(unix))]
{
let _ = target;
tokio::fs::write(&real_link, "").await?;
}
Ok(())
}

async fn read_link(&self, path: &Path) -> Result<PathBuf> {
Expand Down
22 changes: 22 additions & 0 deletions crates/bashkit/tests/realfs_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,28 @@ async fn realfs_symlink_relative_escape_blocked() {
);
}

#[tokio::test]
async fn realfs_symlink_within_mount_allowed() {
let dir = setup_host_dir();
std::fs::write(dir.path().join("original.txt"), "content").unwrap();

let mut bash = Bash::builder()
.mount_real_readwrite_at(dir.path(), "/mnt/workspace")
.build();

// Relative symlink within mount should succeed (exit code 0)
let r = bash
.exec("ln -s original.txt /mnt/workspace/link.txt 2>&1; echo $?")
.await
.unwrap();
assert!(
r.stdout.trim().ends_with('0'),
"Symlink within mount should succeed, got stdout: {} stderr: {}",
r.stdout,
r.stderr
);
}

// --- Runtime mount/unmount (exercises Bash::mount / Bash::unmount) ---

#[tokio::test]
Expand Down
Loading