Sandbox Virtual Machine for Development and AI Agent

Why a Virtual Machine, Not a Container

Containers share the host kernel, credential store, and filesystem namespaces. A VM provides a hard boundary: the guest has no visibility into the host filesystem, no access to SSH keys or git config, and no way to reach host services unless explicitly forwarded. No login is performed from inside the VM — authentication always happens on the host. The VM only sees the code directory that is deliberately shared with it.


Setting Up the Virtual Machine

Guest OS: Fedora Server — minimal by default, no desktop environment bundled, dnf for package management.

On Linux — QEMU: Available in most distro repositories, supports virtio paravirtualized devices required for the shared filesystem. Create a disk image and boot the installer:

qemu-img create -f qcow2 fedora-agent.qcow2 20G

qemu-system-x86_64 \
  -m 2048 \
  -smp 2 \
  -hda fedora-agent.qcow2 \
  -cdrom Fedora-Server-43-x86_64-dvd1.iso \
  -boot d \
  -net nic -net user

After installation, start the VM without the ISO and add a virtio-9p share:

qemu-system-x86_64 \
  -m 2048 \
  -smp 2 \
  -hda fedora-agent.qcow2 \
  -net nic -net user \
  -virtfs local,path=/home/user/projects,mount_tag=projects,security_model=mapped-xattr

Virtual machines can also be managed using virt-manager

On macOS — UTM: Wraps QEMU with a native macOS GUI. Create a new VM → Virtualize → Linux, set 2 GB RAM and 2 CPU cores, and add a shared directory in the VM settings.

Software Setup

Scripts are used so the environment is reproducible — a fresh VM reaches a working state in one run.

  • vm-setup.sh — installs Xorg, Awesome WM, Thunar, git, and basic utilities. Run with -y to skip confirmation prompts.
  • vm-apps.sh — installs Go, Node (via nvm), Ghostty, Claude CLI, lf, fzf. Run with -y for unattended install.

Why a GUI? The agent needs a browser for OAuth flows, previews, and documentation. Xorg with a minimal window manager is the smallest surface for that — a full desktop like GNOME adds unnecessary packages and background services.

The XOrg vs Wayland debate has been happening in many forums, blog posts, and social media sites. My choice for XOrg was mainly because it is stable and runs well enough inside the virtual machine.

Why Awesome WM? Lua-configured, starts in under a second, no compositor or notification daemon. The default config at /etc/xdg/awesome/rc.lua is used verbatim except for some additions:

  • Populating the menu from installed .desktop files so apps appear without adding them manually
  • Custom commands like Shutdown and Reboot

Awesome WM already indexes desktop entries which is searchable in the menubar, the configuration below just adds those desktop entries in in a new submenu called applications:

-- generate applications menu
menubar.menu_gen.generate(function(entries)
    local items = {}
    for _, v in ipairs(entries) do
        table.insert(items, { v.name, v.cmdline, v.icon })
    end
    mymainmenu:add({ 'applications', items }, 2)
end)

File Sharing and Mounting

Two paravirtualized protocols are available — virtio-9p and virtiofs. Both are faster than Samba and require no network configuration. The share is identified by a label (mount tag).

virtio-9p

Older, widely supported, simpler to configure. Add the share to the QEMU command:

Mount inside the guest:

sudo mount -t 9p -o trans=virtio,version=9p2000.L projects /mnt/projects

/etc/fstab entry to mount the shared directory at startup:

projects /mnt/projects 9p trans=virtio,version=9p2000.L,rw,_netdev,nofail,auto 0 0

virtiofs

Newer, better performance and POSIX semantics. Requires virtiofsd running on the host and a shared memory backend in QEMU:

# Start the daemon on the host (run before QEMU)
virtiofsd --socket-path=/tmp/vhostqemu \
  --shared-dir=/home/user/projects \
  --announce-submounts

Mount inside the guest:

sudo mount -t virtiofs projects /mnt/projects

/etc/fstab entry to mount the shared directory at startup:

projects /mnt/projects virtiofs defaults,_netdev,nofail,auto 0 0

UTM uses virtiofs by default. If the mount fails, check the label the kernel sees:

dmesg | grep virtiofs

Note: UTM always exposes the virtiofs share under the fixed label share. Use that label when mounting:

sudo mount -t virtiofs share /mnt/projects

And in /etc/fstab:

share /mnt/projects virtiofs defaults,_netdev,nofail,auto 0 0

UID/GID Remapping with bindfs

virtio-9p protocol preserves the host’s UID/GID, so the guest user may not own the files. bindfs layers a FUSE mount on top that remaps ownership:

sudo bindfs --map=1000/1000:@1000/@1000 /mnt/projects /home/user/projects

/etc/fstab entry (x-systemd.requires ensures the share is up before bindfs tries to use it):

/mnt/projects /home/user/projects fuse.bindfs map=1000/1000:@1000/@1000,x-systemd.requires=/mnt/projects,_netdev,nofail,auto 0 0

vm-mount.sh detects the share’s UID/GID automatically and appends both fstab entries:

./vm-mount.sh --label projects --mount /mnt/projects --map-mount /home/user/projects

Custom bindfs Patch: Filtering .git

The shared directory contains git repositories, but the agent must not access .git directory. This is a deliberate policy for the setup. Feel free to skip if you disagree.

The standard bindfs has no path filtering, so the following patch strips all permissions from any path component named .git (case-insensitive):

diff --git a/src/bindfs.c b/src/bindfs.c
index ba34891..1ea945a 100644
--- a/src/bindfs.c
+++ b/src/bindfs.c
@@ -116,6 +116,9 @@ static const int64_t UID_T_MAX = ((1LL << (sizeof(uid_t)*8-1)) - 1);
 static const int64_t GID_T_MAX = ((1LL << (sizeof(gid_t)*8-1)) - 1);
 static const int UID_GID_OVERFLOW_ERRNO = EIO;
 
+/* Subdirectories whose first path component matches this pattern (case-insensitive) are blocked with 000 permissions */
+static const char *blocked_dir_pattern = ".git";
+
 /* SETTINGS */
 static struct Settings {
     const char *progname;
@@ -415,6 +418,22 @@ static char *process_path(const char *path, bool resolve_symlinks)
     }
 }
 
+static bool is_blocked_path(const char *path) {
+    size_t len = strlen(blocked_dir_pattern);
+    if (len == 0) return false;
+    const char *p = path;
+    while (*p) {
+        if (*p == '/') p++;
+        const char *end = p;
+        while (*end && *end != '/') end++;
+        size_t component_len = (size_t)(end - p);
+        if (component_len == len && strncasecmp(p, blocked_dir_pattern, len) == 0)
+            return true;
+        p = end;
+    }
+    return false;
+}
+
 static int getattr_common(const char *procpath, struct stat *stbuf)
 {
     struct fuse_context *fc = fuse_get_context();
@@ -774,6 +793,8 @@ static int bindfs_getattr(const char *path, struct stat *stbuf)
 
     res = getattr_common(real_path, stbuf);
     free(real_path);
+    if (res == 0 && is_blocked_path(path))
+        stbuf->st_mode &= ~0777;
     return res;
 }
 
@@ -794,6 +815,8 @@ static int bindfs_fgetattr(const char *path, struct stat *stbuf,
     }
     res = getattr_common(real_path, stbuf);
     free(real_path);
+    if (res == 0 && is_blocked_path(path))
+        stbuf->st_mode &= ~0777;
     return res;
 }
 #endif

Hooks into bindfs_getattr and bindfs_fgetattr. When is_blocked_path detects a .git component, it zeroes the permission bits (st_mode &= ~0777) — the directory remains stat-able, but no read, write, or execute access is granted to anyone inside the guest.

I have a separate fork at ibrahim-13/bindfs, and modifications are done in the filter branch. The original repository is at mpartel/bindfs

setup_env.sh installs build dependencies (fuse3, fuse3-devel, gcc, autoconf, automake, libtool). Then build from source:

git clone https://github.com/mpartel/bindfs.git
cd bindfs
./setup_env.sh
./autogen.sh  # Only needed if you cloned the repo.
./configure
make
sudo make install

Trade-offs

Blocking .git

The agent can read and write source files, but .git is inaccessible. This means no git commands, no staging, no reading commit history for the coding agent. Git identity, credential helpers, and SSH keys stay on the host; allowing write access to .git would let the agent alter hooks, rewrite refs, or tamper with the index — bypassing host-side review tooling. Any git workflow must happen on the host.

No login from the VM

No SSH keys, tokens, or cloud credentials are placed inside the VM. This eliminates credential theft and unintended autonomous API calls. The cost is that anything requiring authentication must be initiated from the host — deliberate friction that keeps a human in control of what gets sent out.

Shared directory is writable from the guest as root

The original share is mounted read-write, so a rogue process with root in the guest could still modify or delete shared content. This is accepted; the coding agent and all commands will run as a non-root user. Keeping the agent unprivileged limits the blast radius to the shared directory, the same scope a developer has when running the agent directly on the host.