Running Claude Code in an Ephemeral Microsandbox MicroVM

  ¡  12 min read

A compromised dependency doesn’t need its own credential stealer if it can just prompt the AI agent already running with your permissions to do the harvesting for it, and Claude Code runs with exactly those permissions by default. I’ve laid out that threat model in full in Claude Code Runs as You. That Is the Problem.; this is the mechanical follow-up.

This tutorial builds a Claude Code image, loads it into Microsandbox, and configures a shell command named claude to launch it inside an ephemeral microVM.

â„šī¸ This setup was tested on Apple Silicon with Fish; on an x86-64 host, swap linux/arm64 for linux/amd64, and the remaining steps should hold up on Linux too as long as you have Docker, Microsandbox, Git, and a shell (Fish, Bash, or Zsh) installed.

The finished system provides:

  • One ARM64 OCI image containing Claude Code, Git, GitHub CLI, Gitea Tea, jq, ripgrep, and an optional pre-installed Claude plugin marketplace.
  • Hardware-isolated execution through Microsandbox.
  • Only the current Git repository mounted into the VM.
  • Persistent Claude authentication, settings, plugins, MCP configuration, and conversation history, even though the VM filesystem itself is recreated every time Claude starts.
  • Automatic microVM removal when Claude exits.
  • No Docker account, registry push, or Docker Sandbox login.

1. Verify the host #

Confirm the Mac is running natively as Apple Silicon:

uname -m
arch

Both commands should print arm64.

Confirm that Docker is running and containers have outbound network access:

docker version
docker run --rm node:22-bookworm \
    curl -fsSI https://registry.npmjs.org/

A successful network check returns an HTTP status such as HTTP/2 200.

Confirm Microsandbox:

msb doctor

Correct any errors reported by msb doctor before continuing.

2. Create the Claude Code image #

Create a directory for the image definition:

mkdir -p ~/claude-msb-image
cd ~/claude-msb-image

Create Dockerfile with the following contents:

FROM node:22-bookworm

RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \
        /etc/apt/sources.list.d/debian.sources \
    && apt-get update \
    && apt-get install -y --no-install-recommends \
        bash \
        ca-certificates \
        curl \
        git \
        jq \
        openssh-client \
        ripgrep \
    && rm -rf /var/lib/apt/lists/*

RUN mkdir -p -m 755 /etc/apt/keyrings \
    && curl -fsSL \
        https://cli.github.com/packages/githubcli-archive-keyring.gpg \
        -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
    && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
        > /etc/apt/sources.list.d/github-cli.list \
    && apt-get update \
    && apt-get install -y --no-install-recommends gh \
    && rm -rf /var/lib/apt/lists/*

ARG TARGETARCH
ARG TEA_VERSION=0.14.2

RUN set -eu; \
    case "${TARGETARCH}" in \
        arm64) \
            tea_sha256="f201f6ba4136f1129e99e6318af07900c0c16a92030648bd186ff27067b34568" \
            ;; \
        amd64) \
            tea_sha256="be4ab135752825ab223cfa87d30e7f328312a24120b70176b67c1bd4aba19cc3" \
            ;; \
        *) \
            echo "Unsupported architecture: ${TARGETARCH}" >&2; \
            exit 1 \
            ;; \
    esac; \
    curl -fsSL \
        "https://dl.gitea.com/tea/${TEA_VERSION}/tea-${TEA_VERSION}-linux-${TARGETARCH}" \
        -o /usr/local/bin/tea; \
    echo "${tea_sha256}  /usr/local/bin/tea" | sha256sum -c -; \
    chmod 0755 /usr/local/bin/tea

ARG CLAUDE_CODE_VERSION=latest

RUN npm install --global "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
    && claude --version

RUN mkdir -p /workspace /home/node /opt/claude-plugin-seed \
    && chown -R node:node \
        /workspace \
        /home/node \
        /opt/claude-plugin-seed

ENV HOME=/home/node
ENV SHELL=/bin/bash
ENV PATH=/usr/local/bin:/usr/bin:/bin

USER node
WORKDIR /workspace

RUN export CLAUDE_CODE_PLUGIN_CACHE_DIR=/opt/claude-plugin-seed; \
    claude plugin marketplace add \
        https://github.com/rikdc/ai-skills.git; \
    for plugin in \
        security-hooks \
        pr-review-triage \
        dev-skills \
        git-workflow \
        pm-tools \
        code-quality \
        prompt-tools; \
    do \
        claude plugin install "${plugin}@ai-skills"; \
    done

ENV CLAUDE_CODE_PLUGIN_SEED_DIR=/opt/claude-plugin-seed

CMD ["claude"]

Tea comes from Gitea’s official release host and is checked against the published checksum for the selected architecture. GitHub CLI comes from GitHub’s official Debian repository rather than Debian’s older community package.

The final RUN instruction adds rikdc/ai-skills, my own plugin marketplace, through its explicit HTTPS URL, and pre-installs every plugin it declares into an image-level seed directory. I bake mine in at build time because I use the same handful of plugins in every session and would rather not reinstall them into a filesystem that gets thrown away the moment I exit.

To use another marketplace, replace the marketplace source and plugin names. Each plugin is installed using the PLUGIN_NAME@MARKETPLACE_NAME identifier declared by that marketplace. To build Claude without pre-installed plugins, remove the final plugin RUN instruction and CLAUDE_CODE_PLUGIN_SEED_DIR environment variable.

Credentials don’t belong in the Dockerfile: baking a token into an image is just a slower way of leaking it. Claude, GitHub CLI, and Tea all get authenticated after the image is running instead.

3. Build and verify the image #

From ~/claude-msb-image, build the image:

docker build \
    --platform linux/arm64 \
    --tag local/claude-code-msb:latest \
    .

Confirm the image architecture:

docker image inspect local/claude-code-msb:latest \
    --format '{{.Os}}/{{.Architecture}}'

The command should print:

linux/arm64

Verify Claude and the included development tools:

docker run --rm \
    --platform linux/arm64 \
    local/claude-code-msb:latest \
    bash -lc '
        set -e
        claude --version
        git --version
        gh --version
        tea --version
        jq --version
        rg --version
        ssh -V
    '

Each command should print version information and exit successfully.

4. Load the image into Microsandbox #

Export the Docker image directly into Microsandbox:

docker save local/claude-code-msb:latest | msb load

Confirm that Microsandbox has the image:

msb images

local/claude-code-msb:latest should appear in the output.

Test it in a temporary microVM:

msb run local/claude-code-msb:latest \
    -- claude --version

The VM should print the Claude version and exit. Because the command does not provide --name, Microsandbox removes the VM automatically.

5. Test repository mounting #

Run a temporary shell with the current directory mounted into the VM:

msb run \
    --cpus 4 \
    --memory 8G \
    --net public \
    --mount-dir "$PWD:/workspace/test:rw,quota=10G" \
    --workdir /workspace/test \
    local/claude-code-msb:latest \
    -- bash

Inside the VM, confirm the user, working directory, files, and Claude installation:

whoami
pwd
ls -la
claude --version

whoami should print node, and pwd should print /workspace/test.

Exit the microVM:

exit

6. Create persistent Claude storage #

Claude stores user-level configuration beneath its home directory, so persisting the complete /home/node directory also retains authentication and user configuration for gh, tea, plugins, MCP servers, and other tools.

Create the named volume and write a test file through a temporary microVM. --mount-named creates the volume if it does not already exist.

msb run \
    --mount-named 'claude-home:/home/node:kind=dir,quota=4G' \
    local/claude-code-msb:latest \
    -- bash -lc 'touch "$HOME/.volume-test"'

Verify that the file survives a completely new microVM:

msb run \
    --mount-named 'claude-home:/home/node:kind=dir,quota=4G' \
    local/claude-code-msb:latest \
    -- bash -lc 'test -f "$HOME/.volume-test" && echo "Volume persistence works"'

Expected result:

Volume persistence works

Remove the test file and inspect the volume:

msb run \
    --mount-named 'claude-home:/home/node:kind=dir,quota=4G' \
    local/claude-code-msb:latest \
    -- rm -f /home/node/.volume-test

msb volume ls
msb volume inspect claude-home

7. Install the shell launcher #

The launcher makes the isolated version of Claude available through the normal claude command: it discovers the repository root, constructs a unique guest path, mounts the repository and persistent Claude home, and forwards command-line arguments to Claude inside the microVM.

The launcher is shell-specific, but its behavior is the same. Use the Fish version if you run Fish, or the shared Bash and Zsh version if you use either of those shells.

Fish #

Create Fish’s function directory if it does not already exist:

mkdir -p ~/.config/fish/functions

Create ~/.config/fish/functions/claude.fish:

function claude --description "Run Claude Code inside a Microsandbox microVM"
    set -l image "local/claude-code-msb:latest"
    set -l profile_volume "claude-home"

    if not type -q msb
        echo "claude: msb is not installed or is not on PATH" >&2
        return 127
    end

    set -l host_workspace (git rev-parse --show-toplevel 2>/dev/null)
    if test $status -ne 0
        set host_workspace (pwd -P)
    end

    if test -z "$host_workspace"
        echo "claude: could not determine the current workspace" >&2
        return 1
    end

    if test "$host_workspace" = "/"; or test "$host_workspace" = "$HOME"
        echo "claude: refusing to mount a broad host directory" >&2
        return 2
    end

    set -l project_name (
        basename "$host_workspace" |
        string replace --all --regex '[^A-Za-z0-9._-]' '-'
    )

    set -l project_hash (
        printf '%s' "$host_workspace" |
        git hash-object --stdin |
        string sub --length 12
    )

    set -l guest_workspace "/workspace/$project_name-$project_hash"

    command msb run \
        --cpus 4 \
        --memory 8G \
        --net public \
        --mount-dir "$host_workspace:$guest_workspace:rw,quota=10G" \
        --mount-named "$profile_volume:/home/node:kind=dir,quota=4G" \
        --workdir "$guest_workspace" \
        "$image" \
        -- claude $argv
end

Fish automatically loads functions stored in this directory. Load it explicitly for the current shell and verify that Fish recognizes it:

functions --erase claude
source ~/.config/fish/functions/claude.fish
type claude

The output should identify claude as a function.

Bash and Zsh #

Bash and Zsh can use the same function. Add the following to ~/.bashrc for Bash or ~/.zshrc for Zsh:

claude() {
    local image="local/claude-code-msb:latest"
    local profile_volume="claude-home"
    local host_workspace
    local project_name
    local project_hash
    local guest_workspace

    if ! command -v msb >/dev/null 2>&1; then
        echo "claude: msb is not installed or is not on PATH" >&2
        return 127
    fi

    if ! host_workspace="$(git rev-parse --show-toplevel 2>/dev/null)"; then
        host_workspace="$(pwd -P)"
    fi

    if [ -z "$host_workspace" ]; then
        echo "claude: could not determine the current workspace" >&2
        return 1
    fi

    if [ "$host_workspace" = "/" ] || [ "$host_workspace" = "$HOME" ]; then
        echo "claude: refusing to mount a broad host directory" >&2
        return 2
    fi

    project_name="$(
        basename "$host_workspace" |
        sed 's/[^A-Za-z0-9._-]/-/g'
    )"

    project_hash="$(
        printf '%s' "$host_workspace" |
        git hash-object --stdin |
        cut -c1-12
    )"

    guest_workspace="/workspace/$project_name-$project_hash"

    command msb run \
        --cpus 4 \
        --memory 8G \
        --net public \
        --mount-dir "$host_workspace:$guest_workspace:rw,quota=10G" \
        --mount-named "$profile_volume:/home/node:kind=dir,quota=4G" \
        --workdir "$guest_workspace" \
        "$image" \
        -- claude "$@"
}

Load and verify the Bash function:

source ~/.bashrc
type claude

Or, for Zsh:

source ~/.zshrc
type claude

On macOS, Bash login shells may load ~/.bash_profile without loading ~/.bashrc. If the function is unavailable after opening a new terminal, add this to ~/.bash_profile:

if [ -f "$HOME/.bashrc" ]; then
    . "$HOME/.bashrc"
fi

8. Authenticate Claude and the included tools #

Enter any Git repository and launch Claude:

cd ~/repos/example-project
claude

Complete Claude’s normal login flow, then inside Claude run:

/status

Confirm that Claude reports the expected account and working directory, then exit and launch it again:

/exit
claude

The second session should remain authenticated through the claude-home volume.

The image contains gh and tea, but neither tool is authenticated during the image build, so authenticate them from a Claude session or a temporary shell using the same persistent volume:

gh auth login
tea login add

Their user-level configuration is written beneath /home/node and survives subsequent microVMs.

Inspect the pre-installed Claude plugins with:

/plugin

The image-level seed provides the default plugin collection, while anything installed interactively afterward lands in the persistent home volume instead.

9. Normal usage #

The Fish function forwards all arguments to Claude Code, so its normal CLI remains available:

claude

Pass an initial prompt:

claude "Explain the architecture of this repository"

Continue the previous conversation:

claude --continue

Select a model:

claude --model opus

Run non-interactively:

claude -p "Review the current Git diff"

Run with Claude’s internal permission prompts disabled:

claude --dangerously-skip-permissions

The microVM limits host access, but the mounted repository remains writable, so Claude can still delete, corrupt, or exfiltrate files within it, and Git is your only recovery and review boundary. I keep permission prompts on by default and only reach for --dangerously-skip-permissions when I’m handing Claude a long, unattended task on a repository I’ve already decided is disposable.

10. Verify isolation #

Launch claude, then ask it to run:

whoami
pwd
ls -la /workspace
ls -la /Users
ls -la /home/node

Confirm that:

  • whoami prints node.
  • pwd is the unique guest path for the current repository.
  • /workspace contains only the mounted repository directory.
  • The macOS /Users directory is unavailable.
  • /home/node contains only the persistent microVM profile.
  • Host SSH keys, cloud credentials, the macOS Keychain, and sibling repositories are not mounted.

The launcher grants public internet access through --net public, but it doesn’t grant private-network or host-network access, and I’ve never had to change that in practice. Treat broader access as an exception you can justify, not a default you reach for.

11. Update the image #

Rebuild the image to update Debian packages, GitHub CLI, Tea, Claude Code, or the seeded plugins:

cd ~/claude-msb-image

docker build \
    --pull \
    --no-cache \
    --platform linux/arm64 \
    --tag local/claude-code-msb:latest \
    .

Confirm the rebuilt version:

docker run --rm \
    --platform linux/arm64 \
    local/claude-code-msb:latest \
    claude --version

Replace Microsandbox’s cached copy and test the imported image:

msb image rm local/claude-code-msb:latest
docker save local/claude-code-msb:latest | msb load
msb run local/claude-code-msb:latest \
    -- claude --version

Replacing the image does not touch claude-home, so authentication and user-level configuration remain intact.

For tighter reproducibility, replace latest with a tested Claude Code version and update the pinned Tea version deliberately.

12. Maintenance and recovery #

Inspect Microsandbox state:

msb ps
msb ls
msb volume ls
msb volume inspect claude-home

The launcher uses unnamed sandboxes, so completed sessions should not accumulate.

Reset Claude completely by removing its named home volume:

msb volume rm claude-home

This permanently removes the persisted Claude login, settings, plugins, MCP configuration, caches, gh and tea profiles, and conversation history, a full personality wipe for the sandbox, though it doesn’t modify any repository.

The next claude invocation creates a fresh claude-home volume and behaves like a first installation.

13. Apply the pattern to other agents #

The isolation model isn’t specific to Claude Code: any command-line agent can use the same pattern.

  1. Install the agent in an OCI image.
  2. Run it as a non-root user.
  3. Mount only the current project into /workspace.
  4. Persist only the agent’s home directory or required configuration paths.
  5. Launch an unnamed Microsandbox VM for each session.

Each agent should have its own image and persistent home volume. Agent-specific plugins and skills should use that agent’s native packaging rather than copying Claude’s plugin cache into an incompatible tool.

14. Use the maintained multi-agent setup #

Following the tutorial manually makes every part of the setup visible. If you would rather use the completed implementation, or want a reference while troubleshooting your own build, rikdc/ai-sandboxes packages the same approach with support for both Claude Code and Codex.

The repository may include shared image layers, build helpers, verification scripts, and other maintenance conveniences that are useful in a reusable project but unnecessary for understanding the single-agent setup above. Use its README for the current installation commands.

🤖 This tutorial was drafted using Gemma 4 LLM, which processed my Claude Code session history. I have subsequently reviewed, refined, and added my own editorial input to ensure accuracy and quality.

References #