Skip to content

Commit 87bc000

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 87bc000

File tree

2 files changed

+252
-14
lines changed

2 files changed

+252
-14
lines changed

cmd/limactl/shell.go

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

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

website/content/en/docs/examples/ai.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,56 @@ See also <https://github.com/github/copilot-cli>.
116116
{{% /tab %}}
117117
{{< /tabpane >}}
118118

119+
120+
# Syncing Working Directory
121+
122+
The `--sync` flag for `limactl shell` enables bidirectional synchronization of your host working directory with the guest VM. This is particularly useful when running AI agents (like Claude, Copilot, or Gemini) inside VMs to prevent them from accidentally modifying or breaking files on your host system.
123+
124+
### Usecase - Running AI Code Assistants Safely
125+
126+
1. Create an isolated instance for AI agents which must be started without host mounts for `--sync` to work:
127+
128+
```bash
129+
limactl start --name=ai-sandbox --mount-none template://default
130+
```
131+
132+
2. Navigate to your project
133+
134+
```bash
135+
cd ~/my-project
136+
```
137+
138+
3. Run an AI agent that modifies code:
139+
140+
```bash
141+
limactl shell --sync ai-sandbox claude "Add error handling to all functions"
142+
```
143+
144+
Or simply shell into the instance and make changes:
145+
```bash
146+
limactl shell --sync ai-sandbox
147+
```
148+
149+
4. After running commands, you'll see an interactive prompt:
150+
151+
```
152+
⚠️ Accept the changes?
153+
→ Yes
154+
No
155+
View the changed contents
156+
```
157+
158+
- **Yes**: Syncs changes back to your host and cleans up guest directory
159+
- **No**: Discards changes and cleans up guest directory
160+
- **View the changed contents**: Shows a diff of changes made by the agent
161+
162+
### Requirements
163+
164+
- **rsync** must be installed on both host and guest
165+
- The host working directory must be at least 4 levels deep (e.g., `/Users/username/projects/myproject`)
166+
- The instance must not have any host mounts configured (use `--mount-none` when creating)
167+
168+
119169
## See also
120170

121171
- [Config » AI](../config/ai/)

0 commit comments

Comments
 (0)