file_sync: skip nested symlinks during recursive copy

A user who sets `[[file_sync]] host = "~/.aws"` (per the README's
own example) can unintentionally copy files from outside that
directory if .aws contains symlinks. copyHostDir used os.Stat
during recursion, which transparently follows: a symlink to a
credential dir elsewhere would be recursed into, materialising
unrelated secrets inside the guest. For credential trees that's
an avoidable sprawl vector.

Switched copyHostDir's per-entry probe from os.Stat to os.Lstat
and added a default skip-with-warning branch for ModeSymlink.
Files and dirs at the SAME level copy as before; symlinks (both
file and directory flavours) surface a "file_sync skipped
symlink (would escape the requested tree)" warn log and are
otherwise omitted.

Top-level entry paths still follow — the Stat in runFileSync is
unchanged. The user explicitly named that path, so resolving
"~/.aws" through a symlink out of $HOME is on them.

Tests:
- TestRunFileSyncSkipsNestedSymlinks — builds a synced dir with
  both a file symlink and a directory symlink pointing outside
  the tree; asserts real files copy, symlinks do not materialise
  anywhere in the guest mount, and each skipped symlink surfaces
  a warn log entry.

README updated with a one-line note about the skip behaviour so
users know to expect it rather than chasing "why didn't my file
show up."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-23 14:11:58 -03:00
parent caa6a2b996
commit 1850904d9c
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 127 additions and 8 deletions

View file

@ -220,7 +220,7 @@ func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord)
}
if info.IsDir() {
if err := copyHostDir(ctx, runner, hostPath, target); err != nil {
if err := s.copyHostDir(ctx, *vm, runner, hostPath, target); err != nil {
return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, target, err)
}
continue
@ -240,10 +240,14 @@ func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord)
// copyHostDir recursively copies hostDir into guestTarget using only
// `mkdir` (for subdirs) and `install` (for files). Each file's source
// permissions are preserved; ownership is forced to root:root via
// `install -o 0 -g 0`. Symlinks are followed (target content is
// copied as a regular file). Other special types (devices, FIFOs)
// are skipped silently.
func copyHostDir(ctx context.Context, runner system.CommandRunner, hostDir, guestTarget string) error {
// `install -o 0 -g 0`. Symlinks encountered during recursion are
// SKIPPED with a warning — `os.Lstat` tells us the entry itself is a
// link without resolving it, so a symlink inside ~/.aws that points
// at ~/secrets can't leak out of the tree the user named. Other
// special types (devices, FIFOs) are skipped silently. Top-level
// host paths go through `os.Stat` back in runFileSync and still
// follow, since the user explicitly named that path.
func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, runner system.CommandRunner, hostDir, guestTarget string) error {
if _, err := runner.RunSudo(ctx, "mkdir", "-p", guestTarget); err != nil {
return err
}
@ -255,13 +259,15 @@ func copyHostDir(ctx context.Context, runner system.CommandRunner, hostDir, gues
hostChild := filepath.Join(hostDir, entry.Name())
guestChild := filepath.Join(guestTarget, entry.Name())
info, err := os.Stat(hostChild)
info, err := os.Lstat(hostChild)
if err != nil {
return err
}
switch {
case info.Mode()&os.ModeSymlink != 0:
s.warnFileSyncSymlinkSkipped(vm, hostChild)
case info.IsDir():
if err := copyHostDir(ctx, runner, hostChild, guestChild); err != nil {
if err := s.copyHostDir(ctx, vm, runner, hostChild, guestChild); err != nil {
return err
}
case info.Mode().IsRegular():
@ -372,6 +378,17 @@ func (s *WorkspaceService) warnFileSyncSkipped(vm model.VMRecord, hostPath strin
s.logger.Warn("file_sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
}
// warnFileSyncSymlinkSkipped surfaces a skipped nested symlink to the
// user through the daemon log. Skipping is deliberate — see
// copyHostDir's docstring — but invisible skips would hide a
// "why did my file not show up in the guest?" debugging trail.
func (s *WorkspaceService) warnFileSyncSymlinkSkipped(vm model.VMRecord, hostPath string) {
if s.logger == nil {
return
}
s.logger.Warn("file_sync skipped symlink (would escape the requested tree)", append(vmLogAttrs(vm), "host_path", hostPath)...)
}
func (s *WorkspaceService) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {
if s.logger == nil || err == nil {
return