Skip to content

Commit cb663bd

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 cb663bd

File tree

1 file changed

+182
-14
lines changed

1 file changed

+182
-14
lines changed

cmd/limactl/shell.go

Lines changed: 182 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,15 @@ 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-host-workdir", 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+
const (
74+
rsyncMinimumSrcDirDepth = 4 // Depth of "/Users/USER" is 3.
75+
guestSyncedWorkdir = "~/synced-workdir"
76+
)
77+
7078
func shellAction(cmd *cobra.Command, args []string) error {
7179
ctx := cmd.Context()
7280
flags := cmd.Flags()
@@ -150,29 +158,45 @@ func shellAction(cmd *cobra.Command, args []string) error {
150158
}
151159
}
152160

161+
syncHostWorkdir, err := flags.GetBool("sync-host-workdir")
162+
if err != nil {
163+
return fmt.Errorf("failed to get sync-host-workdir flag: %w", err)
164+
} else if syncHostWorkdir && len(inst.Config.Mounts) > 0 {
165+
return errors.New("cannot use `--sync-host-workdir` when the instance has host mounts configured, use `--mount-none` to disable mounts")
166+
}
167+
153168
// When workDir is explicitly set, the shell MUST have workDir as the cwd, or exit with an error.
154169
//
155170
// changeDirCmd := "cd workDir || exit 1" if workDir != ""
156171
// := "cd hostCurrentDir || cd hostHomeDir" if workDir == ""
157172
var changeDirCmd string
173+
hostCurrentDir, err := hostCurrentDirectory(ctx, inst)
174+
if err != nil {
175+
changeDirCmd = "false"
176+
logrus.WithError(err).Warn("failed to get the current directory")
177+
}
178+
if syncHostWorkdir {
179+
if _, err := exec.LookPath("rsync"); err != nil {
180+
return fmt.Errorf("rsync is required for `--sync-host-workdir` but not found: %w", err)
181+
}
182+
183+
srcWdDepth := len(strings.Split(hostCurrentDir, string(os.PathSeparator)))
184+
if srcWdDepth < rsyncMinimumSrcDirDepth {
185+
return fmt.Errorf("expected the depth of the host working directory (%q) to be more than %d, only got %d (Hint: %s)",
186+
hostCurrentDir, rsyncMinimumSrcDirDepth, srcWdDepth, "cd to a deeper directory")
187+
}
188+
}
189+
158190
workDir, err := cmd.Flags().GetString("workdir")
159191
if err != nil {
160192
return err
161193
}
162-
if workDir != "" {
194+
switch {
195+
case workDir != "":
163196
changeDirCmd = fmt.Sprintf("cd %s || exit 1", shellescape.Quote(workDir))
164197
// 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-
}
198+
case len(inst.Config.Mounts) > 0 || inst.VMType == limatype.WSL2:
199+
changeDirCmd = fmt.Sprintf("cd %s", shellescape.Quote(hostCurrentDir))
176200
hostHomeDir, err := os.UserHomeDir()
177201
if err == nil && runtime.GOOS == "windows" {
178202
hostHomeDir, err = mountDirFromWindowsDir(ctx, inst, hostHomeDir)
@@ -182,7 +206,9 @@ func shellAction(cmd *cobra.Command, args []string) error {
182206
} else {
183207
logrus.WithError(err).Warn("failed to get the home directory")
184208
}
185-
} else {
209+
case syncHostWorkdir:
210+
changeDirCmd = fmt.Sprintf("cd %s/%s", guestSyncedWorkdir, filepath.Base(hostCurrentDir))
211+
default:
186212
logrus.Debug("the host home does not seem mounted, so the guest shell will have a different cwd")
187213
}
188214

@@ -267,6 +293,17 @@ func shellAction(cmd *cobra.Command, args []string) error {
267293
}
268294
sshArgs := append([]string{}, sshExe.Args...)
269295
sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...)
296+
297+
var sshExecForRsync *exec.Cmd
298+
if syncHostWorkdir {
299+
logrus.Infof("Syncing host current directory(%s) to guest instance...", hostCurrentDir)
300+
sshExecForRsync = exec.CommandContext(ctx, sshExe.Exe, sshArgs...)
301+
if err := rsyncDirectory(ctx, sshExecForRsync, hostCurrentDir, fmt.Sprintf("%s:%s", *inst.Config.User.Name+"@"+inst.SSHAddress, guestSyncedWorkdir)); 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/%s) instance.", guestSyncedWorkdir, filepath.Base(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,138 @@ 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, inst, sshExecForRsync, hostCurrentDir)
345+
}
346+
return nil
347+
}
348+
349+
func askUserForRsyncBack(ctx context.Context, 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+
remoteSource := fmt.Sprintf("%s:%s/%s", *inst.Config.User.Name+"@"+inst.SSHAddress, guestSyncedWorkdir, filepath.Base(hostCurrentDir))
358+
hostTmpDest, err := os.MkdirTemp("", "lima-guest-synced-*")
359+
if err != nil {
360+
logrus.WithError(err).Warn("Failed to create temporary directory")
361+
return
362+
}
363+
defer func() {
364+
if err := os.RemoveAll(hostTmpDest); err != nil {
365+
logrus.WithError(err).Warnf("Failed to clean up temporary directory %s", hostTmpDest)
366+
}
367+
}()
368+
369+
if err := rsyncDirectory(ctx, sshCmd, remoteSource, hostTmpDest); err != nil {
370+
logrus.WithError(err).Warn("Failed to sync back the changes to host for viewing")
371+
return
372+
}
373+
374+
for {
375+
ans, err := uiutil.Select(message, options)
376+
if err != nil {
377+
if errors.Is(err, uiutil.InterruptErr) {
378+
logrus.Fatal("Interrupted by user")
379+
}
380+
logrus.WithError(err).Warn("Failed to open TUI")
381+
return
382+
}
383+
384+
switch ans {
385+
case 0: // Yes
386+
dest := filepath.Dir(hostCurrentDir)
387+
if err := rsyncDirectory(ctx, sshCmd, remoteSource, dest); err != nil {
388+
logrus.WithError(err).Warn("Failed to sync back the changes to host")
389+
return
390+
}
391+
cleanGuestSyncedWorkdir(ctx, sshCmd)
392+
logrus.Info("Successfully synced back the changes to host.")
393+
return
394+
case 1: // No
395+
cleanGuestSyncedWorkdir(ctx, sshCmd)
396+
logrus.Info("Skipping syncing back the changes to host.")
397+
return
398+
case 2: // View the changed contents
399+
diffCmd := exec.CommandContext(ctx, "diff", "-ru", "--color=always", hostCurrentDir, filepath.Join(hostTmpDest, filepath.Base(hostCurrentDir)))
400+
lessCmd := exec.CommandContext(ctx, "less", "-R")
401+
pipeIn, err := lessCmd.StdinPipe()
402+
if err != nil {
403+
logrus.WithError(err).Warn("Failed to get less stdin")
404+
return
405+
}
406+
diffCmd.Stdout = pipeIn
407+
lessCmd.Stdout = os.Stdout
408+
lessCmd.Stderr = os.Stderr
409+
410+
if err := lessCmd.Start(); err != nil {
411+
logrus.WithError(err).Warn("Failed to start less")
412+
return
413+
}
414+
if err := diffCmd.Run(); err != nil {
415+
// Command `diff` returns exit code 1 when files differ.
416+
var exitErr *exec.ExitError
417+
if errors.As(err, &exitErr) && exitErr.ExitCode() >= 2 {
418+
logrus.WithError(err).Warn("Failed to run diff")
419+
_ = pipeIn.Close()
420+
return
421+
}
422+
}
423+
424+
_ = pipeIn.Close()
425+
426+
if err := lessCmd.Wait(); err != nil {
427+
logrus.WithError(err).Warn("Failed to wait for less")
428+
return
429+
}
430+
}
431+
}
432+
}
433+
434+
func cleanGuestSyncedWorkdir(ctx context.Context, sshCmd *exec.Cmd) {
435+
sshCmd.Args = append(sshCmd.Args, "rm", "-rf", guestSyncedWorkdir)
436+
sshRmCmd := exec.CommandContext(ctx, sshCmd.Path, sshCmd.Args...)
437+
if err := sshRmCmd.Run(); err != nil {
438+
logrus.WithError(err).Warn("Failed to clean up guest synced workdir")
439+
return
440+
}
441+
logrus.Debug("Successfully cleaned up guest synced workdir.")
442+
}
443+
444+
func hostCurrentDirectory(ctx context.Context, inst *limatype.Instance) (string, error) {
445+
hostCurrentDir, err := os.Getwd()
446+
if err == nil && runtime.GOOS == "windows" {
447+
hostCurrentDir, err = mountDirFromWindowsDir(ctx, inst, hostCurrentDir)
448+
}
449+
return hostCurrentDir, err
450+
}
451+
452+
// Syncs a directory from host to guest and vice-versa. It creates a directory
453+
// named "synced-workdir" in the guest's home directory and copies the contents
454+
// of the host's current working directory into it.
455+
func rsyncDirectory(ctx context.Context, sshCmd *exec.Cmd, source, destination string) error {
456+
rsyncArgs := []string{
457+
"-ah",
458+
"-e", sshCmd.String(),
459+
source,
460+
destination,
461+
}
462+
rsyncCmd := exec.CommandContext(ctx, "rsync", rsyncArgs...)
463+
rsyncCmd.Stdin = os.Stdin
464+
rsyncCmd.Stdout = os.Stdout
465+
rsyncCmd.Stderr = os.Stderr
466+
logrus.Debugf("executing rsync: %+v", rsyncCmd.Args)
467+
return rsyncCmd.Run()
300468
}
301469

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

0 commit comments

Comments
 (0)