Skip to content

Commit e6577f3

Browse files
committed
feature(cli): add sync-host-workdir to prevent AI agents from breaking the host files
Signed-off-by: Ansuman Sahoo <[email protected]>
1 parent 88173f0 commit e6577f3

File tree

1 file changed

+193
-14
lines changed

1 file changed

+193
-14
lines changed

cmd/limactl/shell.go

Lines changed: 193 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"os"
1111
"os/exec"
12+
"path/filepath"
1213
"runtime"
1314
"strconv"
1415
"strings"
@@ -28,6 +29,7 @@ import (
2829
"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
2930
"github.com/lima-vm/lima/v2/pkg/sshutil"
3031
"github.com/lima-vm/lima/v2/pkg/store"
32+
"github.com/lima-vm/lima/v2/pkg/uiutil"
3133
)
3234

3335
const shellHelp = `Execute shell in Lima
@@ -64,9 +66,13 @@ func newShellCommand() *cobra.Command {
6466
shellCmd.Flags().Bool("reconnect", false, "Reconnect to the SSH session")
6567
shellCmd.Flags().Bool("preserve-env", false, "Propagate environment variables to the shell")
6668
shellCmd.Flags().Bool("start", false, "Start the instance if it is not already running")
69+
shellCmd.Flags().Bool("sync", false, "Copy the host working directory to the guest to run AI commands inside VMs (prevents AI agents from breaking the host files)")
6770
return shellCmd
6871
}
6972

73+
// Depth of "/Users/USER" is 3.
74+
const rsyncMinimumSrcDirDepth = 4
75+
7076
func shellAction(cmd *cobra.Command, args []string) error {
7177
ctx := cmd.Context()
7278
flags := cmd.Flags()
@@ -150,29 +156,45 @@ func shellAction(cmd *cobra.Command, args []string) error {
150156
}
151157
}
152158

159+
syncHostWorkdir, err := flags.GetBool("sync")
160+
if err != nil {
161+
return fmt.Errorf("failed to get sync flag: %w", err)
162+
} else if syncHostWorkdir && len(inst.Config.Mounts) > 0 {
163+
return errors.New("cannot use `--sync` when the instance has host mounts configured, start the instance with `--mount-none` to disable mounts")
164+
}
165+
153166
// When workDir is explicitly set, the shell MUST have workDir as the cwd, or exit with an error.
154167
//
155168
// changeDirCmd := "cd workDir || exit 1" if workDir != ""
156169
// := "cd hostCurrentDir || cd hostHomeDir" if workDir == ""
157170
var changeDirCmd string
171+
hostCurrentDir, err := hostCurrentDirectory(ctx, inst)
172+
if err != nil {
173+
changeDirCmd = "false"
174+
logrus.WithError(err).Warn("failed to get the current directory")
175+
}
176+
if syncHostWorkdir {
177+
if _, err := exec.LookPath("rsync"); err != nil {
178+
return fmt.Errorf("rsync is required for `--sync` but not found: %w", err)
179+
}
180+
181+
srcWdDepth := len(strings.Split(hostCurrentDir, string(os.PathSeparator)))
182+
if srcWdDepth < rsyncMinimumSrcDirDepth {
183+
return fmt.Errorf("expected the depth of the host working directory (%q) to be more than %d, only got %d (Hint: %s)",
184+
hostCurrentDir, rsyncMinimumSrcDirDepth, srcWdDepth, "cd to a deeper directory")
185+
}
186+
}
187+
158188
workDir, err := cmd.Flags().GetString("workdir")
159189
if err != nil {
160190
return err
161191
}
162-
if workDir != "" {
192+
switch {
193+
case workDir != "":
163194
changeDirCmd = fmt.Sprintf("cd %s || exit 1", shellescape.Quote(workDir))
164195
// FIXME: check whether y.Mounts contains the home, not just len > 0
165-
} else if len(inst.Config.Mounts) > 0 || inst.VMType == limatype.WSL2 {
166-
hostCurrentDir, err := os.Getwd()
167-
if err == nil && runtime.GOOS == "windows" {
168-
hostCurrentDir, err = mountDirFromWindowsDir(ctx, inst, hostCurrentDir)
169-
}
170-
if err == nil {
171-
changeDirCmd = fmt.Sprintf("cd %s", shellescape.Quote(hostCurrentDir))
172-
} else {
173-
changeDirCmd = "false"
174-
logrus.WithError(err).Warn("failed to get the current directory")
175-
}
196+
case len(inst.Config.Mounts) > 0 || inst.VMType == limatype.WSL2:
197+
changeDirCmd = fmt.Sprintf("cd %s", shellescape.Quote(hostCurrentDir))
176198
hostHomeDir, err := os.UserHomeDir()
177199
if err == nil && runtime.GOOS == "windows" {
178200
hostHomeDir, err = mountDirFromWindowsDir(ctx, inst, hostHomeDir)
@@ -182,7 +204,9 @@ func shellAction(cmd *cobra.Command, args []string) error {
182204
} else {
183205
logrus.WithError(err).Warn("failed to get the home directory")
184206
}
185-
} else {
207+
case syncHostWorkdir:
208+
changeDirCmd = fmt.Sprintf("cd ~%s", shellescape.Quote(hostCurrentDir))
209+
default:
186210
logrus.Debug("the host home does not seem mounted, so the guest shell will have a different cwd")
187211
}
188212

@@ -267,6 +291,19 @@ func shellAction(cmd *cobra.Command, args []string) error {
267291
}
268292
sshArgs := append([]string{}, sshExe.Args...)
269293
sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...)
294+
295+
var sshExecForRsync *exec.Cmd
296+
if syncHostWorkdir {
297+
logrus.Infof("Syncing host current directory(%s) to guest instance...", hostCurrentDir)
298+
sshExecForRsync = exec.CommandContext(ctx, sshExe.Exe, sshArgs...)
299+
destDir := fmt.Sprintf("~%s", shellescape.Quote(filepath.Dir(hostCurrentDir)))
300+
preRsyncScript := fmt.Sprintf("mkdir -p ~%s", shellescape.Quote(hostCurrentDir))
301+
if err := rsyncDirectory(ctx, cmd, sshExecForRsync, hostCurrentDir, fmt.Sprintf("%s:%s", *inst.Config.User.Name+"@"+inst.SSHAddress, destDir), preRsyncScript); err != nil {
302+
return fmt.Errorf("failed to sync host working directory to guest instance: %w", err)
303+
}
304+
logrus.Infof("Successfully synced host current directory to guest(~%s) instance.", hostCurrentDir)
305+
}
306+
270307
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
271308
// required for showing the shell prompt: https://stackoverflow.com/a/626574
272309
sshArgs = append(sshArgs, "-t")
@@ -296,7 +333,149 @@ func shellAction(cmd *cobra.Command, args []string) error {
296333
logrus.Debugf("executing ssh (may take a long)): %+v", sshCmd.Args)
297334

298335
// TODO: use syscall.Exec directly (results in losing tty?)
299-
return sshCmd.Run()
336+
if err := sshCmd.Run(); err != nil {
337+
return err
338+
}
339+
340+
// Once the shell command finishes, rsync back the changes from guest workdir
341+
// to the host and delete the guest synced workdir only if the user
342+
// confirms the changes.
343+
if syncHostWorkdir {
344+
askUserForRsyncBack(ctx, cmd, inst, sshExecForRsync, hostCurrentDir)
345+
}
346+
return nil
347+
}
348+
349+
func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype.Instance, sshCmd *exec.Cmd, hostCurrentDir string) {
350+
message := "⚠️ Accept the changes?"
351+
options := []string{
352+
"Yes",
353+
"No",
354+
"View the changed contents",
355+
}
356+
357+
hostTmpDest, err := os.MkdirTemp("", "lima-guest-synced-*")
358+
if err != nil {
359+
logrus.WithError(err).Warn("Failed to create temporary directory")
360+
return
361+
}
362+
defer func() {
363+
if err := os.RemoveAll(hostTmpDest); err != nil {
364+
logrus.WithError(err).Warnf("Failed to clean up temporary directory %s", hostTmpDest)
365+
}
366+
}()
367+
remoteSource := fmt.Sprintf("%s:~%s", *inst.Config.User.Name+"@"+inst.SSHAddress, hostCurrentDir)
368+
rsyncToTempDir := false
369+
370+
for {
371+
ans, err := uiutil.Select(message, options)
372+
if err != nil {
373+
if errors.Is(err, uiutil.InterruptErr) {
374+
logrus.Fatal("Interrupted by user")
375+
}
376+
logrus.WithError(err).Warn("Failed to open TUI")
377+
return
378+
}
379+
380+
switch ans {
381+
case 0: // Yes
382+
dest := filepath.Dir(hostCurrentDir)
383+
if err := rsyncDirectory(ctx, cmd, sshCmd, remoteSource, dest, ""); err != nil {
384+
logrus.WithError(err).Warn("Failed to sync back the changes to host")
385+
return
386+
}
387+
cleanGuestSyncedWorkdir(ctx, sshCmd, hostCurrentDir)
388+
logrus.Info("Successfully synced back the changes to host.")
389+
return
390+
case 1: // No
391+
cleanGuestSyncedWorkdir(ctx, sshCmd, hostCurrentDir)
392+
logrus.Info("Skipping syncing back the changes to host.")
393+
return
394+
case 2: // View the changed contents
395+
if !rsyncToTempDir {
396+
if err := rsyncDirectory(ctx, cmd, sshCmd, remoteSource, hostTmpDest, ""); err != nil {
397+
logrus.WithError(err).Warn("Failed to sync back the changes to host for viewing")
398+
return
399+
}
400+
rsyncToTempDir = true
401+
}
402+
diffCmd := exec.CommandContext(ctx, "diff", "-ru", "--color=always", hostCurrentDir, filepath.Join(hostTmpDest, filepath.Base(hostCurrentDir)))
403+
pager := os.Getenv("PAGER")
404+
if pager == "" {
405+
pager = "less"
406+
}
407+
lessCmd := exec.CommandContext(ctx, pager, "-R")
408+
pipeIn, err := lessCmd.StdinPipe()
409+
if err != nil {
410+
logrus.WithError(err).Warn("Failed to get less stdin")
411+
return
412+
}
413+
diffCmd.Stdout = pipeIn
414+
lessCmd.Stdout = cmd.OutOrStdout()
415+
lessCmd.Stderr = cmd.OutOrStderr()
416+
417+
if err := lessCmd.Start(); err != nil {
418+
logrus.WithError(err).Warn("Failed to start less")
419+
return
420+
}
421+
if err := diffCmd.Run(); err != nil {
422+
// Command `diff` returns exit code 1 when files differ.
423+
var exitErr *exec.ExitError
424+
if errors.As(err, &exitErr) && exitErr.ExitCode() >= 2 {
425+
logrus.WithError(err).Warn("Failed to run diff")
426+
_ = pipeIn.Close()
427+
return
428+
}
429+
}
430+
431+
_ = pipeIn.Close()
432+
433+
if err := lessCmd.Wait(); err != nil {
434+
logrus.WithError(err).Warn("Failed to wait for less")
435+
return
436+
}
437+
}
438+
}
439+
}
440+
441+
func cleanGuestSyncedWorkdir(ctx context.Context, sshCmd *exec.Cmd, hostCurrentDir string) {
442+
clean := filepath.Clean(hostCurrentDir)
443+
parts := strings.Split(clean, string(filepath.Separator))
444+
sshCmd.Args = append(sshCmd.Args, "rm", "-rf", fmt.Sprintf("~/%s", parts[1]))
445+
sshRmCmd := exec.CommandContext(ctx, sshCmd.Path, sshCmd.Args...)
446+
if err := sshRmCmd.Run(); err != nil {
447+
logrus.WithError(err).Warn("Failed to clean up guest synced workdir")
448+
return
449+
}
450+
logrus.Debug("Successfully cleaned up guest synced workdir.")
451+
}
452+
453+
func hostCurrentDirectory(ctx context.Context, inst *limatype.Instance) (string, error) {
454+
hostCurrentDir, err := os.Getwd()
455+
if err == nil && runtime.GOOS == "windows" {
456+
hostCurrentDir, err = mountDirFromWindowsDir(ctx, inst, hostCurrentDir)
457+
}
458+
return hostCurrentDir, err
459+
}
460+
461+
// Syncs a directory from host to guest and vice-versa. It creates a directory
462+
// named "synced-workdir" in the guest's home directory and copies the contents
463+
// of the host's current working directory into it.
464+
func rsyncDirectory(ctx context.Context, cmd *cobra.Command, sshCmd *exec.Cmd, source, destination, preRsyncScript string) error {
465+
rsyncArgs := []string{
466+
"-ah",
467+
"-e", sshCmd.String(),
468+
source,
469+
destination,
470+
}
471+
if preRsyncScript != "" {
472+
rsyncArgs = append([]string{"--rsync-path", fmt.Sprintf("%s && rsync", shellescape.Quote(preRsyncScript))}, rsyncArgs...)
473+
}
474+
rsyncCmd := exec.CommandContext(ctx, "rsync", rsyncArgs...)
475+
rsyncCmd.Stdout = cmd.OutOrStdout()
476+
rsyncCmd.Stderr = cmd.OutOrStderr()
477+
logrus.Debugf("executing rsync: %+v", rsyncCmd.Args)
478+
return rsyncCmd.Run()
300479
}
301480

302481
func mountDirFromWindowsDir(ctx context.Context, inst *limatype.Instance, dir string) (string, error) {

0 commit comments

Comments
 (0)