- Shell 53.1%
- Nix 46.9%
| bin | ||
| modules | ||
| templates | ||
| tests | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
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 buildwithout re-downloading your existing packages. - Signed-only store sync —
ajail syncimports guest-fetched paths into your host store, but only if they pass signature verification. - Optional WireGuard egress — drop a
wg-quickconfig 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 synccopies guest-fetched paths back to the host store withnix copy, but only signed paths are accepted. Because ajail never passes--no-check-sigs, the host nix-daemon enforcesrequire-sigsand refuses any path lacking a valid signature by a key in yourtrusted-public-keys. Guest-built paths are unsigned and rejected; cache-fetched paths keep theircache.nixos.orgsignature 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 youattach. 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 vpnfor 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_DIRpreset, plus a full JDK 21 onJAVA_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
psbefore 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:
/logininsideajail attachasks which platform (kimi.com/codeorkimi.ai/code) and then runs a device-code exchange;ajail ssh agent login --region global(ormainland-cn) does the same from outside. Pick explicitly — a nix-installed CLI has none of the installer's region marker, and the unmarked default ismainland-cn. Credentials persist inhome.imgunder~/.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 acpis 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.mdis a symlink to/etc/AGENT.md, which Kimi reads as user-global instructions ahead of any projectAGENTS.md. Unlike the old kimi-cli seam, the ACP path carries it too. - Auto-update is off (
KIMI_CODE_NO_AUTO_UPDATE=1in both wrappers). Left on, the CLI would download a generic-linux build into~/.kimi-codeand swap it in — a binary that cannot run on NixOS, and a silent escape from the pin inflake.lock. Bump throughnix flake update kimi-codeinstead. - Homes created before 2026-09 hold the archived Python
kimi-cli's state in~/.kimi;ajail ssh agent migrate --runimports config, MCP servers and skills (add--config-onlyto 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:
- An
agentexecutable — the interactive TUI invoked byajail attach - Optionally
agent-acp— ACP-over-stdio for editor integration - 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-gwkeeps 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 (
rwis 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