Beyond the Sandbox: Hardening Claude Code Against Prompt Injection and Data Exfiltration.

  ·  18 min read

Last week I put Claude Code in a Microsandbox microVM and exposed only the repository I was actively working on. Claude could edit my code, but it was no longer a normal macOS process running with my user account’s ambient access to ~/.ssh, ~/.aws, ~/.kube, browser data, and everything else in my home directory.

That gives Claude a much better isolation boundary, but it leaves an interesting question: what have I deliberately trusted inside that boundary?

Claude still needs meaningful capabilities to be useful, because it has to modify the repository, call Anthropic, interact with GitHub, keep some state between sessions, and occasionally reach other services. If Claude is prompt-injected, a package installer runs malicious code, or a plugin is compromised, that code inherits every one of those capabilities.

ℹ️ Everything here is msb 0.6.8. The CLI is moving quickly, so check the flags against your own version before copying them.

The boundaries I changed #

I created the boundary between macOS and the Linux guest, and now four surfaces remain open inside it:

Surface Before Question for now
Guest privilege Claude runs as node Can compromised code gain more privilege inside the VM?
Persistence /home/node survives sessions What should be allowed to survive a disposable VM?
Network Any public destination is reachable Where should code inside the VM be able to send data?
Credentials Claude and development tools authenticate normally Which credentials does the guest actually need to possess?

Each of these controls covers less on its own than it first appears. A read-only filesystem doesn’t prevent a readable secret from being sent over the network, a network allowlist doesn’t stop Claude from deleting a writable source file, and a microVM doesn’t make credentials stored inside that VM unreadable to code running as their owner.

The security model is therefore a set of explicit capabilities: Claude can read and write this repository; Claude can talk to Anthropic and GitHub; Claude cannot browse my home directory; Claude cannot reach an arbitrary server on the Internet; and, if I choose the stronger credential setup later, Claude can use a credential without possessing its real value.

1. Reduce privilege inside the guest #

At first my Dockerfile ended with:

USER node

That’s an OCI image setting, and it means commands from the image, including Claude, start as the unprivileged Linux user node rather than root.

Microsandbox provides a second control called its restricted security profile, which is not another Dockerfile instruction but a host-side option supplied when the VM is started with our launcher script:

--security restricted

According to Microsandbox’s hardening documentation, the restricted profile sets no_new_privs, drops the mount-administration capability, and forces nosuid,nodev on user mounts, all of it intended to reduce what untrusted code can do inside the guest after it starts.

Why use both? Running as node avoids handing the workload root privilege in the first place, and no_new_privs then prevents the process and its children from gaining privileges through mechanisms such as setuid executables. Neither replaces the microVM boundary, but both reduce the options available to code that has already been compromised inside it.

The restricted profile is incompatible with workloads that need privilege escalation or mount administration, Docker-in-Docker being the obvious one. That’s fine for my Claude Code image, but check it before you copy the flag somewhere else and spend an evening blaming the wrong thing.

I also leave the repository executable. A noexec source mount sounds attractive until Claude needs to run a test runner, repository script, compiler output, or locally built binary, and for a development workspace that’s a bit too aggressive.

2. Decide what should survive the VM #

The VM root filesystem is disposable, but I deliberately mounted a named volume alongside it:

claude-home -> /home/node

Destroying the VM does not destroy that volume. The persistent home is useful: Claude remembers its login, settings, MCP configuration, and other user state.

It can also become a persistence mechanism for hostile code. A modified shell file, plugin, or executable under /home/node is still there when the next clean VM starts.

I started this one from a fresh volume rather than carrying the old one across, because I wanted to know which persistent files appeared after the new boundary went up. The launcher creates it on first run:

--mount-named "$profile_volume:/home/node:kind=dir,quota=4G"

Microsandbox makes {$profile_volume$} a directory-backed named volume and records the 4 GiB quota. Later runs reuse it, and because the quota is part of the volume’s recorded configuration, it refuses to reuse a volume whose quota differs rather than silently changing it.

💡 Don’t use msb volume create for this. It has no directory-quota option, so the volume records an unlimited quota and conflicts with the launcher’s quota=4G.

I still keep /home/node writable. Making it ephemeral would mean logging Claude in and rebuilding its configuration every single session, which I would tolerate for about two days before quietly reverting it. Persistence stays, as long as I treat the volume as trusted state rather than part of the disposable VM.

That gives us a recovery rule for later. If a session runs something genuinely suspicious, rebuilding the VM is routine. If I suspect persistent user state was modified, I replace the named home volume too.

3. Turn network access into a capability #

In the first version, I left the network wide open, so a rogue script had full access to the internet:

--net public

Microsandbox’s public profile is already useful isolation, since it permits public Internet access while denying private networks, the host, link-local addresses, and cloud metadata. The problem is that public egress is still broad, and code that can read the repository can also connect to an arbitrary public server and send it whatever it read.

Disabling networking is just inviting misery. Claude has to call Anthropic to be Claude at all, and I expect it to use GitHub without asking me to rebuild its sandbox every time it looks at a pull request. Package installation and arbitrary WebFetch are useful too, although I don’t need them in every session.

So the question isn’t “network or no network?” but “which destinations are normal capabilities of this development environment?”

For mine, I thought the answer was narrower than it turned out to be:

  • Anthropic endpoints needed for Claude Code and authentication.
  • GitHub over HTTPS, including the API and content hosts.
  • DNS through Microsandbox’s gateway.

(That lasted a few days, this list is getting longer…)

How deny-by-default works #

💡 --no-net sounds as though it should make networking impossible. It doesn’t. The CLI keeps the network device and sets policy to deny traffic, so explicit --net-rule entries can still permit selected destinations. Microsandbox calls this allowlist mode in its networking documentation.

The basic shape is:

--no-net
--net-rule 'allow@some-host.example:tcp:443'

There is one less obvious dependency: DNS.

Why the DNS rules target host #

The Linux guest doesn’t send DNS directly to Cloudflare or whatever resolver macOS happens to be using, and instead talks to Microsandbox’s gateway DNS service, which resolves names on the guest’s behalf. A deny-by-default policy therefore has to permit the guest to reach that gateway on port 53.

The working rules are:

--net-rule 'allow@host:udp:53'
--net-rule 'allow@host:tcp:53'

The gateway uses the host resolver by default, which keeps the guest compatible with VPN, split-horizon DNS, and local zones without exposing those networks directly to it, and the only thing these rules permit is the guest-to-gateway DNS traffic itself.

Trust, but verify… #

I wouldn’t jump straight to the full Claude configuration, because the smallest useful test is one allowed domain and one denied domain.

First allow the Anthropic API:

msb run \
    --security restricted \
    --no-net \
    --net-rule 'allow@host:udp:53' \
    --net-rule 'allow@host:tcp:53' \
    --net-rule 'allow@api.anthropic.com:tcp:443' \
    ai-sandboxes-claude:local \
    -- curl -sSI --connect-timeout 5 https://api.anthropic.com/

The exact HTTP status doesn’t matter here, because receiving an HTTP response proves that DNS resolution, TCP, and TLS succeeded.

Now keep the same policy and ask for a domain we didn’t allow:

msb run \
    --no-net \
    --net-rule 'allow@host:udp:53' \
    --net-rule 'allow@host:tcp:53' \
    --net-rule 'allow@api.anthropic.com:tcp:443' \
    ai-sandboxes-claude:local \
    -- curl -sSI --connect-timeout 5 https://example.com/

That request should fail: Anthropic is reachable, example.com is blocked.

That pair of tests tells us considerably more than a configuration file alone does, because it shows that DNS still works, that the domain rule really does grant access, and that resolving names hasn’t accidentally reopened arbitrary egress.

Keep the everyday allowlist outside the launcher #

Anthropic publishes a network access table for Claude Code, and for a direct Claude.ai login the core hosts are:

api.anthropic.com
claude.ai
claude.com
platform.claude.com

Anthropic documents other hosts for features such as MCP connectors, updates, plugins, release notes, documentation lookups, and telemetry. Pick what you want, pick them all. That’s your security positioning, not mine.

The right allowlist depends on which Claude features you expect to work and for ease I keep my list of permitted HTTPS hosts in a small host-side configuration file instead. I use:

~/.config/microvms/claude-egress

Create ~/.config/microvms if it does not already exist, then create claude-egress with one hostname per line:

# Claude Code
api.anthropic.com
claude.ai
claude.com
platform.claude.com

# GitHub
github.com
api.github.com
*.githubusercontent.com

Restrict the file to your account:

chmod 600 ~/.config/microvms/claude-egress

There are no credentials in this file, so 0600 isn’t protecting a secret, and it simply makes this host-owned policy private to your user account while keeping it outside both the launcher and the repository mounted into the VM.

GitHub is in there by default because Claude Code inspects pull requests, pushes branches, and works through review feedback often enough that a network-policy edit every time would make the launcher irritating enough to abandon. That gives Git and gh reachability, not credentials, and we’ll handle those separately in the credential section. The list assumes HTTPS, so add the relevant destination and port if your Git workflow uses SSH, or your own hostname if you self-host Gitea or Forgejo.

Adding a routine service is now mundane. In a Node development environment where Claude regularly installs dependencies, for example, add:

registry.npmjs.org

The next Claude session can reach it, with no second launcher to maintain and no msb command to rewrite. Given how often I have been adding lines to this file, that turned out to be the design decision I’m happiest with.

What about WebFetch, npm, and arbitrary documentation? #

This is the real usability cost of deny-by-default egress, because Claude can’t fetch https://some-blog.example and npm install can’t reach a registry unless that registry is allowed.

For dependencies or services I use constantly I’d add their actual domains to the normal policy, and registry.npmjs.org:443 is a reasonable permanent capability in a Node-focused development image where package installation is routine.

For an unfamiliar dependency or external service, make the same decision you’d make for any other development dependency: if it’s a normal part of the environment, add its hostname. If your workflow depends on unrestricted WebFetch, a strict allowlist is the wrong network policy for it, and then --net public is the better choice.

I gave my launcher an override for exactly that, as an explicit per-session exception:

CLAUDE_MSB_PUBLIC_EGRESS=1 claude

This uses Microsandbox’s public network profile, which still blocks the host, private networks, link-local addresses, and cloud metadata, but permits connections to arbitrary public destinations. Use it only for the session that needs it, because it removes the egress boundary that protects repository data from arbitrary public exfiltration.

4. Separate network reachability from credentials #

Allowing api.github.com answers one question: can a packet reach GitHub? Authentication answers another: what is the process allowed to do once it gets there?

The simplest setup is to authenticate normally inside the guest. Claude stores its Linux credentials under ~/.claude, and gh auth login stores GitHub authentication in its normal guest-side configuration. Because /home/node is persistent, both survive subsequent VMs.

The consequence: malicious code running as node should be assumed capable of reading credentials owned by node. Mode 0600 keeps other Linux users out and does precisely nothing to protect a file from its owner, which is a sentence worth re-reading if you have ever felt reassured by seeing 0600 in a security checklist. That may still be an acceptable boundary for you. It mostly is for me.

When host-side secret injection is worth the complexity #

But, I went quite far into the rabit hole here, for science (and my own lack of anything else interesting to do on a hot and humid Sunday.) Microsandbox supports a stronger model.

Its secret mechanism places a placeholder such as $MSB_GITHUB_TOKEN in the guest and keeps the real value in the host-side process. On an allowed, inspected TLS request, Microsandbox substitutes the real value at the network boundary.

That changes the compromised-guest scenario, because a process dumping its own environment or memory finds the placeholder rather than the reusable token.

There is an important limitation: secret injection doesn’t stop the guest from using the credential against an allowed service. If a GitHub token can modify workflows, create releases, or write to ten repositories, hostile code may still exercise those permissions through GitHub. The mechanism protects the value from being extracted and reused elsewhere, while token scope controls its authority at the service.

It also relies on Microsandbox being able to inspect the TLS request and verify the destination, which is why I treat secret injection as an optional additional boundary rather than hiding it inside the beginner path.

For GitHub, gh natively honors GH_TOKEN, and if the host already exposes a suitable GH_TOKEN the Microsandbox CLI syntax is:

--secret 'GH_TOKEN@api.github.com,github.com'

The real value comes from the host environment variable of the same name, and you shouldn’t put the literal token into the launcher or the command line.

With this mode enabled there’s no reason to run gh auth login inside the persistent guest home, because gh sees the placeholder through GH_TOKEN and Microsandbox handles substitution for eligible requests to the allowed GitHub hosts.

Applying the same idea to Claude’s credential #

Claude Code on Linux normally stores authentication in ~/.claude/.credentials.json with mode 0600, according to Anthropic’s authentication documentation, and once again code running as the same node user can read it.

Claude Code can also use a long-lived token generated with:

claude setup-token

via CLAUDE_CODE_OAUTH_TOKEN, and if you deliberately choose that authentication model it can be combined with Microsandbox secret injection so the reusable value stays host-side.

I’d make that change because I have a tendency to be intense. Most of my computer is automated, this was no unique exception. I want things easy, and I want them secure.

5. Assemble the hardened Fish launcher #

At this point every flag in the launcher represents a decision we’ve already made:

  • --security restricted reduces privilege inside the Linux guest.
  • --root-disk 10G bounds the writable, disposable root filesystem.
  • {$profile_volume$} is the state we intentionally allow to persist.
  • --no-net changes egress from allow-by-default to deny-by-default.
  • The two host port 53 rules preserve gateway DNS.
  • The host-side egress file describes the HTTPS services this environment normally uses.
  • The environment variables remove optional Claude traffic we don’t want in this profile.

For Fish, my launcher became this:

function claude --description 'Run Claude Code in a hardened Microsandbox VM'
    set -l image 'ai-sandboxes-claude:local'
    set -l profile_volume 'claude-home-hardened'
    set -l egress_file "$HOME/.config/microvms/claude-egress"
    set -l workspace_quota '10G'
    set -l root_disk '10G'
    # Let Microsandbox's gateway DNS follow the host resolver. An external
    # resolver is not reachable through every public-network gateway.
    set -l network_args \
        --no-net \
        --net-rule 'allow@host:udp:53' \
        --net-rule 'allow@host:tcp:53'

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

    if set -q CLAUDE_MSB_PUBLIC_EGRESS; and test "$CLAUDE_MSB_PUBLIC_EGRESS" = 1
        set network_args --net public
    else
        if not test -f "$egress_file"
            echo "claude: missing egress allowlist: $egress_file" >&2
            echo 'claude: copy config/claude-egress.example there and review its hosts' >&2
            return 1
        end

        while read -l egress_host
            set egress_host (string trim -- "$egress_host")
            if test -z "$egress_host"; or string match -q '#*' -- "$egress_host"
                continue
            end

            # The allowlist contains hostnames only: one HTTPS destination per line.
            if not string match -rq '^(\*\.)?[A-Za-z0-9][A-Za-z0-9.-]*$' -- "$egress_host"
                echo "claude: invalid hostname in $egress_file: $egress_host" >&2
                return 1
            end
            set -a network_args --net-rule "allow@$egress_host:tcp:443"
        end < "$egress_file"
    end

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

    set -l home_path (realpath "$HOME")
    if test -z "$host_workspace"; or test "$host_workspace" = /; or test "$host_workspace" = "$home_path"
        echo 'claude: refusing to mount an empty path, /, or the complete home 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 \
        --tty \
        --pull never \
        --user node \
        --cpus 4 \
        --memory 8G \
        --root-disk "$root_disk" \
        --security restricted \
        $network_args \
        --mount-dir "$host_workspace:$guest_workspace:rw,quota=$workspace_quota" \
        --mount-named "$profile_volume:/home/node:kind=dir,quota=4G" \
        --workdir "$guest_workspace" \
        "$image" \
        -- env \
            CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
            DISABLE_TELEMETRY=1 \
            CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 \
            ENABLE_CLAUDEAI_MCP_SERVERS=false \
            claude $argv
end

Unless CLAUDE_MSB_PUBLIC_EGRESS=1 is set, the launcher starts with the two DNS rules, reads the hostnames from claude-egress, and turns each one into an HTTPS rule of the form allow@hostname:tcp:443, ignoring comments and blank lines as it goes. The policy stays visible as data rather than embedded shell syntax.

The first invocation creates the quota-bearing claude-home-hardened volume and mounts it at /home/node, so authenticate Claude again. Once login works, exit and start a second session to prove that authentication survives in the named volume.

If you choose host-side secret injection later, add the relevant --secret options to this msb run command.

ℹ️ This launcher is the Fish-specific, Claude-specific version of what I now maintain in rikdc/ai-sandboxes, where the same controls are generalised across agents rather than hardcoded for one shell function. If you followed Part 1 using that repository rather than building the image by hand, the hardening described here is already wired into it, and the egress allowlist is a file you edit instead of a launcher you rewrite.

6. How I tested this. #

Maybe it’s out of some sense of duty, documentation or invitation to be messaged and told I’m wrong, here is how I tested this setup. Each of the following answers a specific security claim rather than serving as a generic hardening checklist.

Inside the normal hardened session:

whoami
grep NoNewPrivs /proc/self/status

These verify the in-guest privilege boundary, and you should expect node and NoNewPrivs: 1.

Then look at the filesystem:

ls -la /Users
ls -la /workspace

The Mac’s /Users tree shouldn’t be mounted at all, and /workspace should contain only the host directory you intentionally exposed.

Next prove both sides of the network policy:

curl -sSI --connect-timeout 5 https://api.anthropic.com/
curl -sSI --connect-timeout 5 https://api.github.com/
curl -sSI --connect-timeout 5 https://example.com/

Anthropic and GitHub should be reachable at the transport layer, and example.com should fail because it isn’t on the allowlist.

Finally, test a private address that exists on your LAN with a short timeout, and it should remain unreachable. That checks a different boundary from the public-domain test, since the guest shouldn’t gain LAN access merely because we permitted gateway DNS.

7. What did we do, and what we now have? #

One thing I have not fixed, and don’t intend to: the repository is still mounted rw. Claude can read, modify, and delete anything in it. That is what a coding agent does, and building an elaborate sandbox around an agent that can’t edit your code would be an odd use of an afternoon.

Two consequences follow. .gitignore has no security value, because a .env file in the mounted repository is readable whether or not Git tracks it, so keep secrets out of the repository entirely. And Git is your recovery mechanism, so commit or stash anything you care about before turning Claude loose, then read the diff afterwards.

For a task that genuinely needs only inspection, mount it read-only:

--mount-dir "$host_workspace:$guest_workspace:ro,quota=10G"

Microsandbox enforces that host-side, so the guest can’t remount it writable. Good for code review, tedious as a default.

Recovery depends on what you think was affected, because the root filesystem, the named volume, and the repository all have different lifetimes. If untrusted build code ran and you only care about the VM root, the next microVM is already clean. If you suspect something touched persistent Claude state, replace the volume and log in again:

msb volume rm claude-home-hardened

The next session recreates it empty. If the repository may have changed, recover it with Git. If a reusable credential was exposed inside the guest, revoke it, because no amount of deleting VMs will make a copied token secret again.

Which is the whole reason the persistence boundary came before the flags. “The VM is disposable” is only true of the parts that are actually disposable, and I’d rather find that out now than during an incident.

Where that leaves Claude #

The final setup keeps Claude useful while making its remaining authority easier to describe:

Surface Before After
Compute Hardware-isolated microVM Same microVM boundary
Guest user node node + restricted profile
Host filesystem Current repository Current repository
Repository Read/write Read/write; read-only when the task permits
Claude home Persistent Fresh, explicitly trusted persistent volume
Public egress Any public destination Hosts listed in claude-egress
Private/LAN egress Blocked Blocked
Arbitrary WebFetch Available Limited to hosts you choose to allow
GitHub Reachable Reachable by default over HTTPS
Credentials Stored normally in guest Normal guest storage, with host-side injection as an optional stronger mode
DNS Automatic upstream Gateway DNS using the host resolver

The microVM remains the strongest single boundary in the design, and the Part 2 controls make the things deliberately placed on the other side of it smaller and more predictable.

The resulting contract is straightforward: Claude gets the repository, enough network access to be Claude, normal GitHub automation, and persistent state that can be discarded. The small egress file keeps network policy editable without turning the launcher into policy soup.

References #