8000 GitHub - braiins/llm-jail: A Nix script for running CLI coding tools in an isolated MicroVM environment · GitHub
[go: up one dir, main page]

Skip to content
braiinsPublic

About

A Nix script for running CLI coding tools in an isolated MicroVM environment

Resources

Security policy

Stars

32 stars

Watchers

1 watching

Forks

Latest commit

 

History

238 Commits

Folders and files

Repository files navigation

llm-jail

Hardware-level sandbox for running coding agents inside QEMU microVMs. No containers, no persistent disk images - each session boots a minimal NixOS guest on tmpfs (or a throwaway ext4 disk with --disk) with the host Nix store shared read-only.

Supported tools:

Tool Runner command Dangerous flag
Claude Code llm-jail-claude --dangerously-skip-permissions
Codex CLI llm-jail-codex --dangerously-bypass-approvals-and-sandbox
GitHub Copilot CLI llm-jail-copilot --yolo
opencode llm-jail-opencode --auto
Autolith (x86_64 only) llm-jail-autolith --permissions full
Pi llm-jail-pi -
oh-my-pi llm-jail-omp -
Interactive shell (debugging) llm-jail-shell -

Requirements

  • Linux (x86_64 or aarch64)
  • Nix with flakes enabled
  • KVM access recommended (falls back to emulation without it)

No host-side tool credentials are needed: you log in once inside the jail on first run, and the tool's state persists in a jail-private directory under ~/.config/llm-jail/ (see First run & authentication).

Quick start

# Run Claude
nix run github:braiins/llm-jail#claude

# Run Claude in dangerous mode
nix run github:braiins/llm-jail#claude -- --dangerous

# Run Codex
nix run github:braiins/llm-jail#codex

# Run GitHub Copilot CLI
nix run github:braiins/llm-jail#copilot

# Run opencode
nix run github:braiins/llm-jail#opencode

# Run Pi
nix run github:braiins/llm-jail#pi

# Run oh-my-pi
nix run github:braiins/llm-jail#omp

# Authenticate Autolith, then run it (x86_64 Linux only)
nix run github:braiins/llm-jail#autolith -- -- --auth
nix run github:braiins/llm-jail#autolith

# Drop into a shell inside the sandbox (for debugging the jail itself)
nix run github:braiins/llm-jail#shell

Pass tool arguments after --:

nix run github:braiins/llm-jail#claude -- -- -p "Refactor the auth module" --max-turns 5

First run & authentication

Each tool keeps its state in a jail-private directory on the host - ~/.config/llm-jail/<tool>/<profile> (profile default unless --profile is given) - mounted read-write into the guest and selected via the tool's native relocation variable (CLAUDE_CONFIG_DIR, CODEX_HOME, COPILOT_HOME, Autolith's XDG roots plus CODEX_HOME/GROK_HOME, XDG_DATA_HOME for opencode, or PI_CODING_AGENT_DIR for Pi). Your real ~/.claude, ~/.codex, ~/.copilot, ~/.local/share/opencode, and ~/.pi are never mounted or read.

On first run the directory is empty, so the tool walks you through its normal login flow in the terminal (the OAuth paste-a-URL flow works as-is). Credentials, settings, and session history then persist across runs. Copilot on headless Linux will ask to store its token in plaintext inside the config dir - that's expected, there is no keychain in the guest. opencode doesn't prompt by itself - log in once with nix run .#opencode -- -- auth login. Authenticate Autolith explicitly with nix run .#autolith -- -- --auth; its first launch also builds pinned recovery and active Lisp images offline in the private state directory. Pi has no explicit login command - launch it once with nix run .#pi and use its built-in /login flow; credentials persist in the jail-private state directory.

Work for multiple clients by giving each its own profile, each with its own one-time login:

nix run .#claude -- --profile client1
nix run .#claude -- --profile client2

Renamed (migration note): --config-dir is now --state-dir (the jail-private state root directory, mounted read-write; <tool>/<profile> is appended beneath it). The old --config-dir fla 6870 g is rejected; the LLMJAIL_CONFIG_DIR env var still works but is deprecated (use LLMJAIL_STATE_DIR). Do not point it at a directory your host tool also uses - a compromised agent could write hooks or settings there that your host tool would later execute. If you want to reuse an existing host login rather than logging in fresh, you can copy it in at your own risk: cp -a ~/.claude/. ~/.config/llm-jail/claude/default/ (note that host and jail will then refresh the same OAuth token independently).

Usage

llm-jail-{claude,codex,copilot,opencode,autolith,pi,shell} [options] [-- tool-args...]

Options

Flag Description Default
--dangerous Enable the tool's dangerous / unattended mode off
--profile NAME State profile, a subdir of --state-dir/<tool> default
--state-dir PATH Jail state root dir, mounted read-write ~/.config/llm-jail
--immutable Mount workspace as read-only off
--tmpdir PATH Directory to use for runtime data ${TMPDIR:-/tmp}
--mount PATH Extra read-write mount (repeatable) -
--ro-mount PATH Extra read-only mount (repeatable) -
--nix-env Capture nix develop environment from workspace off
--devenv Capture devenv.sh shell environment from workspace off
--disk SIZE Use an ext4 disk (SIZE in GB) as the root filesystem instead of RAM off
--allow-domain DOMAIN Add domain to network whitelist (repeatable) tool defaults
--no-net-filter Disable network filtering (unrestricted access) filtering on
--mem SIZE VM memory in MB 4096
--vcpu COUNT Number of vCPUs 2
-h, --help Show help -

Press Ctrl-a x to force-quit QEMU at any time.

Nix build-time 'arguments'

Some features may need to be added at build-time for the VM itself, and can thus not be enabled with simple script --flags. For such features we have a way to easily pass 'arguments' to the tool before it builds itself. This is done by specifying a nested attribute that corresponds to a defined argument in the tool you want to use. The arguments can be specified in any order. For example:

# Runs the base claude tool with no build-time arguments
nix run .#claude

# Runs the claude tool with argument `devenv`, which then gets used to modify the build
nix run .#claude.devenv

# Run with arguments `foo` and `devenv`
nix run .#claude.foo.devenv
Argument Description
devenv Adds devenv to the runner script's PATH
docker Adds a rootless Docker daemon (with the compose and buildx plugins) to the VM and whitelists Docker Hub
virtiofs Shares host directories over virtiofs instead of 9p (see below)

virtiofs

nix run .#claude.virtiofs mounts the workspace, the host /nix/store and all other host directories with virtiofs instead of 9p. Metadata-heavy work is much faster: on a full Nixpkgs checkout, git status takes ~3.3s instead of ~29s.

One virtiofsd per shared directory is started as your user (no root needed). Unprivileged user namespaces must be allowed. virtiofsd runs with --sandbox=namespace, which confines it to the shared directory inside a user namespace.

Examples

Run Claude in dangerous mode for a fully autonomous task:

nix run .#claude -- --dangerous -- -p "Write hello to /workspace/hello.txt" --max-turns 3

Mount an extra directory and allocate more resources:

nix run .#claude -- --mount /tmp/data --mem 8192 --vcpu 4 -- -p "Process the dataset"

Enable git-over-SSH by mounting your SSH directory (read-only):

nix run .#claude -- --ro-mount ~/.ssh -- -p "Push the changes"

Use a nix dev shell inside the VM:

nix run .#claude -- --nix-env -- -p "Run the test suite"

Use virtiofs instead of 9p for faster mounts:

nix run .#claude.virtiofs

Use a devenv.sh shell inside the VM (requires --same-path, since devenv bakes the workspace's absolute host path into its environment):

nix run .#claude.devenv -- --devenv --same-path -- -p "Run the test suite"

Let the agent build and run containers. Images and layers are stored on the root filesystem, so use --disk to keep them out of RAM:

nix run .#claude.docker -- --disk 20 -- -p "Run the integration tests with docker compose"

The daemon is rootless: it runs as the agent's own unprivileged user, so having Docker doesn't give the agent root in the VM, and container traffic goes through the same network filter as the agent itself. Docker Hub (docker.io and its blob CDN hosts) is whitelisted automatically; other registries need --allow-domain (e.g. --allow-domain ghcr.io).

Allow access to additional domains (e.g. for package installs or git cloning):

nix run .#claude -- --allow-domain github.com --allow-domain registry.npmjs.org

Disable network filtering entirely:

nix run .#claude -- --no-net-filter

Run nix build, or anything else that writes a lot outside the mounts, with extra storage (the tmpfs root is limited to half the VM memory):

nix run .#claude -- --disk 20 -- -p "nix build and run the tests"

Drop into your own shell inside the sandbox to inspect mounts or reproduce tool behavior manually:

nix run .#shell                              # offline debugging shell
nix run .#shell -- --allow-domain github.com # opt in to specific domains
nix run .#shell -- --no-net-filter           # full network for debugging

The shell tool honors $SHELL (resolved through symlinks so the /nix/store path is used) and falls back to zsh or bash if the host shell isn't reachable inside the guest (e.g. on non-NixOS hosts). Shell rc files are not copied in; bring anything you need with --ro-mount.

What's isolated

Filesystem. The guest boots on a tmpfs root (or a throwaway ext4 disk with --disk). Only explicitly mounted directories are visible:

  • The current working directory -> /workspace (read-write)
  • The jail-private tool state dir ~/.config/llm-jail/<tool>/<profile> -> /home/user/.claude, .codex, .copilot, .opencode, .autolith, .pi, or .shell (read-write; Autolith uses its XDG roots plus CODEX_HOME/GROK_HOME; the other tools use CLAUDE_CONFIG_DIR, CODEX_HOME, COPILOT_HOME, XDG_DATA_HOME, PI_CODING_AGENT_DIR, or ZDOTDIR)
  • ~/.gitconfig is copied in (9p/virtiofs cannot mount single files)
  • Host system and user packages -> /host-sw, /host-user-sw (read-only, NixOS hosts only)
  • Any directories added via --mount / --ro-mount

All other host paths are invisible to the guest. Changes outside mounted directories are lost when the VM shuts down.

On NixOS hosts, system packages (/run/current-system/sw) and user packages (/etc/profiles/per-user/$USER) are automatically mounted and added to PATH, so tools like jj, ripgrep, etc. are available without hardcoding them in the guest.

Processes. The agent runs inside a full QEMU virtual machine - separate kernel, separate PID namespace. There is no shared process space with the host.

Environment variables. Only these are forwarded to the guest:

  • ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, CLAUDE_CODE_MAX_OUTPUT_TOKENS
  • OPENAI_API_KEY, OPENAI_BASE_URL
  • AWS_*

All other host environment variables are stripped.

Network. By default, outbound network access is restricted via DNS-based domain filtering and a port-level firewall:

  • DNS resolution is limited to tool-specific API domains via a local dnsmasq instance
  • Only HTTP/HTTPS traffic (ports 80/443) is allowed outbound; all other protocols are blocked by nftables
  • Custom API endpoints (via ANTHROPIC_BASE_URL / OPENAI_BASE_URL) are automatically whitelisted
  • Additional domains can be added with --allow-domain (subdomains are included automatically)
  • Use --no-net-filter to disable all network restrictions

Default allowed domains per tool:

Tool Domains
Claude api.anthropic.com, claude.ai, platform.claude.com, statsig.anthropic.com, sentry.io
Codex api.openai.com, auth.openai.com, chatgpt.com, sentry.io
Copilot github.com, api.github.com, api.individual.githubcopilot.com, copilot-proxy.githubusercontent.com, githubcopilot.com, collector.github.com, …
opencode models.dev, registry.npmjs.org, plus the major hosted providers and their login flows: api.anthropic.com, claude.ai, console.anthropic.com, api.openai.com, auth.openai.com, generativelanguage.googleapis.com, api.githubcopilot.com, openrouter.ai, … (account-specific endpoints like Bedrock/Azure/Vertex need --allow-domain)
Autolith auth.openai.com, chatgpt.com
Pi pi.dev, api.anthropic.com, api.openai.com, auth.openai.com, chatgpt.com, generativelanguage.googleapis.com, github.com, api.github.com, openrouter.ai, api.deepseek.com, api.mistral.ai, api.x.ai, api.groq.com, api.cerebras.ai
oh-my-pi api.anthropic.com, api.openai.com, auth.openai.com, chatgpt.com, generativelanguage.googleapis.com, github.com, api.github.com, openrouter.ai, api.deepseek.com, api.mistral.ai, api.x.ai, api.groq.com, api.cerebras.ai
+ docker build arg docker.io, production.cloudfront.docker.com, production.cloudflare.docker.com, docker-images-prod.6aa30f8b08e16409b46e0173d6de2f56.r2.cloudflarestorage.com

Note

Outbound HTTP/HTTPS is restricted to IPs that the guest's dnsmasq resolved through a whitelisted domain - every successful lookup populates an nftables set (allowed_ips), and the firewall only accepts packets whose destination is in that set. Connections to hardcoded IPs that bypass DNS hit the default drop. IPv6 outbound traffic is dropped outright (no IPv6 rules). This is robust against accidental or prompt-injected exfiltration as long as the whitelisted domains themselves aren't bidirectional data channels - see the dangerous-mode warning below.

Dangerous mode

Caution

Dangerous mode skips the tool's built-in permission prompts (--dangerously-skip-permissions for Claude, --dangerously-bypass-approvals-and-sandbox for Codex, --yolo for Copilot, --auto for opencode). Pi has no dangerous mode flag; project trust is set to trusted in the jail-private config so the tool does not prompt. The agent can execute arbitrary commands, write to any mounted directory, and make network requests without asking.

Network filtering remains active in dangerous mode - the agent can only reach whitelisted domains. To grant unrestricted network access, use --no-net-filter (this is independent of --dangerous).

Mitigations if you use dangerous mode:

  • Scope API keys to the minimum permissions needed
  • Avoid mounting directories containing secrets
  • Be cautious with --allow-domain - domains like github.com or npmjs.org are bidirectional and could be used for data exfiltration
  • Review agent output before trusting it

Without --dangerous, the tool's own permission system is active and will prompt before taking sensitive actions. This is the recommended mode for most use cases (when applicable).

How it works

+-- Host ----------------------------------------+
|  nix run .#claude                              |
|    v                                           |
|  writeShellApplication (mkRunner.nix)          |
|    - parses CLI args                           |
|    - writes env vars + tool args to tmpdir     |
|    - sets up 9p (or virtiofs) mounts           |
|    - optionally creates root disk image        |
|    - launches qemu-system-*                    |
+------------------+-----------------------------+
                   | QEMU (direct kernel boot)
+-- Guest (NixOS) -+-----------------------------+
|  /          <- tmpfs, or ext4 disk (--disk)    |
|  /nix/store <- overlay (host lower + rootfs)   |
|  /workspace <- 9p/virtiofs read-write          |
|                                                |
|  systemd                                       |
|    -> llmjail-mounts: mount host shares        |
|    -> llmjail-net-filter: dnsmasq + nftables   |
|    -> llmjail-tool: exec the tool binary       |
|                                                |
|  ExecStopPost: poweroff when tool exits        |
+------------------------------------------------+

No persistent disk images are involved. The guest kernel and initrd are built by NixOS and passed to QEMU via -kernel / -initrd. The host Nix store is shared read-only over 9p (or virtiofs with the virtiofs build-time argument) and used directly as the lower layer of a /nix/store overlay whose upper layer lives on the root filesystem. The root is a tmpfs by default; with --disk it is a sparse ext4 image instead, so everything the guest writes outside the host mounts (new store paths, /nix/var/nix/builds/, $HOME caches, Docker images) lands on disk rather than in RAM. The image is cleaned up automatically when the VM exits.

Overriding tool packages

Each runner is built with lib.makeOverridable, so the underlying tool package (claude-code, codex-cli, copilot-cli, opencode, autolith, or pi-coding-agent) can be swapped without forking the flake:

# flake.nix (consumer)
let
  llm-jail = inputs.llm-jail.packages.${system};
in
  llm-jail.claude.override {
    claude-code = my-pinned-claude-code;
  }

Useful for pinning a specific tool version, applying local patches, or testing an unreleased build.

Adding a new tool

  1. Add a guest module under guests/ (import common.nix, set llmjail.toolBinary and llmjail.dangerousFlag).
  2. Add an entry to tools.nix pointing at the new module.
  3. nix run .#your-tool - the flake generates a runner automatically.

License

This project is licensed under the MIT License.

About

A Nix script for running CLI coding tools in an isolated MicroVM environment

Resources

Security policy

Stars

32 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

0