I run AI agents in a dedicated VM. The agents need access to my local git repositories so they can read and modify code, but I don’t want them to be able to push to remotes, read my SSH keys, or touch anything outside the repos directory — even if an agent somehow gains root in the VM.

The Setup

The workflow looks like this:

  • The host machine holds the git repositories and is where I push and commit.
  • The agents VM runs AI agents (e.g. Claude Code). It has no git credentials of its own.
  • The agent mounts the repos via sshfs, reads and writes files normally, but has no path to the outside world from the host’s perspective.

This means I retain full control over git history — I review what the agent changed, then push myself. The agent just works on the files.

The Problem with a Plain SSH Key

If I gave the agents VM a normal SSH key, it would have full shell access to my home directory. Even an sftp-only key without a command= restriction can browse the entire filesystem the user has access to. And if the agent gains root inside the VM, it controls that key.

The critical property I want: even root in the agents VM cannot access anything on the host beyond the repos directory. The constraint has to live on the host, not inside the VM.

Solution: Forced Command + bwrap Sandbox

Two mechanisms work together:

  1. authorized_keys forced command — The host runs a wrapper script for this key regardless of what the client requests.
  2. bwrap sandbox — The wrapper launches sftp-server inside a bubblewrap namespace where only the repos directory is visible, read-write.

The Wrapper Script

Save this as /usr/local/bin/sftp-force-directory on the host:

#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Jan Christoph Uhde <jan@uhde.io>
set -uo pipefail

log() {
    echo "$(date) [$USER] $*" >&2
    echo "$(date) [$USER] $*" >> "$HOME/sftp_wrapper.log"
}

if (( $# != 1 )); then
    echo "Usage: $0 <allowed-directory>" >&2
    exit 2
fi

allowed_dir="$1"
allowed_command="/usr/lib/openssh/sftp-server"

if [[ -z "$allowed_dir" || "$allowed_dir" == '/' || ! -d "$allowed_dir" ]]; then
    log "Invalid allowed_dir: '$allowed_dir'"
    exit 1
fi

if [[ -z "${SSH_ORIGINAL_COMMAND:-}" ]]; then
    log "Access denied: no command provided"
    exit 1
fi

log "CMD: $SSH_ORIGINAL_COMMAND"

if [[ "$SSH_ORIGINAL_COMMAND" != "$allowed_command" ]]; then
    log "Access denied: only sftp-server is allowed"
    exit 1
fi

allowed_real=$(realpath -- "$allowed_dir")

exec bwrap \
    --ro-bind /usr /usr \
    --ro-bind /lib /lib \
    --ro-bind /lib64 /lib64 \
    --ro-bind /bin /bin \
    --ro-bind /etc/passwd /etc/passwd \
    --dir /dev \
    --dev-bind /dev/null /dev/null \
    --dev-bind /dev/zero /dev/zero \
    --dev-bind /dev/random /dev/random \
    --dev-bind /dev/urandom /dev/urandom \
    --proc /proc \
    --dir /tmp \
    --bind "$allowed_real" /mnt \
    --chdir /mnt \
    /usr/lib/openssh/sftp-server

bwrap gives sftp-server read-only access to the system libraries it needs, and read-write access to exactly one directory mounted at /mnt. The rest of the host filesystem simply does not exist inside the sandbox.

authorized_keys Entry on the Host

command="/usr/local/bin/sftp-force-directory /path/to/repos-claude" <your public key> root@agents-vm

The command= option means OpenSSH ignores whatever the client asks for and always runs the wrapper. The target directory is baked into the key entry — different keys can be locked to different directories.

No extra options like no-pty are required: the wrapper itself rejects everything except an exact sftp-server invocation, so a shell can never be opened regardless.

sshfs Mount on the Agents VM

On the agents VM, mount the repos directory via sshfs. An /etc/fstab entry for a persistent mount:

vmhost: /home/user/remote-repos fuse.sshfs _netdev,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,allow_other,uid=1001,gid=1001 0 0

The agent sees the repos as a normal local filesystem and can read, write, and run git operations on them. It just has no credentials to push anywhere.

What This Protects Against

  • No host filesystem access — the agent cannot see anything outside the repos directory, not even a directory listing one level up.
  • No shell — only sftp-server is accepted; any other command is logged and rejected.
  • Root in the VM is not enough — even if an agent gains root and controls the SSH key, the host-side wrapper and bwrap sandbox still apply. The kernel enforces the namespace; there is nothing inside the VM that can bypass it.
  • No git push from the VM — the agent has no git remote credentials. It can commit locally to the mounted repos, but pushing requires credentials that live only on the host.

Dependencies

  • bwrap (bubblewrap) — apt install bubblewrap on Debian/Ubuntu
  • sshfs on the agents VM — apt install sshfs
  • OpenSSH server with sftp-server at /usr/lib/openssh/sftp-server (adjust path if needed)