Run coding agents inside isolated NixOS microVMs. The agent gets your project directory, a nix store, and outbound network — nothing else.
  • Shell 53.1%
  • Nix 46.9%
Find a file
2026-10-30 22:07:05 +01:00
bin Add 'ajail resize': grow volume images after first boot 2026-10-05 00:06:20 +02:00
modules Add REA reverse-engineering toolkit, registered for both agents 2026-10-16 11:22:53 +02:00
templates Add REA reverse-engineering toolkit, registered for both agents 2026-10-16 11:22:53 +02:00
tests Let ajail agent override a project flake's .#jail 2026-07-26 12:43:03 +02:00
.gitignore initial release v0.1 2026-06-13 16:14:17 +02:00
flake.lock bump flake.lock 2026-10-30 22:07:05 +01:00
flake.nix Track Kimi Code CLI, replacing the archived kimi-cli 2026-09-24 10:55:21 +02:00
README.md Add REA reverse-engineering toolkit, registered for both agents 2026-10-16 11:22:53 +02:00

ajail

Run coding agents inside isolated NixOS microVMs. The agent gets your project directory, a nix store, and outbound network — nothing else.

Why

Coding agents execute arbitrary commands as your user. A mistake (or a prompt injection) can read your SSH keys, push to your repos, or rm -rf things you care about. ajail puts the agent in a throwaway VM where it can only touch the project you gave it.

Features

  • Zero host setup — no root, no bridges, no taps, no firewall rules, nothing in /etc. Runs entirely as your user.
  • Per-project isolation — each project gets its own VM, keys, and state.
  • Host nix store shared read-only — the agent can nix build without re-downloading your existing packages.
  • Signed-only store sync — ajail sync imports guest-fetched paths into your host store, but only if they pass signature verification.
  • Optional WireGuard egress — drop a wg-quick config and all guest traffic is tunneled with a fail-closed killswitch.
  • Swappable agent — ships with Claude Code and Kimi Code CLI (ajail agent kimi); any agent that provides an executable works.
  • Rootless podman in the guest — the agent can build and run containers (images live on the /home volume; grow it with ajail resize home <MiB>).
  • REA reverse-engineering tools — the REA MCP server is pre-registered, with headless Ghidra, JDK 21, and Node.js baked into the guest (see Reverse engineering).
  • Editor integration — ACP-over-stdio for Zed (and anything else that speaks the protocol).

How it works

cloud-hypervisor with passt for unprivileged userspace networking. The guest is a minimal NixOS built by microvm.nix. Shares are mounted via virtiofsd at their original host paths so tools see the same absolute paths on both sides. The host nix store is mounted read-only; a persistent overlay lets the guest fetch and build into its own writable layer. SSH (key-only) rides a loopback-bound passt forward on a free port that ajail up picks per jail — run as many concurrent jails as you like with no port conflicts and no host setup beyond /dev/kvm.

Prerequisites

You need Nix in multi-user mode with flakes and KVM support. ajail init checks all of these automatically; ajail verify re-checks at any time.

Installing Nix

If you don't have Nix yet, install it in multi-user mode (the default on Linux). See the official Nix installation guide for details.

# official installer (multi-user is the default)
curl -L https://nixos.org/nix/install | sh -s -- --daemon

Configuring Nix

After installation, enable flakes and KVM support. Edit /etc/nix/nix.conf (system-wide, requires sudo) or ~/.config/nix/nix.conf (per-user):

experimental-features = nix-command flakes
system-features = nixos-test benchmark big-parallel kvm

Restart the daemon after editing:

sudo systemctl restart nix-daemon

KVM access

Your user needs access to /dev/kvm (typically owned by the kvm group):

sudo usermod -aG kvm $USER
# log out and back in (or: newgrp kvm)

require-sigs

Keep nix's default require-sigs = true. ajail sync imports with nix copy (never --no-check-sigs), so the host daemon rejects any path lacking a trusted signature — this is what keeps a malicious guest from injecting store paths. Being in trusted-users is fine (the daemon still enforces signatures on nix copy for trusted users); only require-sigs = false defeats it. ajail verify flags that. See Store architecture for the mechanism.

Install

nix profile install 'git+https://git.nixfritz.de/max/ajail.git'

# from a local checkout
nix profile install /path/to/ajail

# upgrade
nix profile upgrade ajail

Quick start

cd ~/code/myproject

ajail init                           # verify host + generate keys/state
ajail up                             # boot the microVM

# optional
ajail vpn set /path/to/wg.conf      # tunnel all egress through WireGuard
ajail share add ~/code/libfoo        # grant another repo (read-only)
ajail share add ~/code/libfoo rw     # ... or read-write, if you mean it
ajail agent kimi                     # run Kimi Code CLI instead of Claude Code

ajail attach                         # interactive agent session
ajail down                           # stop everything

vpn set, vpn off, share add, share rm, and agent automatically restart the jail if it is running.

Commands

ajail v2 — agent jail launcher (unprivileged, zero host setup)

  init             verify host + keys + state dirs (per project)
  info             state location, ssh port, shares
  list             all jails on this machine (orphans marked)
  gc               interactively remove orphaned state dirs
  verify           check host prerequisites (nix, kvm, config)
  up | down        start/stop the microVM + share backends
  status           unit status
  mem [<MiB>]      show / set live guest RAM (virtio-mem)
  resize <vol> <MiB>  grow a volume image + filesystem (grow-only)
  attach [args]    interactive agent session (project dir)
  acp              ACP-over-stdio (editor integration)
  ssh [cmd]        agent shell (commands run in the project dir)
  debug [cmd]      debug shell: wheel + passwordless sudo, own keypair
  share            list | add <abs-path> [rw] | rm <abs-path>  (ro default)
  agent [name]     show / set the agent (claude, kimi)
  vpn              set <wg.conf> | off | status
  test             smoke test inside the guest
  sync             host-verified import of guest-fetched store paths

Store architecture

The host /nix/store is mounted read-only into the guest. On top of it, a persistent overlay (store-overlay.img) gives the guest a writable layer for packages it fetches or builds itself. This means:

  • The agent reuses everything already in your host store (no re-downloads).
  • Anything the agent fetches lands only in the overlay — the host store is never modified by the guest.
  • ajail sync copies guest-fetched paths back to the host store with nix copy, but only signed paths are accepted. Because ajail never passes --no-check-sigs, the host nix-daemon enforces require-sigs and refuses any path lacking a valid signature by a key in your trusted-public-keys. Guest-built paths are unsigned and rejected; cache-fetched paths keep their cache.nixos.org signature and pass.

This enforcement is done by the daemon at import, so it holds for trusted and untrusted users alike — nix copy (unlike nix-store --import) always asks the daemon to check signatures. The only setting that defeats it is require-sigs = false, which ajail verify flags.

The guest has four persistent volumes:

Volume Mount Purpose
store-overlay.img /nix/.rw-store Guest-fetched store paths
home.img /home Agent home directory
nix-var.img /nix/var/nix Nix DB (validity, so fetches persist across reboots)
nix-build.img /nix/build Disk-backed Nix build scratch (so big builds don't exhaust the tmpfs root)

To reset the guest to a clean slate, stop the jail and delete the vm/ directory inside the state dir (ajail info shows the path).

Danger mode (no permission prompts)

Inside a throwaway VM, Claude Code's per-action permission prompt is a second boundary on top of the one that actually contains the agent (the VM). So ajail defaults the agent to bypassPermissions — it runs without asking — and sets IS_SANDBOX=1 so this engages non-interactively (no disclaimer, no non-root nag). The jail is the boundary; the prompt was just friction.

This is not a blank cheque. The VM protects the host, kernel, host keys, and the (signature-gated) host store — but it does not protect the two surfaces you handed in:

  • Writable shared paths. p0 (the project) is read-write by design and is a real host path — the agent can rewrite or delete it with no prompt, and uncommitted work is unrecoverable. Commit before you attach. Extra shares are read-only by default (see Limitations) to keep this blast radius small.
  • Open egress. With no prompts, a prompt-injected agent can exfiltrate anything it can read in shared paths. Use ajail vpn for a killswitched tunnel, and don't put secrets in shared paths.

Claude Code still prompts for a few hardcoded dangerous commands (rm -rf /, rm -rf ~) and honours deny rules and PreToolUse hooks even in bypass mode.

To restore normal prompting, build with mkJail { dangerMode = false; }.

Reverse engineering (REA + headless Ghidra)

The guest is wired for REA, an MCP toolkit that lets the agent inspect native binaries, JavaScript/Electron apps, .NET assemblies and more. The MCP server is registered as rea in the agent's user-level MCP config — ~/.claude.json for Claude Code, ~/.kimi-code/mcp.json for Kimi Code — and starts via npx -y rea-agents@6.5.0 mcp; the first call downloads the package from npm, afterwards it is cached on the persistent /home volume.

Both files are seeded copy-if-absent, so a /home that already has one keeps it untouched. In particular, ~/.claude.json is Claude Code's state file: on a home that has run Claude before, register REA once by hand with claude mcp add --scope user rea -- npx -y rea-agents@6.5.0 mcp. Other modules can pre-register servers the same way through the agent-neutral ajail.mcpServers option (modules/mcp.nix).

The toolchain REA shells out to is baked into the guest image:

  • Headless Ghidra 12.1.x (from nixpkgs-unstable; nixos-26.05 pins 12.0.x, which REA rejects) with GHIDRA_INSTALL_DIR preset, plus a full JDK 21 on JAVA_HOME — REA validates both before using the provider.
  • Node.js 24 + npm — REA's runtime (^22.19 || ^24.11 || >=26).
  • procps — REA's Linux process-ownership checks require a procps-compatible ps before it will launch Ghidra.

Verify inside the jail with ajail ssh npx rea-agents@6.5.0 doctor --provider ghidra --json. To also install REA's bundled workflow skill, run npx rea-agents@latest setup inside the jail once (it rewrites the seeded registration; bump the pinned version in modules/rea.nix when you do).

Localization (timezone & locale)

The guest mirrors the host's timezone and locale so timestamps, file metadata, and date match what you see on the host. ajail up captures the host's TZ and LANG/LC_* into the read-only state share on every start, so the guest follows the host dynamically — change your host locale, restart the jail, done. Nothing is baked in.

The guest generates the full locale archive by default (supportedLocales = [ "all" ]) so any host locale resolves at runtime. NixOS can only use a locale it generated at build time, so this is what makes the mirror host-agnostic. To trim the closure on a single-locale host, override it:

ajail.lib.mkJail {
  inherit system;
  supportedLocales = [ "en_US.UTF-8/UTF-8" "de_DE.UTF-8/UTF-8" ];
}

Managing state

Per-project state lives outside the project tree (the project dir is agent-writable — keys inside it would be exposed):

~/.local/state/ajail/<project>-<hash>/
  port                   loopback ssh port (free, unique per jail)
  id_ed25519(.pub)       agent SSH key
  id_debug(.pub)         debug SSH key
  state/                 read-only share into the guest
    authorized_keys, ssh_host_ed25519_key
    mounts.conf, wg.conf (if VPN configured)
  vm/                    runner symlink + volume images
  shares                 extra repo grants
  agent                  agent module for the generic jail (default: claude)
ajail info                           # print state dir, ssh port, shares
ajail list                           # all jails on this machine
ajail gc                             # remove orphaned state dirs
ajail down && rm -rf <state-dir>     # wipe a jail completely

Editor integration

ajail speaks ACP (Agent Client Protocol) over stdio via ajail acp. Any editor that supports ACP can use it. Shares are mounted at their original host paths, so the editor and the agent agree on all file locations.

Zed

Add to ~/.config/zed/settings.json (open with zed: open settings from the command palette):

{
  "agent_servers": {
    "Agent (jailed)": {
      "type": "custom",
      "command": "ajail",
      "args": ["acp"]
    }
  }
}

VS Code

Install the VSCode ACP extension, then add to your settings.json (Ctrl+Shift+P -> Preferences: Open User Settings (JSON)):

{
  "acp.agents": {
    "Agent (jailed)": {
      "command": "ajail",
      "args": ["acp"]
    }
  }
}

Neovim

Use agentic.nvim or any ACP-compatible plugin. Example with agentic.nvim:

require("agentic").setup({
  agents = {
    ["ajail"] = {
      command = "ajail",
      args = { "acp" },
    },
  },
})

Advanced: custom jails

The generic jail works out of the box. For customization — staging a toolchain, swapping the agent — add a project flake:

nix flake init -t 'git+https://git.example.com/you/ajail#project'

Example:

{
  inputs = {
    ajail.url = "git+https://git.example.com/you/ajail";
    nixpkgs.follows = "ajail/nixpkgs";
  };

  outputs = { self, ajail, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      devShells.${system}.default = pkgs.mkShell {
        packages = with pkgs; [ rustc cargo nodejs ];
      };

      packages.${system}.jail = ajail.lib.mkJail {
        inherit system;
        devShell = self.devShells.${system}.default;
        # agent.module = ./my-agent.nix; # swap the agent
      };
    };
}

mkJail parameters

Parameter Default Description
system x86_64-linux Target system
agent Claude Code { module = ./path/to/agent.nix; }
devShell none Project toolchain staged into guest
vcpu 4 Virtual CPUs
mem 4096 Guest memory (MiB)
storeOverlaySize 32768 Store overlay volume (MiB)
homeSize 12288 /home volume (MiB)
supportedLocales [ "all" ] Locale archive (see Localization)
dangerMode true Agent starts in bypassPermissions
extraModules [] Additional NixOS modules

Volume sizes are first-boot only — the images are created once at the declared size. Grow an existing jail later with ajail resize <vol> <MiB> (grow-only).

Agents

Every module in modules/agents/ becomes a jail package (jail-<name>), and ajail agent <name> picks which one runs:

ajail agent          # what this jail runs (default: claude)
ajail agent kimi     # switch; restarts the jail if it is up
ajail agent auto     # drop the override

The choice lives in the state dir, never in the project tree — the same rule as share grants, since anything under the project is agent-writable.

Setting an agent is an override: it forces the generic jail-<name> even for a project that ships its own flake, because otherwise the choice would be unreachable for every project whose flake owns .#jail (ajail's own repo among them). The trade is that the flake's staging — devShell, VPN, volume sizes — is bypassed while the override stands, which up announces. ajail agent auto restores the normal order: the project flake's .#jail if there is one, else the generic jail with claude. To pin an agent and keep a project's staging, set agent.module in that project's mkJail instead.

Agent Payload Notes
claude claude-code from nixpkgs-unstable default; ACP via @zed-industries/claude-code-acp
kimi kimi-code upstream flake nix flake update kimi-code to bump; ACP built in (kimi acp)

Kimi specifics worth knowing:

  • Log in once per jail: /login inside ajail attach asks which platform (kimi.com/code or kimi.ai/code) and then runs a device-code exchange; ajail ssh agent login --region global (or mainland-cn) does the same from outside. Pick explicitly — a nix-installed CLI has none of the installer's region marker, and the unmarked default is mainland-cn. Credentials persist in home.img under ~/.kimi-code/credentials/.
  • Danger mode is seeded as default_permission_mode = "yolo" in ~/.kimi-code/config.toml (copy-if-absent) rather than passed as --yolo, so it holds however the agent is started and the agent can still flip it at runtime. Set "manual" there to get approval prompts back, or go the other way with "auto" — that one never asks at all, including questions the agent would otherwise put to you.
  • ajail acp is the exception: an ACP session starts in manual approval whatever the config says, and the editor drives the mode over the protocol.
  • The sandbox contract is wired the same way as for Claude Code: ~/.kimi-code/AGENTS.md is a symlink to /etc/AGENT.md, which Kimi reads as user-global instructions ahead of any project AGENTS.md. Unlike the old kimi-cli seam, the ACP path carries it too.
  • Auto-update is off (KIMI_CODE_NO_AUTO_UPDATE=1 in both wrappers). Left on, the CLI would download a generic-linux build into ~/.kimi-code and swap it in — a binary that cannot run on NixOS, and a silent escape from the pin in flake.lock. Bump through nix flake update kimi-code instead.
  • Homes created before 2026-09 hold the archived Python kimi-cli's state in ~/.kimi; ajail ssh agent migrate --run imports config, MCP servers and skills (add --config-only to skip chat sessions).

Adding another agent

Drop a module in modules/agents/<name>.nix — it is picked up by jail-<name> and by ajail agent <name> automatically. The module must provide:

  1. An agent executable — the interactive TUI invoked by ajail attach
  2. Optionally agent-acp — ACP-over-stdio for editor integration
  3. Wire its context file to /etc/AGENT.md

Minimal example:

# modules/agents/my-agent.nix
{ pkgs, ... }:
let
  wrapper = pkgs.writeShellScriptBin "agent" ''
    exec ${pkgs.my-agent-package}/bin/my-agent "$@"
  '';
in {
  environment.systemPackages = [ pkgs.my-agent-package wrapper ];

  systemd.tmpfiles.rules = [
    "d  /home/agent/.config/my-agent  0755 agent agent -"
    "L+ /home/agent/.config/my-agent/context.md - - - - /etc/AGENT.md"
  ];
}

Use it — from this repo, ajail agent my-agent; from a project flake:

packages.${system}.jail = ajail.lib.mkJail {
  inherit system;
  agent.module = ./modules/agents/my-agent.nix;
};

Limitations

  • Linux only — requires /dev/kvm (cloud-hypervisor + passt); no macOS or WSL support.
  • No whitespace in paths — share paths and project paths must not contain spaces.
  • Open egress by default — the guest can reach the internet (as your user's sockets). The VPN option restricts this but is not the default. The passt gateway (10.0.2.2) is always blocked, and passt's --no-map-gw keeps host loopback unreachable.
  • x86_64 only — no aarch64 support currently.
  • No domain/port filtering — fine-grained egress control (allow only specific hosts) would require a host-side proxy or the v1 bridge+nftables design.
  • Agent can read all files in shared paths — the boundary is at the share level. You cannot hide individual files within a shared directory. Extra shares are read-only by default (rw is opt-in), so a grant is not a write grant unless you say so; the project dir (p0) is always read-write — that is the point of the jail.

Testing

nix flake check -L          # shellcheck + builds + socket-layout guard
ajail up && ajail test       # guest-enforced boundary assertions

Layout

flake.nix                 lib.mkJail, guest module, launcher, generic jail, checks
modules/jail.nix          parameters -> microvm runner
modules/guest.nix         cloud-hypervisor/passt, store overlay, shares, guard, users, wg
modules/mcp.nix           ajail.mcpServers: agent-neutral MCP registry, rendered per agent
modules/rea.nix           REA MCP registration + ghidra/JDK/node toolchain
modules/agents/*.nix      agent payloads (claude default, kimi) -> jail-<name>
bin/ajail                 unprivileged lifecycle CLI
tests/smoke.sh            guest-boundary assertions
tests/share-cli.sh        host-side share round-trip tests
templates/                AGENT.md contract, optional project flake